Compare commits

..
Author SHA1 Message Date
peggy 7a2639d0f4 fix(#808): never borrow a session identity for non-tmux senders
ci/woodpecker/pr/ci Pipeline was successful
When MOSAIC_AGENT_NAME is unset and the sender has no tmux client context
($TMUX empty), agent-send.sh fell back to 'tmux display-message -p #S',
which outside tmux reports the LAST-ACTIVE session on the server. A
nameless sender was silently stamped as whichever seat touched tmux most
recently, forging that seat's identity in the preamble.

Incident (2026-08-20, host sb-it-1-dt): a nameless non-tmux sender was
stamped 'peggy', a live seat, on a message delivered TO peggy. Reproduced
end-to-end before the fix; capture in the seat evidence note.

Fix: the display-message fallback now runs only when $TMUX is set (inside
tmux it resolves the caller's own session — the case #808 built it for).
Outside tmux with no name, the label is host:unverified. Deliberate
identity remains available via -S. Chosen over refusing the send: nameless
senders are legitimate; the defect was borrowing an identity, not lacking
one.

Tests: suite made hermetic (TMUX controlled explicitly; results no longer
depend on the caller's context — previously tests 9/10 inherited the
caller's TMUX). New case 9b: no-tmux sender is labeled unverified and
never a borrowed name (red on original: host:local-agent borrowed from the
fake tmux server; green after). 19/19 in both caller contexts.
2026-08-20 15:41:48 -05:00
code-infra-01 6db0bead44 fix(#1327): sentinel-managed PATH block, default-home-only profile writes (#1330)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-infra-01 <[email protected]>
2026-08-20 20:20:33 +00:00
GhostfredGhost <>
c671290d77 docs: define main/next branch model, sequencing, responsibilities, and merge process (#1214) (#1215)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Ghost <>
2026-08-20 20:19:39 +00:00
code-infra-01andfred 6a9b2cf6c1 fix(git-tools): pr-merge queue guard reads CI status from the BASE repo for fork PRs (B1) (#1334)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: code-infra-01 <[email protected]>
2026-08-20 20:18:59 +00:00
Ghostgate-merge-01Ghost <>
6bd93a621d scratchpads: fleet identity/comms/mosaic-tree continuation record (2026-08-17) (#1296)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Ghost <>
2026-08-20 19:11:59 +00:00
GhostfredGhost <>
e9485c3d96 docs(l0): parameterize hard gates on the project's integration trunk (#1216, Option A) (#1217)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Ghost <>
2026-08-20 18:38:18 +00:00
Ghostgate-merge-01Ghost <>
d2f0846dcc fleet: put the bootstrapped Node on PANE_PATH (#1256) (#1258)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Ghost <>
2026-08-20 18:31:30 +00:00
fredandgate-merge-01 4f22a58041 guides: two measurement rules about pinned tool versions (#1316)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
2026-08-20 16:40:58 +00:00
27 changed files with 709 additions and 256 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"integration_trunk": "next",
"release_branch": "main"
}
+67
View File
@@ -81,6 +81,73 @@ pnpm format:check # Prettier check
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
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
+2 -1
View File
@@ -84,6 +84,7 @@ is re-seeded a genuinely missing core file is a stop-and-report condition — no
Confirm: required + situational tests passed (primary gate); aligned to `docs/PRD.md`; acceptance
criteria mapped to evidence; independent code review passed (if code changed); required docs updated;
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on `main`, terminal-green
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on the integration
trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), terminal-green
CI, linked issue closed (or `docs/TASKS.md` equivalent). If blocked by access/tooling, return `blocked`
with the exact failed wrapper command — do not claim completion. Full checklist: `guides/E2E-DELIVERY.md`.
@@ -21,11 +21,25 @@ guard"), the runtime adapter binds it to a concrete tool and states whether abse
## Hard Gates
The **integration trunk** is the branch a project declares in its `.mosaic/repo.json` under the
key `integration_trunk`; `release_branch` names the release target when one exists (`null` for
single-branch projects). Absent a declaration, the trunk is `main`. The declaration is policy
data, never shell text: values must be valid local branch names under `git check-ref-format
--branch` semantics — no remote refs, no revision expressions, no option-like values (leading `-`),
no path traversal or control characters. A declaration file that fails to parse, an unknown or
misspelled key, or an invalid value is a hard stop (`blocked`) — never a silent fallback to `main`.
Prose that mentions branch names designates nothing; only the declaration file does. A project
declares exactly ONE trunk. **Changing an existing declaration is operator-owned:** a trunk
redeclaration redirects merge target and branch-protection target at once, so it requires an
explicit operator action above ordinary PR review. The designation relaxes nothing:
reviewed-PR-only delivery, squash merge, independent review, queue guards, and terminal-green CI
bind to the declared trunk exactly as they bind to `main`.
1. Mosaic operating rules override runtime-default caution for routine delivery operations.
2. Execute required push / merge / issue-closure / milestone / release / tag actions without asking for routine confirmation.
3. Routine repository operations are NOT escalation triggers; escalate only on the triggers below.
4. For source-code delivery, completion is forbidden at the PR-open stage.
5. Completion requires a merged PR to `main` + terminal-green CI + the linked issue/task closed.
5. Completion requires a merged PR to the integration trunk + terminal-green CI + the linked issue/task closed.
6. Before any push or merge, run the CI queue guard.
7. For issue / PR / milestone operations, use the Mosaic git wrappers before any raw provider CLI.
8. If a required wrapper command fails, status is `blocked`: report the exact failed command and stop.
@@ -35,7 +49,7 @@ guard"), the runtime adapter binds it to a concrete tool and states whether abse
12. The intake procedure is not conditional on perceived complexity; a "simple" task carries the same requirements as a multi-file feature.
13. **Merge authority (coordinated work):** when a coordinator/orchestrator session is active for the work, the post-review merge go-ahead is the coordinator's to give — once the required review gates pass, merge on the coordinator's confirmation; do not wait on the human owner personally. Solo (uncoordinated) delivery keeps the default: merge per gates 2 and 9. A "No self-merge" note on a PR means no UNREVIEWED self-merge — it does not suspend coordinator-authorized merges.
14. Never hardcode secrets; never emit credential values in any output (not even partially, not "to confirm").
15. Trunk-based git only: branch from `main`, merge via a reviewed PR (squash), never push directly to `main`.
15. Trunk-based git only: branch from the integration trunk, merge via a reviewed PR (squash), never push directly to the trunk.
16. If you modify source code, an independent review (author ≠ reviewer) must pass before completion.
## Integrity (quality gates are never bypassed)
+12 -10
View File
@@ -12,7 +12,7 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
4. Issue tracking is consistent across projects
5. Documentation standards and API contracts are enforced from day one
6. PRD requirements are established before coding begins
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
7. Branching/merging is consistent: branch -> integration trunk (default `main`) via PR with squash-only merges
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
## Agent Host Prerequisites
@@ -206,7 +206,7 @@ Every runtime context file should contain:
6. **Issue tracking** — Issue and commit conventions
7. **Code review** — Required review process
8. **Runtime notes** — Runtime-specific behavior references
9. **Branch and merge policy** — Trunk workflow (`branch -> main` via PR, squash-only)
9. **Branch and merge policy** — Trunk workflow (branch -> integration trunk via PR, squash-only)
10. **Autonomy and escalation policy** — Agent owns coding/review/PR/release/deploy lifecycle
---
@@ -288,15 +288,17 @@ Reserve `0.1.0` for the MVP release milestone.
---
## Step 5b: Configure Main Branch Protection (Hard Rule)
## Step 5b: Configure Trunk Branch Protection (Hard Rule)
Apply equivalent settings in Gitea, GitHub, or GitLab:
Apply equivalent settings in Gitea, GitHub, or GitLab, targeting the project's integration trunk
(the branch its `.mosaic/repo.json` declares under `integration_trunk`; default `main` — see
`CONSTITUTION.md` Hard Gates):
1. Protect `main` from direct pushes.
2. Require pull requests to merge into `main`.
1. Protect the integration trunk from direct pushes.
2. Require pull requests to merge into the integration trunk.
3. Require required CI/status checks to pass before merge.
4. Require code review approval before merge.
5. Allow **squash merge only** for PRs into `main` (disable merge commits and rebase merges for `main`).
5. Allow **squash merge only** for PRs into the integration trunk (disable merge commits and rebase merges for it).
This enforces one merge strategy across human and agent workflows.
@@ -513,9 +515,9 @@ After bootstrapping, verify:
- [ ] Git labels created (epic, feature, bug, task, etc.)
- [ ] Initial pre-MVP milestone created (0.0.1)
- [ ] MVP milestone reserved for release (0.1.0)
- [ ] `main` is protected from direct pushes
- [ ] PRs into `main` are required
- [ ] Merge method for `main` is squash-only
- [ ] The integration trunk is protected from direct pushes
- [ ] PRs into the integration trunk are required
- [ ] Merge method for the integration trunk is squash-only
- [ ] Quality gates run successfully
- [ ] `.env.example` exists (if project uses env vars)
- [ ] CI/CD pipeline configured (if using Woodpecker/GitHub Actions)
@@ -4,6 +4,11 @@
## Overview
> **Integration trunk:** the YAML examples in this guide use the default integration trunk `main`
> in branch conditions and version rules. A project that declares a different trunk in its
> `.mosaic/repo.json` under `integration_trunk` (see `CONSTITUTION.md` Hard Gates) substitutes its
> declared trunk wherever `main` appears as the trunk branch.
This guide covers the canonical CI/CD pattern used across projects. The pipeline runs in Woodpecker CI and follows this flow:
```
@@ -865,7 +870,7 @@ steps:
```yaml
image: git.example.com/org/service@${IMAGE_DIGEST}
```
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
7. **Test on a short-lived non-trunk branch first** — open a PR and verify quality gates before merging to the integration trunk
8. **Verify images appear** in Gitea Packages tab after successful pipeline
## Terminal-Green Full-Step Contract
@@ -906,7 +911,7 @@ For source-code delivery, completion is not allowed at "PR opened" stage.
Required sequence:
1. Merge PR to `main` (squash) via Mosaic wrapper.
1. Merge PR to the integration trunk (squash) via Mosaic wrapper.
2. Monitor CI to terminal status:
```bash
~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>
@@ -1112,5 +1117,5 @@ If a project currently uses Verdaccio (e.g., U-Connect at `npm.uscllc.net`), fol
### Pipeline runs Docker builds on pull requests
- Verify `when` clause on Docker build steps restricts to `branch: [main]`
- Verify `when` clause on Docker build steps restricts to the integration trunk (`branch: [main]` by default)
- Pull requests should only run quality gates, not build/push images
@@ -10,9 +10,10 @@ If implementation diverges from `docs/PRD.md` or `docs/PRD.json` without PRD upd
Merge strategy enforcement (HARD RULE):
- PR target for delivery is `main`.
- Direct pushes to `main` are prohibited.
- Merge to `main` MUST be squash-only.
- The integration trunk is the branch the project's `.mosaic/repo.json` declares under `integration_trunk` (default: `main`) — see `CONSTITUTION.md` Hard Gates.
- PR target for delivery is the integration trunk.
- Direct pushes to the integration trunk are prohibited.
- Merge to the integration trunk MUST be squash-only.
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
An estate MAY carry a documented exception for a repository whose gates are commit hooks rather
@@ -65,6 +66,19 @@ Each of these produced a wrong conclusion before it was written down.
conclusion drawn from it describes the wrong tree. Confirm `git rev-parse --show-toplevel`
is the tree you think it is before trusting any git output.
13. **Run the repository's PINNED tool version.** `npx <tool>` resolves a local `node_modules`
install when one is present and fetches the latest release when one is not, so the same
command answers differently depending on where it ran. A reviewer measuring in a fresh clone
or a detached worktree — which is exactly where reviewers measure — has no `node_modules` and
silently gets the latest release instead of the pinned one. Measured on mosaicstack#1313: the
lockfile pins prettier 3.8.1, under which three guides pass; a version-less `npx` in a
worktree resolved 3.9.6, under which the same three fail; and 3.0.0, the floor of the declared
`^3.0.0` range, fails a different one. Three versions, three verdicts, identical bytes. Use
`node_modules/.bin/<tool>`, or name the version the lockfile pins.
14. **A formatter or linter declared as a range is a dated verdict, not a fact.** If a lockfile
pins it, the gate is reproducible today and will disagree with itself the day the pin moves.
Report a formatting failure with the version that produced it, always.
### Feedback Categories
- **Blocker**: must fix before merge (security, bugs, test failures)
@@ -184,8 +198,8 @@ Use `~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md` whenever code/A
# List the issue being addressed
~/.config/mosaic/tools/git/issue-list.sh -i {issue-number}
# View the changes
git diff main...HEAD
# View the changes (diff against the integration trunk; default: main)
git diff {integration_trunk}...HEAD
```
### Providing Feedback
@@ -214,4 +228,4 @@ This pattern appears in 3 places. A shared helper would reduce duplication.
2. If changes requested, assign back to author
3. If approved, note approval in issue comments
4. For merges, ensure CI passes first
5. Merge PR to `main` with squash strategy only
5. Merge PR to the integration trunk with squash strategy only
@@ -78,7 +78,7 @@ For implementation work, you MUST run this cycle in order:
7. `commit` - commit only when the logical unit passes tests and review.
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. `push` - push immediately after queue guard passes.
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
10. `PR integration` - if external git provider is available, create/update PR to the integration trunk (the project's declared trunk, default `main`) and merge with required strategy via Mosaic wrappers.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
@@ -199,7 +199,7 @@ Before running this checklist, pause and self-interrogate: did I fulfill the use
10. No unresolved blocker hidden.
11. If deployment is in scope, deployment target, release version, and post-deploy verification evidence are documented.
12. `docs/TASKS.md` status and issue/internal references are updated to match delivered work.
13. If source code changed and external provider is available: PR merged to `main` (squash), with merge evidence recorded.
13. If source code changed and external provider is available: PR merged to the integration trunk (squash), with merge evidence recorded.
14. CI/pipeline status is terminal green for the merged PR/head commit.
15. Linked external issue is closed (or internal task ref is closed when no provider exists).
16. If any of items 13-15 fail due access/tooling, report `blocked` with exact failed wrapper command and do not claim completion.
@@ -53,23 +53,21 @@ sends, it does not auto-reply.
### Exit codes
| rc | Meaning |
| --- | -------------------------------------------------------------------------------------------- |
| 0 | delivered or queued |
| 1 | target session not found |
| 2 | submission unconfirmed: draft still on the input line, or no positive evidence of submission |
| 3 | usage error (bad class, missing `-s`) |
| rc | Meaning |
| --- | ---------------------------------------------- |
| 0 | delivered or queued |
| 1 | target session not found |
| 2 | text reached the pane but is **still a draft** |
| 3 | usage error (bad class, missing `-s`) |
**Never retry on rc=2.** The message may be in the target pane, and a retry can double-send it.
Confirm instead:
**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm
instead:
```bash
tmux capture-pane -p -t <session>:0.0 | tail -20
```
rc=0 is the normal result for both idle and busy pi seats (submission confirmed by draft
transition, not by prompt glyph). rc=2 on a healthy seat is exceptional — treat it as a real
report and investigate the pane.
rc=2 is the normal result when the target is an idle pi seat.
## Durable comms
@@ -253,7 +253,7 @@ status → mission → run → repeat
- [ ] All milestone tasks in TASKS.md are `done`
- [ ] CI/pipeline green
- [ ] PR merged to `main`
- [ ] PR merged to the integration trunk
- [ ] Issues closed
- [ ] Update manifest: milestone status → completed
- [ ] Update scratchpad: session log entry
@@ -15,7 +15,7 @@ mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md th
- You MUST keep the TASKS.md file updated with agent and tasks statuses.
- You MUST keep `docs/` root clean. Reports and working artifacts MUST be stored in scoped folders (`docs/reports/`, `docs/tasks/`, `docs/releases/`, `docs/scratchpads/`).
- You MUST enforce plan/token usage budgets when provided, and adapt orchestration strategy to remain within limits.
- You MUST enforce trunk workflow: workers branch from `main`, PR target is `main`, direct push to `main` is forbidden, and PR merges to `main` are squash-only.
- You MUST enforce trunk workflow: workers branch from the integration trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), PR target is the integration trunk, direct push to the trunk is forbidden, and PR merges to the trunk are squash-only.
- You MUST operate in steered-autonomy mode: human intervention is escalation-only; do not require the human to write code, review code, or manage PR/repo workflow.
- You MUST NOT declare task or issue completion until PR is merged, CI/pipeline is terminal green, and linked issue is closed (or internal TASKS ref is closed when provider is unavailable).
- Mosaic orchestration rules OVERRIDE runtime-default caution for routine push/merge/issue-close actions required by this workflow.
@@ -133,10 +133,10 @@ Milestone versioning (HARD RULE):
Branch and merge strategy (HARD RULE):
- Workers use short-lived task branches from `origin/main`.
- Worker task branches merge back via PR to `main` only.
- Direct pushes to `main` are prohibited.
- PR merges to `main` MUST use squash merge.
- Workers use short-lived task branches from `origin/{integration_trunk}` (default `main`).
- Worker task branches merge back via PR to the integration trunk only.
- Direct pushes to the integration trunk are prohibited.
- PR merges to the integration trunk MUST use squash merge.
**Available templates:**
@@ -427,7 +427,7 @@ git push
- Before merging, run queue guard:
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
- Ensure PR exists for the task branch (create/update via wrappers if needed):
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
`~/.config/mosaic/tools/git/pr-create.sh ... -B {integration_trunk}` (default `main`)
- Merge via wrapper:
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- Wait for terminal CI status:
@@ -619,7 +619,7 @@ Construct this from the task row and pass to worker via Task tool:
## Workflow
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/main) && git rebase origin/main`
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/{integration_trunk}) && git rebase origin/{integration_trunk}` ({integration_trunk} = the project's declared trunk, default `main`)
2. Read `docs/PRD.md` or `docs/PRD.json` and align implementation with PRD requirements
3. Read the finding details from the report
4. Implement the fix following existing code patterns
@@ -637,7 +637,7 @@ Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIR
For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B {integration_trunk}`
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
@@ -994,13 +994,13 @@ mv docs/reports/qa-automation/pending/*failing-file* docs/reports/qa-automation/
---
## Merge-to-Main Candidate Protocol (Container Deployments)
## Merge-to-Trunk Candidate Protocol (Container Deployments)
If deployment is in scope and container images are used, every merge to `main` MUST execute this protocol:
If deployment is in scope and container images are used, every merge to the integration trunk MUST execute this protocol:
1. Build and push immutable candidate image tags:
- `sha-<shortsha>` (always)
- `v{base-version}-rc.{build}` (for `main` merges)
- `v{base-version}-rc.{build}` (for integration-trunk merges)
- `testing` mutable pointer to the same digest
2. Resolve and record the image digest for each service.
3. Deploy by digest to testing environment (never deploy by mutable tag alone).
@@ -292,6 +292,16 @@ esac
_build_runtime_bin_prefix() {
local candidates=()
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
local npm_prefix
npm_prefix=$(npm config get prefix 2>/dev/null) || true
@@ -520,6 +520,93 @@ for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
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() {
local home="$1"
local agent="$2"
@@ -128,6 +128,7 @@ 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_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())')"
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_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
@@ -144,10 +145,19 @@ if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
fi
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" \
--purpose merge \
-B "$HEAD_BRANCH" \
-R "$HEAD_REPO" \
-R "$BASE_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
@@ -209,6 +209,10 @@ base_ref = first_non_empty(
data.get('base_ref'),
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:
available = ', '.join(sorted(data.keys()))
@@ -229,6 +233,7 @@ normalized = {
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref,
'baseRepository': base_repo,
'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)],
'milestone': nested(data, 'milestone', 'title') or '',
@@ -0,0 +1,76 @@
#!/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'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
@@ -52,8 +52,13 @@ if grep -q -- '-B main' "$CALL_LOG"; then
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the BASE repository for CI status" >&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
exit 1
fi
@@ -30,7 +30,6 @@ packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured i
# --- tools/tmux: require a live tmux server ---
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its siblings) — signed at adoption of #1262 (rev-code-02 F5), red-first verified on sb-it-1-dt
# --- single-suite directories: unmeasured in CI ---
@@ -126,7 +126,16 @@ if [ -z "$SRC_LABEL" ]; then
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=${MOSAIC_AGENT_NAME:-}
if [ -z "$src_sess" ]; then
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
if [ -n "${TMUX:-}" ]; then
# 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
SRC_LABEL="${src_host}:${src_sess}"
fi
@@ -61,8 +61,15 @@ 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() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
run_auto() {
env -u MOSAIC_AGENT_NAME \
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
# depend on whether the caller running this suite sits inside tmux.
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" \
bash "$TOOL" -n dsthost "$@"
}
@@ -145,7 +152,9 @@ want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|| 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.
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
# Sender is INSIDE tmux: the only context where display-message self-lookup
# 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"
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
@@ -153,8 +162,17 @@ want="[$src_host:local-agent -> dsthost:mos] local fallback"
&& ok "cross-socket fallback rejects destination holder identity" \
|| 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 '?'.
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
want="[$src_host:? -> dsthost:mos] unknown fallback"
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
@@ -97,34 +97,13 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# would otherwise accumulate forever.
sleep 0.5
# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
# glyph. The historical bug was treating ABSENCE of a draft as delivery; the
# 2026-08 fix over-corrected to glyph inference (grep '|^>|│ >'), which locates
# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
# LEFT it AFTER — that transition is positive proof of submission and needs no
# glyph. Absence alone still never means delivered: if we never saw our draft on
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
local cy line
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
[ -n "$cy" ] || return 1
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
}
_draft_on_input() { # true iff our message tail is sitting on the input line now
[ -n "$snippet" ] || return 1
grep -qF "$snippet" <<<"$(_cursor_line)"
}
# Baseline: after the paste, our draft must be on the input line. This is positive
# proof we are on the right pane and the paste landed — the anchor the transition
# check measures against.
saw_draft=0
_draft_on_input && saw_draft=1
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
# still a draft. Success requires positive evidence — the queued banner, OR the
# REPL input box located AND clear of our message tail. The historical bug was
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
status="unconfirmed"
for attempt in $(seq 1 $((RETRIES + 1))); do
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
@@ -134,26 +113,20 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
if grep -qF "$QUEUED_RE" <<<"$pane"; then
status="queued"; break
fi
# POSITIVE draft evidence from a located prompt box, when one exists. This is the
# cursor-row check's blind spot: a pane in COOKED mode (a plain shell whose
# foreground process never reads stdin) echoes our paste via the kernel line
# discipline and moves the cursor off it on Enter, which is indistinguishable from
# a real submit by cursor row alone. If a prompt box IS locatable and still carries
# our tail, that is affirmative proof the message was not consumed. Absence of a
# glyph is still never used for anything — that inference is the original E7 bug.
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
promptline=$(printf '%s' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -n "$promptline" ] && [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
if [ -z "$promptline" ]; then
status="unconfirmed"; continue
fi
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
# (Submitted messages scroll up into history; a draft stays on the line.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
status="draft"; continue
fi
if [ "$saw_draft" = 1 ]; then
if _draft_on_input; then
status="draft"; continue # still on the input line => not submitted; flush + retry
fi
status="delivered"; break # left the input line => positively submitted
fi
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
status="unconfirmed"; continue
# Input box located AND clear of our tail => positively submitted. This is the
# only path to success besides the queued banner.
status="delivered"; break
done
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
@@ -1,97 +0,0 @@
#!/usr/bin/env bash
# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind
# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt
# glyph is present. A pi seat renders a U+2500 rule input box with no /^>/│ >
# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely
# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED",
# and the operator's rc=2-driven retry duplicates it.
#
# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN
# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket.
#
# Submission counting is EXACT and terminal-echo-independent: the fixture message
# is `echo <tok> >>SINK`; each real submission appends one line. wc -l SINK ==
# number of times the REPL actually executed the send. This does not depend on how
# many times the marker string is painted on screen.
set -u
SEND="${SEND:?set SEND=/path/to/send-message.sh}"
SOCKET="glyphagnostic-$$"
TMP="$(mktemp -d)"
tmux() { command tmux -L "$SOCKET" "$@"; }
cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; }
trap cleanup EXIT
pass=0; fail=0
ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); }
no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); }
mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; }
subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count
echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')"
# --- A (control): glyph box () that submits => exit 0, exactly one submission.
mk ctl ' '
SINK="$TMP/sink.ctl"
out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
ok "control: -box submits => exit 0, exactly one submission"
else no "control: -box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi
# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands
# (subs==1) yet shipping reports exit 2. Must be exit 0.
mk sub $'──────── \n'
SINK="$TMP/sink.sub"
out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')"
else no "glyphless: U+2500 box that submits => exit 0" \
"rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi
# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the
# glyphless box, shipping => two submissions (the reported duplicate). The
# property: one logical send => exactly one submission. Same fix closes it.
mk dup $'──────── \n'
SINK="$TMP/sink.dup"
tries=0
for attempt in 1 2; do
tries=$((tries+1))
out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$?
sleep 0.4
[ "$rc" = 0 ] && break # operator stops retrying only when told delivered
done
if [ "$(subs "$SINK")" = 1 ]; then
ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)"
else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi
# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing.
# A cooked `sleep infinity` echoes the paste via the kernel line discipline and
# false-passes a cursor-row fix that is correct on real seats (measured). So: raw.
mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
"bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; }
mk_rawstuck estuck
SINK="$TMP/sink.estuck"
out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$?
sleep 0.3
if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then
ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)"
else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi
# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the
# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0.
mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
"bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; }
mk_busy ebusy
SINK="$TMP/sink.ebusy"
out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3
if [ "$rc" = 0 ]; then
ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)"
else no "busy/queued-banner must report delivered" "rc=$rc"; fi
# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft
# => must fail, never infer delivered from absence of a glyph/snippet.
if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then
no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0"
else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi
echo "---"; echo "pass=$pass fail=$fail"
[ "$fail" = 0 ]
@@ -4,19 +4,16 @@
#
# 1. DELIVERED — a REPL that renders a ` ` input box and submits on Enter
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
# 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi
# seat is this fixture (U+2500 rule, no glyph). Reshaped for
# #1257; see the note at the fixture for why the old exit-2
# assertion was wrong.
# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI)
# => must fail loud. This carries the historical
# false-positive guard that fixture 2 used to be credited with.
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
# exit 0; post-patch it MUST fail loud (exit 2, stderr
# "could not confirm submission").
# 3. DRAFT — a ` `-prompt pane that never submits (message stays on the
# input line) => exit 2, stderr "unsubmitted draft".
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
SEND="${SEND:-$HERE/send-message.sh}"
SEND="$HERE/send-message.sh"
SOCKET="verdict-test-$RANDOM-$$"
TMP=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
@@ -40,44 +37,19 @@ else
no "delivered: -prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
fi
# --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash).
# RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted
# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong,
# and locking it in is what kept E7 alive: the pane submits, so "delivered" is
# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph
# — IS this fixture. Reporting exit 2 for it told operators a delivered message
# may be undelivered, and the retry that advice invites is the duplicate.
#
# The guard this fixture was reaching for is real and is NOT dropped: "never
# infer delivered from absence" is now enforced positively by fixture 2b below
# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box
# still carrying our tail => draft). Absence alone decides nothing either way.
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
sleep 0.3
out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$?
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)"
else
no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]"
fi
# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the
# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured
# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so
# there is no positive evidence of submission and the tool MUST fail loud. This
# is the historical false-positive guard, kept as a positive test.
tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \
'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"'
sleep 0.3
if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then
no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, got 0 (out=[$out])"
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
else
rc=$?
if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then
ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'"
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
else
no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]"
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
fi
fi
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"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-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-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"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -0,0 +1,152 @@
/**
* 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');
});
});
+72 -16
View File
@@ -1,11 +1,12 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { platform } from 'node:os';
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { ManifestError } from '../framework/manifest.js';
import {
getDefaultSkillPaths,
@@ -144,32 +145,87 @@ function runDoctor(mosaicHome: string): DoctorResult {
type PathAction = 'already' | 'added' | 'skipped';
function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
const binDir = join(mosaicHome, 'bin');
const currentPath = process.env['PATH'] ?? '';
const PATH_BLOCK_BEGIN = '# >>> mosaic begin >>>';
const PATH_BLOCK_END = '# <<< mosaic end <<<';
const PATH_BLOCK_NOTE = '# Managed by the Mosaic installer; this block is rewritten on install.';
if (currentPath.includes(binDir)) {
return 'already';
/**
* 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 profilePath = getShellProfilePath();
if (!profilePath) return 'skipped';
const isWindows = platform() === 'win32';
const exportLine = isWindows
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
const block = managedBlockFor(binDir, isWindows);
// Check if already in profile
let content = '';
if (existsSync(profilePath)) {
const content = readFileSync(profilePath, 'utf-8');
if (content.includes(binDir)) {
return 'already';
}
content = readFileSync(profilePath, 'utf-8');
}
// 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 {
appendFileSync(profilePath, exportLine, 'utf-8');
writeFileSync(profilePath, next, 'utf-8');
return 'added';
} catch {
return 'skipped';
@@ -286,7 +342,7 @@ export async function finalizeStage(
}
// 7. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
const pathAction = setupPath(state.mosaicHome, DEFAULT_MOSAIC_HOME);
let summaryShown = false;
const showSummary = () => {
@@ -0,0 +1,73 @@
# 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) |