Compare commits

..
Author SHA1 Message Date
fred 80b8f11046 docs(l0): declaration grammar moves to .mosaic/repo.json (#1216)
ci/woodpecker/pr/ci Pipeline was successful
Amendment per operator GO (ms-grill-me design round, 2026-08-20):

- Declaration is .mosaic/repo.json under integration_trunk, not a
  byte-exact AGENTS.md line. Unknown/misspelled keys and unparsable
  files are a structural hard stop; no prose grammar to near-miss
  (Q15 answer, plan D3/D9).
- Changing an existing declaration is operator-owned, above ordinary
  PR review (SF2, plan D10).
- Seeds this repo's declaration: integration_trunk next,
  release_branch main — prevents the absent-declaration default from
  rebinding the stack to main at land time.
- Gloss references in BOOTSTRAP/CODE-REVIEW/CI-CD-PIPELINES point at
  repo.json; merge of origin/next restores pinned-image CI coverage
  for this pre-pin branch (D27 caveat).
2026-08-20 12:52:49 -05:00
fred 3acd3de462 Merge remote-tracking branch 'origin/next' into docs/1216-trunk-parameterization 2026-08-20 12:52:13 -05:00
Jason WoltjeandClaude Fable 5 18f960d3e1 docs(l0): parameterize hard gates on the project's integration trunk
ci/woodpecker/pr/ci Pipeline was successful
Per Jason's Option-A ruling on #1216: the constitution supplies the general
framework; the project declares its own flow. Defines the integration trunk
once in CONSTITUTION.md Hard Gates — declared by exactly one
'Integration trunk: <branch>' line in the project's root AGENTS.md, default
'main', value bound by git check-ref-format --branch semantics, malformed or
multiple declarations are a hard stop, one trunk only, no gate relaxation —
and binds gates 5/15, E2E-DELIVERY, CODE-REVIEW, ORCHESTRATOR(-PROTOCOL),
BOOTSTRAP provisioning, and the defaults AGENTS session closure to that term.
CI-CD-PIPELINES YAML examples stay on the default trunk with a substitution
note.

Known mechanized consumer left as tracked follow-up on #1216: pr-merge.sh:133
hardcodes a {main,next} base allowlist — fail-closed (refuses exotic trunks,
cannot misroute), to be parameterized with red-first tests separately.

Refs #1216

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-13 13:45:49 -05:00
13 changed files with 28 additions and 596 deletions
-67
View File
@@ -81,73 +81,6 @@ pnpm format:check # Prettier check
pnpm build # Build all packages and applications pnpm build # Build all packages and applications
``` ```
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
docs, and policy changes all take the same route; urgency changes queue priority,
never the route. Agents never commit to or merge into `main`.
| Branch | Role | Who merges into it |
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `next` | Integration trunk — the only PR target for contributions | The designated merge-gate agent, after all gates pass. Never the PR author. |
| `main` | Stable/release line — receives promotion merges from `next` only | Jason only (or an agent he explicitly delegates for a named promotion). |
### Contribution sequencing (in order, no skipping)
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
issue number appears in the branch name and the PR body.
2. **Branch from the current `origin/next` head.** Name it
`feat/…`, `fix/…`, `docs/…`, or `test/…` with the issue number
(e.g. `docs/1214-branch-process`). Record the base SHA in the PR body.
3. **Develop with evidence.** Applicable tests accompany the change. Hooks are
never bypassed (`--no-verify` is prohibited). Stage explicit paths — never
`git add -A`.
4. **Open the PR against `next`.** The body states: scope, base SHA,
verification commands with results, and any known pre-existing failures on
the base — documented, not retried to green and not absorbed silently.
5. **CI must be terminal-green on the exact head.** All bounded Woodpecker
steps succeed (`verify-terminal-green` contract). Pipelines for fork PRs
start `blocked`; a maintainer approves the run — approving CI is not
approving the PR.
6. **Independent review. Self-merge is prohibited** — for every agent, on every
PR, including trivial ones. Where the change touches protected or
contract-bearing content, the reviewer verifies the exact head
(exact-byte/exact-blob comparison), not a description of it. An `AMEND`
verdict returns the PR to its author; the reviewer's gate stays held until
a fresh exact head passes.
7. **Merge into `next`** happens only after CI green + review pass, pinned to
the reviewed head SHA (a post-review push voids the review).
8. **Promotion `next` → `main`** is a deliberate, Jason-owned reconciliation
merge — not part of any contribution's lifecycle. Contributors are done at
step 7.
### Responsibilities
- **Contributor** — base pinning, green CI, evidence in the PR body,
responding to AMEND verdicts, never merging own work.
- **Reviewer / merge gate** — independent verification on the exact head;
holds and lifts gates; executes the merge into `next`.
- **Orchestrator / adjudicator** — cross-PR sequencing, disposition when PRs
collide, conflict adjudication.
- **Jason** — `next``main` promotions, merge-authority grants, collaborator
and token provisioning. Agents cannot grant themselves or each other any of
these.
### Hotfixes and divergence
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
Committing the fix to `main` directly is prohibited even under pressure.
- **Never land work on `main` that is not on `next`.** This has happened
(issue #1152's goal controller reached `main` without reaching `next`) and
every later PR paid for it. If it happens anyway: transplant the work onto
a `next`-based branch with provenance-preserving commits
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
through the normal gates, and let promotion re-align `main`. Do not
hand-patch `main` to compensate.
- Force-pushing a branch you do not own is prohibited; rebasing your own PR
branch is fine before review, and voids any review already given.
## Database and Local Runtime Safety ## Database and Local Runtime Safety
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset. - Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
@@ -292,16 +292,6 @@ esac
_build_runtime_bin_prefix() { _build_runtime_bin_prefix() {
local candidates=() local candidates=()
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
# A host with no system Node gets one bootstrapped here by tools/install.sh, which
# records it in ~/.profile. The fleet unit runs `env -i ... bash --noprofile --norc`
# by design, so ~/.profile is never read and the directory has to be named here.
# The npm probe below cannot cover this: it reports a package prefix
# (~/.npm-global), never a Node runtime directory. It sits ahead of the npm probe so
# the bootstrapped runtime wins on a host that has both — that is the one the installer
# verified — while an explicit MOSAIC_RUNTIME_BIN still outranks it.
# Runtime binaries are `#!/usr/bin/env node`, so without this the pane resolves the
# binary and then dies on `env: 'node': No such file or directory`.
candidates+=("$PANE_HOME/.mosaic/node/current/bin")
if command -v npm >/dev/null 2>&1; then if command -v npm >/dev/null 2>&1; then
local npm_prefix local npm_prefix
npm_prefix=$(npm config get prefix 2>/dev/null) || true npm_prefix=$(npm config get prefix 2>/dev/null) || true
@@ -520,93 +520,6 @@ for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked" contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
done done
# #1256. On a host with no system Node, tools/install.sh bootstraps one into
# ~/.mosaic/node/ and writes that directory to ~/.profile. The fleet unit runs
# `env -i ... bash --noprofile --norc`, so ~/.profile is never read — correctly, by
# design — and _build_runtime_bin_prefix does not list the bootstrap directory. Its
# `npm config get prefix` branch cannot cover the gap either: the installer points
# npm's prefix at ~/.npm-global, so that branch contributes the npm-global directory
# and never the Node one, however it resolves.
#
# The property under test is not "the string is in PATH". It is that the pane can
# EXECUTE a Node-shebang runtime binary — which is what `mosaic` is
# (`#!/usr/bin/env node`) and what actually failed: measured on a greenfield VM as
# `env: 'node': No such file or directory` after a clean install that reported success.
#
# So this case runs the pane for real and requires it to have run. A PATH-substring
# assertion would pass on a fix that put the directory in the wrong position, and it
# would keep passing if the pane later stopped running for some unrelated reason.
: > "$TMUX_CALLS"
HOME_NODE="$ROOT/bootstrap-node/.config/mosaic"
write_generated "$HOME_NODE" "coder-node"
NODE_PANE_HOME="${HOME_NODE%/.config/mosaic}"
NODE_BOOTSTRAP_BIN="$NODE_PANE_HOME/.mosaic/node/current/bin"
mkdir -p "$NODE_BOOTSTRAP_BIN"
# The bootstrapped runtime. It records that it ran, which is the evidence this case
# turns on: no node reachable from the pane means no marker.
cat > "$NODE_BOOTSTRAP_BIN/node" <<'SHIM'
#!/usr/bin/env bash
set -euo pipefail
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
SHIM
chmod +x "$NODE_BOOTSTRAP_BIN/node"
# write_generated plants its symlinks under the MOSAIC_HOME it is given; here the
# pane's HOME is the trusted parent, so the pane's view of "installed" is this
# directory instead. `pi` is what #1241 resolves against PANE_PATH; `mosaic` is what
# the pane then executes, and it is a Node script — not a bash script that would run
# anywhere and quietly hide the defect.
mkdir -p "$NODE_PANE_HOME/.npm-global/bin"
ln -sf "$FAKE_BIN/pi" "$NODE_PANE_HOME/.npm-global/bin/pi"
printf '#!/usr/bin/env node\n' > "$NODE_PANE_HOME/.npm-global/bin/mosaic"
chmod +x "$NODE_PANE_HOME/.npm-global/bin/mosaic"
# The npm branch is modelled ALIVE and still cannot close the gap, which is the
# stronger statement. An earlier draft of this case tried to model npm as absent —
# true on a real bootstrap host, where npm lives only in the Node directory — and it
# refused to run anywhere npm is in the system path, i.e. most machines. It was also
# the weaker claim: it would have proven only that a dead branch supplies nothing.
#
# On a bootstrap host the installer sets npm's prefix to ~/.npm-global. So even with
# `command -v npm` true and the branch executing, `npm config get prefix` yields the
# npm-global directory and never the Node one. The gap does not depend on whether
# that branch runs.
NODE_LAUNCHER_BIN="$ROOT/bootstrap-node-launcher-bin"
mkdir -p "$NODE_LAUNCHER_BIN"
ln -sf "$FAKE_BIN/tmux" "$NODE_LAUNCHER_BIN/tmux"
ln -sf "$FAKE_BIN/npm" "$NODE_LAUNCHER_BIN/npm"
/usr/bin/env -i \
"HOME=$NODE_PANE_HOME" \
"PATH=$NODE_LAUNCHER_BIN:/usr/bin:/bin" \
"MOSAIC_HOME=$HOME_NODE" \
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
"MOSAIC_TEST_HOME=$NODE_PANE_HOME" \
"MOSAIC_TEST_NPM_PREFIX=$NODE_PANE_HOME/.npm-global" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_TEST_EXECUTE_PANE=1 \
"MOSAIC_TEST_PANE_PID=$$" \
"$START" coder-node
[ -f "$HOME_NODE/fleet/pane-environment" ] || \
fail "pane could not execute a Node-shebang runtime: $NODE_BOOTSTRAP_BIN is absent from PANE_PATH (#1256)"
node_pane_environment=$(tr '\0' '\n' < "$HOME_NODE/fleet/pane-environment")
# Colon-pad and match a whole element. A regex with `(^|:)` after `.*` looks like it
# does this and does not: an anchor cannot match mid-pattern, so it silently requires
# a leading colon and rejects the directory in FIRST position — which is where THIS
# FIXTURE puts it: it runs under `env -i` with no MOSAIC_RUNTIME_BIN, so the bootstrap
# directory leads. That is a property of the fixture, not of the fix — in general the
# directory sits second, after MOSAIC_RUNTIME_BIN. The colon padding makes the
# assertion position-independent either way, which is why it is written this way and
# not with an anchor. That produced a failure reading "pane ran but PANE_PATH does not
# carry <dir>" against a PATH whose first element was that dir.
node_pane_path=":$(printf '%s\n' "$node_pane_environment" | sed -n 's/^PATH=//p' | head -1):"
case "$node_pane_path" in
*":$NODE_BOOTSTRAP_BIN:"*) ;;
*) fail "pane ran but PANE_PATH does not carry $NODE_BOOTSTRAP_BIN (PATH=$node_pane_path)" ;;
esac
write_interaction_generated() { write_interaction_generated() {
local home="$1" local home="$1"
local agent="$2" local agent="$2"
@@ -128,7 +128,6 @@ BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print(
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')" HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')" HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')" HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')" PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')" PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
@@ -145,19 +144,10 @@ if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
fi fi
if [[ "$DRY_RUN" != true ]]; then if [[ "$DRY_RUN" != true ]]; then
# CI statuses for a PR live on the BASE repo (Woodpecker posts there),
# even when the head branch lives in a fork. Reading status against the
# fork repo yields statuses:null -> malformed for every fork PR (#1215,
# gate-merge-01 B1). The head repo is used only for head-sha identity;
# when metadata carries no base repository, the origin repo is where CI
# posts and remains correct for same-repo PRs.
if [[ -z "$BASE_REPO" ]]; then
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
fi
"$SCRIPT_DIR/ci-queue-wait.sh" \ "$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \ --purpose merge \
-B "$HEAD_BRANCH" \ -B "$HEAD_BRANCH" \
-R "$BASE_REPO" \ -R "$HEAD_REPO" \
--sha "$HEAD_SHA" \ --sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \ -t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}" -i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
@@ -209,10 +209,6 @@ base_ref = first_non_empty(
data.get('base_ref'), data.get('base_ref'),
data.get('base_label'), data.get('base_label'),
) )
base_repo = first_non_empty(
nested(data, 'base', 'repo', 'full_name'),
nested(data, 'base', 'repo', 'name_with_owner'),
)
if not head_ref or not base_ref: if not head_ref or not base_ref:
available = ', '.join(sorted(data.keys())) available = ', '.join(sorted(data.keys()))
@@ -233,7 +229,6 @@ normalized = {
'headRefOid': head_sha, 'headRefOid': head_sha,
'headRepository': head_repo, 'headRepository': head_repo,
'baseRefName': base_ref, 'baseRefName': base_ref,
'baseRepository': base_repo,
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)], 'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)], 'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
'milestone': nested(data, 'milestone', 'title') or '', 'milestone': nested(data, 'milestone', 'title') or '',
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
# B1 (stack #1215, gate-merge-01): for a fork PR the merge queue guard must
# read CI status against the BASE repository. Woodpecker posts statuses on the
# base repo; pr-metadata's headRepository names the fork, and passing it to
# ci-queue-wait yields statuses:null -> state=malformed rc=3 on every fork PR.
#
# This fixture omits baseRepository entirely (the pre-B1 normalizer's shape),
# so the guard must fall back to the origin repo — and must NEVER see the fork.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-fork-ci-status}"
FIXTURE_DIR="$WORK_DIR/tools/git"
CALL_LOG="$WORK_DIR/queue-call.log"
rm -rf "$WORK_DIR"
mkdir -p "$FIXTURE_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
exit 42
SH
chmod +x "$FIXTURE_DIR"/*.sh
# A git repo with an origin remote, so the origin fallback resolves.
git init -q "$WORK_DIR/upstream"
git -C "$WORK_DIR/upstream" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
set +e
(
cd "$WORK_DIR/upstream"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 1215
) >/dev/null 2>&1
rc=$?
set -e
if [[ "$rc" -ne 42 ]]; then
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
exit 1
fi
if [[ ! -s "$CALL_LOG" ]]; then
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
exit 1
fi
if grep -q -- '-R stack-mos-dt-0/stack' "$CALL_LOG"; then
echo "FAIL: queue guard received the FORK repository for CI status (B1 regression)" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the base (origin) repository" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-B fix/b1-fork-branch' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the PR head branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '--sha fedcba9876543210fedcba9876543210fedcba98' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the exact PR head SHA" >&2
cat "$CALL_LOG" >&2
exit 1
fi
echo "pr-merge fork-PR CI-status repository regression passed"
@@ -15,7 +15,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}' printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
SH SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
@@ -52,13 +52,8 @@ if grep -q -- '-B main' "$CALL_LOG"; then
cat "$CALL_LOG" >&2 cat "$CALL_LOG" >&2
exit 1 exit 1
fi fi
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the BASE repository for CI status" >&2 echo "FAIL: merge queue guard did not receive the fork head repository" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard received the fork head repository (B1: statuses are posted on the base repo)" >&2
cat "$CALL_LOG" >&2 cat "$CALL_LOG" >&2
exit 1 exit 1
fi fi
@@ -126,16 +126,7 @@ if [ -z "$SRC_LABEL" ]; then
src_host=$(hostname -s 2>/dev/null || echo "?") src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=${MOSAIC_AGENT_NAME:-} src_sess=${MOSAIC_AGENT_NAME:-}
if [ -z "$src_sess" ]; then if [ -z "$src_sess" ]; then
if [ -n "${TMUX:-}" ]; then src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
# Inside tmux: display-message resolves against this client's own session.
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
else
# Outside tmux with no name: display-message reports the LAST-ACTIVE
# session — someone else's identity (measured 2026-08-20: a nameless
# non-tmux sender was stamped "peggy", a live seat, forged silently).
# Stamp an explicit unverified label instead; deliberate senders use -S.
src_sess="unverified"
fi
fi fi
SRC_LABEL="${src_host}:${src_sess}" SRC_LABEL="${src_host}:${src_sess}"
fi fi
@@ -61,15 +61,8 @@ no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout. # Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; } run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
# Hermetic auto-label runs: TMUX is controlled explicitly so results never run_auto() {
# depend on whether the caller running this suite sits inside tmux. env -u MOSAIC_AGENT_NAME \
run_auto() { # models a sender OUTSIDE tmux (no client context)
env -u MOSAIC_AGENT_NAME -u TMUX \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \ AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@" bash "$TOOL" -n dsthost "$@"
} }
@@ -152,9 +145,7 @@ want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]" || no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
# 9. Without the env identity, self-lookup uses local tmux, not destination -L. # 9. Without the env identity, self-lookup uses local tmux, not destination -L.
# Sender is INSIDE tmux: the only context where display-message self-lookup got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
# is safe (it resolves against this client's own session).
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
want="[$src_host:local-agent -> dsthost:mos] local fallback" want="[$src_host:local-agent -> dsthost:mos] local fallback"
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \ [ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]" || no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
@@ -162,17 +153,8 @@ want="[$src_host:local-agent -> dsthost:mos] local fallback"
&& ok "cross-socket fallback rejects destination holder identity" \ && ok "cross-socket fallback rejects destination holder identity" \
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]" || no "cross-socket fallback rejects destination holder identity" "got=[$got]"
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
# someone else's identity (forgery vector). The label must be `unverified`,
# never a borrowed name, even though a tmux server exists here and the fake
# would confidently answer `local-agent`.
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
want="[$src_host:unverified -> dsthost:mos] no tmux context"
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
# 10. If neither env nor local tmux identifies the sender, preserve '?'. # 10. If neither env nor local tmux identifies the sender, preserve '?'.
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback") got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
want="[$src_host:? -> dsthost:mos] unknown fallback" want="[$src_host:? -> dsthost:mos] unknown fallback"
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \ [ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]" || no "unknown sender falls back to ?" "got=[$got] want=[$want]"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh" "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",
@@ -1,152 +0,0 @@
/**
* setupPath profile management (issue #1327, MOSAIC-IMPROVEMENTS 4c / D25).
*
* The profile append used to be guarded on the binDir value it was about to
* write, which is blind to accumulation across different Mosaic homes: every
* wizard run against a fresh temp home appended a permanent block to the
* operator's real shell profile (1,061 measured appends on sb-it-1-dt).
*
* Arms below map to the requirements:
* S1 sentinel-managed block, rewritten in place
* S2 a non-default target home never touches the operator profile
* S3 byte-identical profile across repeated runs
* S4 legacy unmarked `# Mosaic` blocks collapse into the managed block
* S5 the Windows ($env:Path) arm shares the same block logic
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
let profilePathMock: string | null = null;
vi.mock('../platform/detect.js', () => ({
getShellProfilePath: (): string | null => profilePathMock,
}));
import { setupPath, managedBlockFor, stripLegacyPathBlocks } from './finalize.js';
// The real resolved default on this host. Tests use it as the comparator a
// non-default home must fail against, exactly as the wizard would.
const REAL_DEFAULT_HOME = join(homedir(), '.config', 'mosaic');
function tempHome(prefix: string): string {
const dir = join(tmpdir(), prefix);
mkdirSync(join(dir, 'bin'), { recursive: true });
return dir;
}
describe('setupPath profile management (#1327)', () => {
let workDir: string;
let profileFile: string;
let defaultLikeHome: string;
let otherHome: string;
const baseline = '# existing operator content\nexport EDITOR=vim\n';
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'setuppath-spec-'));
profileFile = join(workDir, '.bashrc');
writeFileSync(profileFile, baseline, 'utf-8');
profilePathMock = profileFile;
defaultLikeHome = tempHome(join(workDir, 'home-a', '.config', 'mosaic'));
otherHome = tempHome(join(workDir, 'home-b', '.config', 'mosaic'));
});
afterEach(() => {
profilePathMock = null;
rmSync(workDir, { recursive: true, force: true });
});
// S2 — the arm that MUST fail against the pre-fix code: a home that is not
// the resolved default may not modify the operator profile at all.
it('does not touch the operator profile when the target home is not the resolved default', () => {
const action = setupPath(otherHome, REAL_DEFAULT_HOME);
expect(action).toBe('skipped');
expect(readFileSync(profileFile, 'utf-8')).toBe(baseline);
});
it('returns skipped when no shell profile can be resolved', () => {
profilePathMock = null;
const action = setupPath(defaultLikeHome, defaultLikeHome);
expect(action).toBe('skipped');
});
// S1 + S3 — two distinct homes (each run as the resolved default in turn,
// the shape of two legitimate installs against one operator profile) and
// repeated runs against the same home both leave exactly one block.
it('leaves exactly one managed block after runs against two distinct homes', () => {
const first = setupPath(defaultLikeHome, defaultLikeHome);
expect(first).toBe('added');
const second = setupPath(otherHome, otherHome);
expect(second).toBe('added');
const content = readFileSync(profileFile, 'utf-8');
const beginCount = content.split('# >>> mosaic begin >>>').length - 1;
const endCount = content.split('# <<< mosaic end <<<').length - 1;
expect(beginCount).toBe(1);
expect(endCount).toBe(1);
expect(content).toContain(join(otherHome, 'bin'));
expect(content).toContain(baseline);
});
it('is byte-identical across repeated runs against the same home', () => {
setupPath(defaultLikeHome, defaultLikeHome);
const afterFirst = readFileSync(profileFile, 'utf-8');
const again = setupPath(defaultLikeHome, defaultLikeHome);
expect(again).toBe('already');
expect(readFileSync(profileFile, 'utf-8')).toBe(afterFirst);
});
// S4 — pre-existing unmarked blocks from the old append logic collapse
// into the single managed block instead of accumulating beside it.
it('collapses legacy unmarked # Mosaic blocks into the managed block', () => {
const legacy =
'# existing operator content\n' +
'# Mosaic\n' +
'export PATH="/tmp/mosaic-dead-wizard-1/bin:$PATH"\n' +
'export EDITOR=vim\n' +
'# Mosaic\n' +
'export PATH="/tmp/mosaic-dead-wizard-2/bin:$PATH"\n';
writeFileSync(profileFile, legacy, 'utf-8');
const action = setupPath(defaultLikeHome, defaultLikeHome);
expect(action).toBe('added');
const content = readFileSync(profileFile, 'utf-8');
expect(content).not.toContain('/tmp/mosaic-dead-wizard-1/bin');
expect(content).not.toContain('/tmp/mosaic-dead-wizard-2/bin');
expect(content).toContain('export EDITOR=vim');
expect(content.split('# >>> mosaic begin >>>').length - 1).toBe(1);
expect(content).toContain(join(defaultLikeHome, 'bin'));
});
});
describe('managed block helpers (#1327)', () => {
// S5 — the Windows arm shares markers and shape with the POSIX arm.
it('builds the $env:Path variant inside the same markers', () => {
const block = managedBlockFor('C:\\Users\\op\\.config\\mosaic\\bin', true);
expect(block).toContain('# >>> mosaic begin >>>');
expect(block).toContain('# <<< mosaic end <<<');
expect(block).toContain('$env:Path = "C:\\Users\\op\\.config\\mosaic\\bin;$env:Path"');
});
it('builds the POSIX export variant inside the same markers', () => {
const block = managedBlockFor('/home/op/.config/mosaic/bin', false);
expect(block).toContain('# >>> mosaic begin >>>');
expect(block).toContain('export PATH="/home/op/.config/mosaic/bin:$PATH"');
expect(block).toContain('# <<< mosaic end <<<');
});
it('strips legacy $env:Path pairs on the Windows arm', () => {
const legacy =
'# Mosaic\n$env:Path = "C:\\tmp\\dead\\bin;$env:Path"\n' +
'# Mosaic\n$env:Path = "C:\\tmp\\dead2\\bin;$env:Path"\n' +
'Write-Host hi\n';
const stripped = stripLegacyPathBlocks(legacy, true);
expect(stripped).not.toContain('C:\\tmp\\dead');
expect(stripped).toContain('Write-Host hi');
});
});
+18 -74
View File
@@ -1,12 +1,11 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, readFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { platform } from 'node:os'; import { platform } from 'node:os';
import type { WizardPrompter } from '../prompter/interface.js'; import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js'; import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js'; import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js'; import { getShellProfilePath } from '../platform/detect.js';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { ManifestError } from '../framework/manifest.js'; import { ManifestError } from '../framework/manifest.js';
import { import {
getDefaultSkillPaths, getDefaultSkillPaths,
@@ -145,87 +144,32 @@ function runDoctor(mosaicHome: string): DoctorResult {
type PathAction = 'already' | 'added' | 'skipped'; type PathAction = 'already' | 'added' | 'skipped';
const PATH_BLOCK_BEGIN = '# >>> mosaic begin >>>'; function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
const PATH_BLOCK_END = '# <<< mosaic end <<<';
const PATH_BLOCK_NOTE = '# Managed by the Mosaic installer; this block is rewritten on install.';
/**
* The managed PATH block written into the operator's shell profile.
*
* The block is delimited by begin/end sentinels so any number of installs,
* against any homes, collapse to exactly one block: the writer replaces the
* region between the sentinels instead of appending a second copy (#1327).
*/
export function managedBlockFor(binDir: string, isWindows: boolean): string {
const exportLine = isWindows
? `$env:Path = "${binDir};$env:Path"`
: `export PATH="${binDir}:$PATH"`;
return `${PATH_BLOCK_BEGIN}\n${PATH_BLOCK_NOTE}\n${exportLine}\n${PATH_BLOCK_END}\n`;
}
/**
* Remove legacy unmarked `# Mosaic` PATH pairs appended by pre-#1327
* installs. Only the exact two-line shape this installer used to write is
* removed; any other `# Mosaic` comment line is left alone.
*/
export function stripLegacyPathBlocks(content: string, isWindows: boolean): string {
const legacyExport = isWindows ? /^\$env:Path = ".*;\$env:Path"$/ : /^export PATH=".*:\$PATH"$/;
const lines = content.split('\n');
const kept: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const next = i + 1 < lines.length ? lines[i + 1] : undefined;
if (line === '# Mosaic' && next !== undefined && legacyExport.test(next)) {
i += 1;
continue;
}
kept.push(line);
}
return kept.join('\n');
}
/** Drop the region between the managed-block sentinels, first occurrence. */
function withoutManagedBlock(content: string): string {
const beginIdx = content.indexOf(PATH_BLOCK_BEGIN);
if (beginIdx < 0) return content;
const endIdx = content.indexOf(PATH_BLOCK_END, beginIdx);
if (endIdx < 0) return content;
return content.slice(0, beginIdx) + content.slice(endIdx + PATH_BLOCK_END.length);
}
export function setupPath(mosaicHome: string, resolvedDefaultHome: string): PathAction {
// Never write outside the home under test (#1327 S2): a wizard run against
// a non-default home (test harnesses, throwaway installs) must not mutate
// the operator's real shell profile.
if (mosaicHome !== resolvedDefaultHome) {
return 'skipped';
}
const binDir = join(mosaicHome, 'bin'); const binDir = join(mosaicHome, 'bin');
const currentPath = process.env['PATH'] ?? '';
if (currentPath.includes(binDir)) {
return 'already';
}
const profilePath = getShellProfilePath(); const profilePath = getShellProfilePath();
if (!profilePath) return 'skipped'; if (!profilePath) return 'skipped';
const isWindows = platform() === 'win32'; const isWindows = platform() === 'win32';
const block = managedBlockFor(binDir, isWindows); const exportLine = isWindows
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
let content = ''; // Check if already in profile
if (existsSync(profilePath)) { if (existsSync(profilePath)) {
content = readFileSync(profilePath, 'utf-8'); const content = readFileSync(profilePath, 'utf-8');
} if (content.includes(binDir)) {
return 'already';
// Migration (#1327 S4): legacy unmarked blocks collapse into the managed }
// block, and an existing managed block is rewritten in place rather than
// appended beside itself (S1/S3).
const base = stripLegacyPathBlocks(withoutManagedBlock(content), isWindows);
const trimmed = base.replace(/\n+$/, '');
const next = trimmed.length === 0 ? block : `${trimmed}\n${block}`;
if (next === content) {
return 'already';
} }
try { try {
writeFileSync(profilePath, next, 'utf-8'); appendFileSync(profilePath, exportLine, 'utf-8');
return 'added'; return 'added';
} catch { } catch {
return 'skipped'; return 'skipped';
@@ -342,7 +286,7 @@ export async function finalizeStage(
} }
// 7. PATH setup // 7. PATH setup
const pathAction = setupPath(state.mosaicHome, DEFAULT_MOSAIC_HOME); const pathAction = setupPath(state.mosaicHome, p);
let summaryShown = false; let summaryShown = false;
const showSummary = () => { const showSummary = () => {
@@ -1,73 +0,0 @@
# 2026-08-17 — Fleet identity, comms delivery, and the ~/.mosaic tree (continuation record)
> **Status:** active continuation record | **Owner:** Jason (rulings) / fleet (delivery) | **Created:** 2026-08-17, sb-it-1-dt session with Jarvis (jarvis-brain)
> **Audience:** the homelab agents continuing this effort tonight. Read this whole file before acting; it supersedes nothing but preserves structure and decisions that must not be lost.
---
## Why this exists
A session on sb-it-1-dt (2026-08-17) produced three architecture decisions (two awaiting Jason's ruling), one incident postmortem (#1295), interim guardrail edits in the user-owned `~/.mosaic/` contract tree, and one new tool (`ensure-watcher.sh`). The work spans jarvis-brain (P0, not retained) and this repo (the product). **This file is the stack-side anchor so continuation does not depend on jarvis-brain surviving.**
## 1. The `~/.mosaic` tree model — as-built, preserve this structure
Three-tree split (this is design intent, not accident; keep it through all framework work):
| Tree | Owner | Rule |
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
| `~/.config/mosaic/` | framework | upgrade-managed templates; NEVER user-edited; `mosaic upgrade` may overwrite |
| `~/.mosaic/` | user | working contracts, guides, fleet agents; upgrades reconcile with **deny-wins** (user edits never overwritten) |
| repo satellites | repos | bootstrapped per-repo `.mosaic/` state |
As-built inventory of `~/.mosaic` on sb-it-1-dt (2026-08-17):
- **Contract core:** `CONSTITUTION.md` (L0 law), `AGENTS.md` (dispatcher + guide router + Fleet Comms Watcher requirement), `SOUL.md` (generic base for ALL fleet agents, zero persona — includes the new **Fleet Boundaries** section), `STANDARDS.md` (universal standards — includes new **session identity** + **comms watcher hygiene** sections), `SYSTEM.md` (pure communication contract, byte-identical to jarvis-brain's prompt-testing `sr_opus_5_system_prompt.md`), `USER.md`, `TOOLS.md`.
- **`guides/`** — user-owned working copies (E2E-DELIVERY, ORCHESTRATOR(+PROTOCOL,+LEARNINGS), WAKE-DOCTRINE, VAULT-SECRETS, etc.).
- **`fleet/agents/`** — the per-agent store (this is MOSAIC-D-002's substrate, already in use):
- real agent dirs: `fargo/`, `orchestrator/`, `probe/`, `vision/`, `weekly-update/` — shape: `profile.json` (harness/account/overlay pointer) + `overlay.json` + `SOUL.md` (persona) + `scratch/` `work/` `notes/` subdirs (hygiene rules in root SOUL.md)
- `*.env.generated` launch overlays: `luna` `sol` `terra` (carry `MOSAIC_AGENT_NAME`, `_CLASS`, `_RUNTIME`, `_MODEL`, `_REASONING`, `_TOOL_POLICY`) — these are the mosaic-fleet seat launch envs; `inbox.env`, `itops.env` also present
- `probe/` is the validated layout proof: auth-bundle symlink chain, per-agent sessions, plugin-store symlink (from 2026-08-07)
- **`auth/`, `config/`, `memory/`, `plugins/`, `skills/`, `skills-local/`** — per-tree copies/links for runtime isolation.
- Related but outside the tree: watcher units at `~/.config/systemd/user/<agent>-comms-watcher.service`; watcher seen-state at `~/.local/state/comms-watcher-<agent>/`.
## 2. Decisions register (2026-08-17 session)
Full strict records live in jarvis-brain `docs/decisions/mosaic-stack/` (render on its dashboard); both are **Pending Jason's ruling**. Summaries so the content survives P0:
- **MOSAIC-D-001 — SYSTEM.md as canonical harness system prompt.** Static core (Constitution+AGENTS+USER+overlays) in one tracked file; launcher renders dynamic tail (mission/PRD/fleet/persona). Delivery: `--append-system-prompt` (repeatable) for pi/claude; symlinked core file for codex (`$CODEX_HOME/instructions.md`) and opencode (`AGENTS.md`); their dynamic tail via initial prompt (needs live verification). Static-first order is the cache win. `SYSTEM.md` in `~/.mosaic` today is the communication-contract file — D-001's SYSTEM.md is the broader composition; naming to reconcile at implementation.
- **MOSAIC-D-002 — per-agent harness homes + mechanical profiles.** Launch with targeted config-dir env vars (e.g. `PI_CODING_AGENT_DIR=~/.mosaic/fleet/agents/<name>/pi`), NOT literal HOME. SOUL.md identity mechanically generated from roster (single writer; kills the hand-copy drift measured in `agents/vision/SOUL.md` on jarvis-brain: declared Jarvis, answered Vision). Composes: SYSTEM core → per-agent SOUL → dynamic tail. `MOSAIC_AGENT_NAME` stays load-bearing for comms.
- **Comms delivery tooling belongs in the STACK framework, not jarvis-brain** (decided in discussion; supersedes the interim placement). jarvis-brain keeps only the transport _data_ (`comms/` tree) while it lives. Agents launch from their own repos (terra from `~/src/stack` etc.) — delivery is transport-repo-relative, so this works; but every installed watcher unit's ExecStart currently points into `~/src/jarvis-brain/scripts/` — that dependency is the P0 trap to remove. Migration = move tools + regenerate units, in one step.
- **Watcher provisioning is instantiation duty, never running-agent duty.** Interim landed as jarvis-brain `scripts/comms/ensure-watcher.sh` (idempotent ensure + `--status` boot check + interim identity warnings: missing target session, pane `MOSAIC_AGENT_NAME` mismatch via `/proc/<child>/environ`, bare-runtime NOTE). Framework move: fold into `mosaic agent --new` + fleet launch + `mosaic doctor` drift check.
- **Prose guardrails landed (interim fences until mechanical fixes):** `~/.mosaic/SOUL.md` Fleet Boundaries (wrong-session tripwire; comms ownership; cross-agent investigation requires tasking) · `STANDARDS.md` session identity + watcher hygiene · `AGENTS.md` Fleet Comms Watcher requirement (P0-interim script path marked transitional).
## 3. Incident → #1295 (already tracked here)
`https://git.mosaicstack.dev/mosaicstack/stack/issues/1295` — docs-seat incident: cwd-keyed session files served three lives (dev chat → goals seat → 22 watcher injections into a wedged process); name-based watcher delivery with no identity verification; wedge after pi 0.84.1→0.84.2 update passes every liveness instrument. Proposed fixes enumerated there; provisioning follow-up in comment ID 23027.
## 4. Open work queue (suggested sequence)
1. **Comms tooling migration PR** (lane `next`): move `comms-watcher.sh`, `install-watcher.sh`, `ensure-watcher.sh` into the framework tree → deploy `~/.config/mosaic/tools/comms/`; regenerate existing units' ExecStart to framework paths (one-command sweep); keep `COMMS_WATCH_REPO` per-host config (points at a brain checkout until the queue transport lands). Reference: jarvis-brain commit `701c353b1`.
2. **Ensure-on-instantiation**: `mosaic agent --new` / fleet launch call ensure semantics; `mosaic doctor` gains the drift check (`--status --all` semantics + `fred`'s hand-written unit as the known drift case; also note daphne/docs/happy/pepper/sanity/tiny/fargo currently have no watcher — cover or consciously exempt).
3. **MOSAIC-D-002 implementation** (after ruling): per-agent homes via targeted env vars; roster-generated SOUL.md single-writer; extend the existing `*.env.generated` pattern; launch ledger keeps `config_home` audit.
4. **MOSAIC-D-001 implementation** (after ruling): SYSTEM.md sourcing + per-harness delivery + `compose-contract` becomes render-core+tail with drift check; bench cache-ordering before/after (jarvis-brain `domains/software-dev/mosaic-stack/prompt-testing` has the bench).
5. **Queue transport + forced separation** (longer term): supersedes watcher path; identity-verification and wedge-detection remain valid regardless of transport.
6. **Docs inheritance**: jarvis-brain AGENTS.md's durable comms guidance (E7 pi-glyph delivery gotchas, capture-pane rules, comms protocol) must be inherited into stack docs before P0 retirement.
## 5. Rules for tonight's agents
- Lane: **`next`** only; nothing to `main` without Jason (standing ruling).
- Attribution caveat #1280: Gitea/git identity from this host may misattribute (issue #1295 showed as created by `@mos-dt-0`); prefer per-invocation `git -c user.name=<seat>` and verify what the remote recorded.
- Do not delete `sb-it-1-dt:docs]` (untracked file at repo root) — it is cited fleet-wide as incident evidence.
- Edit user contracts in `~/.mosaic/`, never the templates in `~/.config/mosaic/`.
- Do not restart other fleet seats unilaterally (goals/scrappy/sanity restart decisions are fred's/Jason's per the docs-seat report).
## 6. Artifact map
| Artifact | Where |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| Decision records D-001/D-002 (strict, pending ruling) | jarvis-brain `docs/decisions/mosaic-stack/2026-08-17_mosaic-d-00{1,2}_*.md` |
| Incident issue + provisioning comment | stack #1295 + comment 23027 |
| ensure-watcher.sh (reference implementation) | jarvis-brain `scripts/comms/ensure-watcher.sh` (commit `701c353b1`) |
| Bench for prompt A/B (pi, thinking levels, footer-token semantics) | jarvis-brain `domains/software-dev/mosaic-stack/prompt-testing/` + `docs/reports/2026-08-17-prompt-testing-glm-bench.md` |
| Launcher inspection basis | `@mosaicstack/mosaic` 0.0.49 `dist/commands/launch.js` (composeContract / ensureRuntimeConfig / harness-home isolation) |
| Guardrail edits | `~/.mosaic/{SOUL,AGENTS,STANDARDS}.md` on sb-it-1-dt (2026-08-17 16:5317:04) |