Compare commits

...
Author SHA1 Message Date
jason.woltje 27f01ef63a row 251: BUILD.md slot results, part A reds at the predicted fractions only, fix 18/18, bus suite green 3 times and concurrently (sage-2154) 2026-10-21 16:08:58 -05:00
jason.woltje 68f2dfd4b7 row 251: BUILD.md predicts part A's reds on e5e4b2e5a and records no rebase onto 6096b051b (sage-2084) 2026-10-21 14:15:34 -05:00
jason.woltje c19b4167ed row 251: T27 adds its key with -t 2 and sleeps 3.5 s, because ssh-agent counts lifetimes in whole seconds (#453)
ssh-agent sets a key's death to whole seconds of CLOCK_BOOTTIME plus the lifetime and reaps on the
same whole seconds (OpenSSH 10.5p1 ssh-agent.c:1315, :1064, misc.c:1828), so -t 1 can end within
milliseconds of an add made late in a second. -t 2 keeps the key over 1 s; the sleep keeps its 1.5 s
margin past the 2 s ceiling. Both checks stay. host-run.mjs is unchanged: production adds with
timeout + 30. Evidence and the slot plan are in agents/darkwing/work/queue-251/BUILD.md.
2026-10-21 14:12:55 -05:00
jason.woltje e5e4b2e5af row 251 step 1: T27's lists print the timing and the agent state when they fail (#453)
A failed list now names when addKey started and resolved and when each list ran, on hrtime and
/proc/uptime, with how far into its second each fell, and whether the agent and its socket were
still there. The test's checks are unchanged.
2026-10-21 14:11:03 -05:00
jason.woltje 3b0aef9952 deferred: row 66 live checks, Claude Code at the first re-pin with row 66 and pi after #383, Vikunja #454 (mos-1186) 2026-10-21 13:55:07 -05:00
jason.woltje 2c38325eca row 66: managed sessions run the unslop check on both harnesses, the operator note on the RESULT, a correction notice on the next turn, and unslop.jsonl counts (#1559)
Lands Sage's sage/row66-r5 at 7ffb640ac, round 4 (r4d 82c830779) rebased onto a36523713 after row 197,
approved by Dewey (comment 30085) and Filbert (30086) on manifest 6cfd812da4ca (23 paths, rev 2905). The
rebase had no conflict. The range-diff is = except one pi-session.test.mjs hunk header. Patch-id without
agents/ is d67275ac4251 on 7b9737953, a36523713 and this commit's parent. Gate (mos-1186): unslop,
harness, cli and every scripts/test-*.sh green on r4d, harness 149/0 again on r5. Landing sessions on a
scratch root from the candidate tree: Claude Code r4ab24630d323 showed the note, the injected notice and
counts checked 2, flagged 1, notices 1. Pi r1dd94171bede is launch only until #383 (Vikunja #454).
2026-10-21 13:51:28 -05:00
jason.woltje 640aa03f27 brief: Console inbox Approve and Deny for grant requests, Vikunja #435 (mos-1193) 2026-10-21 13:44:03 -05:00
Herbandjason.woltje a365237133 test: resolve the real Docker binary inside lane fixtures
(cherry picked from commit c45ddeb6bd)
2026-10-21 13:28:40 -05:00
jason.woltje 5afff54908 brief: T27 row 251 records the monotonic-boundary check, 8 of 80 adds lost the key (mos-1185 item 2) 2026-10-21 12:20:21 -05:00
jason.woltje f33932921c brief: T27 lists no identities right after ssh-add -t 1, Vikunja #453 (mos-1185 item 2); row 197 lane-b observation (item 3) 2026-10-21 12:12:46 -05:00
jason.woltje 1be656ac97 brief: K12 reads cgroup.events after cgroup.kill under load, Vikunja #450 (mos-1182 item 3) 2026-10-21 11:09:37 -05:00
jason.woltje 19d3d5f026 sessions: Filbert row 66 round 4 review, approve 2026-10-21 10:21:00 -05:00
jason.woltje 6c75887b0c sessions: Sage 10-21 afternoon (rows 249, 197, 222, 66, queue revs 2642-2904), with pending Dewey and Researcher lines 2026-10-21 10:19:14 -05:00
jason.woltje 0636721e60 queue: revs 2642-2904 (rows 234, 235, 249 landed; 212, 213, 218, 221, 231, 247 landed; 228, 233, 238, 240, 195, 245, 248 landed; 197 r8 approve, 215 r1 approve, 66 round 4; Case 2 and installer row notes)
Sweeps other seats' revs: Gizmoduck (234, 235, 249), Duckworth (234, 235, 195, 245, 249 verdicts),
Darkwing (233, 240, 195, 245), Filbert (233, 240, 215 verdicts), Gosalyn (212, 221, 231, 247),
Morgana (213), Researcher (228), Launchpad (238), Herb (197, 215), Webby (197, 212, 213, 218, 221,
228, 231, 238, 247, 248 verdicts). The rest are Sage's notes, landings, moves and review requests.
2026-10-21 10:18:29 -05:00
jason.woltje 7b9737953a deferred: row 248 Webby N1, the grant executor test sleep 30 can outlive its test; Duckworth deferred it in the row 249 review (#1751 c30070) 2026-10-21 09:55:13 -05:00
jason.woltje 9de5a57abd row 249: row 235 follow-up, a grant whose install never returned records hostLineLeft, since the remote add can outlive an abort or a timeout (#1751)
An abort or a timeout kills only the local ssh. A non-pty sshd command gets no SIGHUP, so the remote
add can still write its line after the undo's remove. The broker now marks install done only after
`await install(...)` returns. When it never returned, the undo records hostLineLeft with the tag, so
grant.failed and status name the line, hosts.remove refuses until `mosaic hosts sweep` clears it.
The :432 test comment now matches. Row a's enroll does no remote add, so it has no such race.

Candidate gizmoduck/row249-on-8a04 05595794b (Gizmoduck), manifest 3a969bb136bc, approved by
Duckworth in round 1 (rev 2889-2890 request c30068, verdict c30070). Gate on Sage's slot 14:53Z
(sage-2026): grant-executor.test.mjs whole 39/0 and bus 598/0 with no stub left, and C1, which
drops the `if (!installed)` branch, fails G8 and both G23 tests. Webby's N1 on X5 `sleep 30` is
deferred by Duckworth (DEFERRED.md).
2026-10-21 09:54:29 -05:00
jason.woltje 8ca064158a sessions: Filbert row 215 candidate review r1, approve 2026-10-21 08:43:38 -05:00
jason.woltje 8a04f4aa4a row 248: row 235 follow-up, the grant-executor hold-add and hold-remove stubs stop when their test dir is gone, so no stub outlives its test (#1750)
The row 197 r8 D5 proof stopped on a leftover hold-add stub. G23 and G13 write release, finish and
remove the test dir before the stub polls again, so the stub looped forever on sshd's side. 4 runs
of the file left 7 stubs. Both hold loops now also stop once $D is gone.

Candidate 4d99c178b, approved by Webby in round 1 (rev 2883, webby-0080). Gate on Sage's slot
13:05-13:07Z: the whole file 3 runs at 37/0 with no stub left, bus 596/0, and a mutant that drops
the hold-add check leaves 2 stubs. The late remote add the leak exposed is row 249 (#1751).
2026-10-21 08:16:49 -05:00
jason.woltje bc579f7a84 docs: host run sheet section 4, the apt-get download W: line as root is expected, and the profile's sha256 is checked again at /etc/apparmor.d (mos-1168) 2026-10-21 08:14:11 -05:00
jason.woltje 6de1ebc629 plans: row 235 follow-up brief, the grant undo records a host line it can't prove gone (mos-1166, gizmoduck-0139, duckworth-0091) 2026-10-21 08:11:21 -05:00
jason.woltje 540239b6b7 plans: row 235 follow-up brief, the held grant-executor stubs stop when their test dir is gone 2026-10-21 08:03:55 -05:00
jason.woltje 6d5545d442 row 245: row 195 follow-up, F9 runs node as root only at the pinned root-only path, the header and TOOLS.md list what root runs, F6 refuses .. after claude-<pin>/ (Duckworth N1-N3, #1693)
Darkwing's ef8b4439e (darkwing/row245), Duckworth round 1 APPROVE (rev 2849, comment 30043),
manifest 69bc05f4f706, 5 paths. Slot run sage-1961 (darkwing-0107): singles 23/0, mutants 16/16
killed, N1 and N3 among them. DESIGN.md:336-338 stays unedited, superseded by gosalyn-0087.
Sage gate on 856c1826b plus these 5 paths (12:24-12:35Z): case1-accept.test.mjs alone 23/0,
cli 681/0, every scripts/test-*.sh 429/0.
2026-10-21 07:35:39 -05:00
jason.woltje 856c1826bc row 247: C2-20b persona import follow-up, keys from config.yaml print as JSON strings, the dry run applies C2-08's fence and refuses, privateDir refuses an EEXIST race by name (#1749)
Gosalyn's 6d43618a3 (gosalyn/c2-20b), Webby round 1 APPROVE webby-0076 (rev 2852), manifest 6d3f279f692b,
4 paths. Webby's row 221 notes N1, N2 and N4. Slot runs sage-1969 (red base, the builder's comma
regression) and sage-1971 (14/0, M1-M4 killed). M1/M2 sit at :248 in this commit, not :237 as
BUILD.md says (gosalyn-0111). JSON.stringify leaves DEL, C1, U+2028/9 and bidi raw, a known limit
by sage-1964.
Sage gate on 5c6f73ed7 plus these 4 paths (12:10-12:21Z): the test file alone 14/0, cli 680/0,
every scripts/test-*.sh 429/0. f8276c5eb adds only docs/SESSIONS and docs/case2 sheets no test reads.
2026-10-21 07:22:07 -05:00
jason.woltje f8276c5eb3 row 218: C2-12 setup sheet for Melanie and the library survey run sheet (#1719)
Doc only, Sage's sage/row218 89ba79d94 (three commits squashed). Webby APPROVE round 1 (webby-0075,
rev 2848), round 2 (webby-0077, rev 2856) and round 3 (webby-0078, rev 2860). docs/case2/SETUP-SHEET.md
tells Melanie what the agents read, the playground, link and property forms, and what changes for
Bean and Chet, with M5's 1,200 character reply total (mos-1165). It goes to her only after C2-06
lands and on Jason's go (#419). docs/case2/SURVEY-RUN.md is design 10 step 7's run sheet: the
bare mirror as the source, a node check, output lines and refusals from survey.mjs. Claim sources
and rulings are in agents/sage/work/queue-218/BUILD.md. No test reads either doc path.
2026-10-21 07:05:39 -05:00
jason.woltje b65f3a0e84 sessions: Darkwing row 245 slot green, round 1 in review 2026-10-21 06:50:41 -05:00
85 changed files with 13274 additions and 251 deletions
+17 -1
View File
@@ -75,7 +75,8 @@ session's workspace and its own process group. Same contract, plus:
| `MOSAIC_POLICY_FILE`, `MOSAIC_TOOLS_FILE` | The bundle's gate policy and typed tools. |
| `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. |
| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. |
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. The pi module names two: the gate and typed tools extension, then the unslop check's `tools/unslop-hook/extension.ts` (row 66). |
| `MOSAIC_UNSLOP_LOG` | The unslop check's JSONL log, `<run>/unslop.jsonl`, which the runner sets for both adapters and reads after each turn (row 66). |
| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
| `MOSAIC_THINKING` | The route's thinking level (row 65), which the harness module's `levels()` already checked. `pi` passes `--thinking` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) and omits it when unset, as for `run-task.sh` workers. `claude` passes `--effort` (`low`, `medium`, `high`, `xhigh`, `max`) and requires it; unset exits 2. |
| `MOSAIC_CLAUDE_BIN` | `claude` only: absolute path of the CLI to run, from the host's environment (the bus unit pins it). Unset or empty runs `claude` from `PATH`; a relative path or a non-executable file exits 2. |
@@ -110,3 +111,18 @@ argument, which Linux caps at 128 KiB; row 213 passes the file instead, so no
local user reads the prompt in `/proc/<pid>/cmdline`. Pi 0.85.1 uses the path
string itself as the prompt when the file is missing or can't be read, so the
pi adapter exits 2 unless the path is a readable regular file.
### The unslop check (row 66)
Both adapters run the mechanical writing check from `tools/unslop-hook`,
driven by one `lists.json`. `pi` loads it as its second `-e` extension.
`claude` runs `claude-hook.js` from the bundle's `settings.json`: the Stop
hook checks the turn's replies, and the UserPromptSubmit hook adds the
correction notice as context on the next turn when the last reply had
tells. Each hook command ends `; exit 0`, so neither blocks. Both log to
`MOSAIC_UNSLOP_LOG`, and the runner adds a note for the operator to the
next RESULT it sends (`packages/harness/README.md`, the runner). The
harness modules mount the three files read-only at their own paths, and
the manifest records their digests. `MOSAIC_UNSLOP_HOOK` and `UNSLOP_LISTS`
aren't in the session environment's allowlist, so a managed session can't
switch the check off or swap its lists.
+8
View File
@@ -151,3 +151,11 @@ every run behind my busy check and Sage's, chained with `&&`.
Summed: 818 node tests passed with 0 failed, and 429 selftests passed with 0 failed. The compose network
was removed afterwards, and the sweep found no process left in the worktree, the output directory or the
TMPDIR. This file's results are a doc-only commit on top of the build, so nothing that ran has changed.
## Correction to deviation 6 (follow-up, 2026-10-21)
Deviation 6 says the node F9 runs is root's own. At 37171f65b the script didn't check that. F9 ran
`--node` as root before F10's path check, so the header's "root itself only reads" was false
(Duckworth's N1, `agents/duckworth/work/row195/REVIEW-m1.md` at 6afcee2a5). A host installed as designed
was safe, because `case2-unit.sh` refused a non-root-owned `--node`. Row 245 carries the fix and its
tests: `agents/darkwing/work/queue-245/BUILD.md`.
+91
View File
@@ -0,0 +1,91 @@
# Row 245 build: Duckworth's N1-N3 on `scripts/case1-accept.sh` (row 195 follow-up)
Duckworth approved row 195's manifest round 1 (duckworth-0088, `agents/duckworth/work/row195/REVIEW-m1.md`
at 6afcee2a5) with three notes that don't block. Row 195's code landed as b65014647, and the notes are in
`docs/plans/DEFERRED.md` (d6cff0cdd). Row 245 carries them: brief `docs/plans/2026-10-20_hub-own-uid.md` §
"Row 195 follow-up" at 62ddc96cc, owner Darkwing, reviewer Duckworth, gate owner Sage (sage-1923).
The branch is `darkwing/row245`, on 62ddc96cc. Its first commit is a cherry-pick of `darkwing/row195-n1`
1e3c4767c, which was written on 37171f65b and never run. The script, test and TOOLS.md blobs are the same
as in 1e3c4767c, and none of those paths changed between 37171f65b and 62ddc96cc.
## What changes
N1. F9 ran `$ROOT$NODE` as root (`case1-accept.sh:298` at 37171f65b) before F10 checked that `--node` is
the pinned path. Duckworth offered two fixes: F9's parse through `AS_USER`, or F10's path check first. I
took the path check, plus one more:
- F9 now runs node only when `--node` equals `/opt/mosaic-stack/node-<--node-pin>/bin/node`.
- Then `not_root_only` walks from that file up to `/`. Each entry must be the running user's (root's on the
host) with no group or other write, the rule `case2-unit.sh` `root_only` applied when it rendered the
units. A symlink fails it, because its mode is 777. The first entry that breaks the rule goes in the
evidence.
I didn't run the parse through `AS_USER`. That would hand a check about a root file to the stack's user,
and a process running as `mosaic` shares that node's uid. Whether it could change what node prints then
turns on the host's ptrace setting, and the root-only check doesn't depend on that. Sage agreed in
sage-1877. Rechecking ownership costs one loop and doesn't depend on
how the host was installed.
N2. The header (:9-15) and the `docs/TOOLS.md` entry now list what root runs: `systemctl show` and
`is-enabled` (F1, P11), `curl` on the loopback healthz (F2), `sudo -l` (P1, P3), `sudo -u jarvis bwrap
--version` and `hostcheck.sh --check` (P9), and node for F9 under the rule above.
N3. F6 matched `--claude` by prefix, so `/opt/mosaic-stack/claude-<pin>/../claude-2.0.9/...` passed the
path check. F6 now fails on any `..` segment, before it runs `--version`. F10 compares for equality, so
it needed no change.
Row 195's BUILD.md gets a correction entry for deviation 6. Row 195's DESIGN.md is unchanged. Its "either
works" wording at `DESIGN.md:336-338` is superseded by gosalyn-0087 (root writes `dev.args` at W10), and
it stays in place: an approved design isn't edited (sage-1877).
## Tests
`packages/cli/tests/case1-accept.test.mjs`:
- The node stub touches `node-as-root` in the state directory when something runs it outside the fake
`sudo` (which sets HOME to the stack's home). `host()` sets the node file's directories and the scratch
root to 0755, so the umask can't fail F9.
- "every check passing" also asserts that root ran the pinned node.
- New: "F9 won't run --node as root off the pin path or with a writable directory above it". Another node
path fails F9 and F10, with no root run. A group-writable `bin/` and a symlinked node each fail F9
alone, with the bad path in the evidence and no root run.
- The F6 test gains a `..` case: a path under `claude-<pin>/` by prefix that names 2.0.9's binary, which
prints the pin. F6 alone fails, with "has a .. segment".
That makes 23 tests. All 23 pass on Sage's slot (results below).
## Mutants
The twelve from row 195 still apply, unchanged. These four are new, each one exact replacement:
| # | Mutant | Expected killer |
|---|---|---|
| 13 | F9 runs any `--node` (the pin check becomes `true`) | F9 won't run (another node) |
| 14 | F9 skips the ownership walk (`bad=""`) | F9 won't run (writable `bin/`, symlink) |
| 15 | `not_root_only` checks only the file, not the directories above it | F9 won't run (writable `bin/`) |
| 16 | F6 accepts a `..` segment | F6 stub, path and `..` |
The brief's gate asks for a mutant for each of N1 and N3. Mutants 13-15 are N1's, and 16 is N3's.
## Slot plan
Same shape as row 195's steps, on this row's tip: the single test file, then the sixteen mutants, then
the gate (the cli suite and every `scripts/test-*.sh`), each behind the busy checks and chained with
`&&`. The scripts are in `/mnt/storage/scratch/darkwing-245-out/`.
## Slot results (grant sage-1961, 2026-10-21)
All three steps ran on 0590a29fc with node v26.8.1, launched from `/`. Both busy checks were CLEAR
before each step.
- Singles, 11:27:46-11:27:59Z: `case1-accept.test.mjs` passed 23, failed 0.
- Mutants, 11:28:10-11:31:20Z: all 16 killed, each with exit 1. The runner restored the script, and
its sha256 875bb5b7 matches the committed blob. Rows 1-12 died as in row 195. 13, 14 and 15 died on
"F9 won't run --node as root off the pin path or with a writable directory above it". 16 died on
"F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment".
- Gate, 11:31:20-11:42:51Z: the cli suite passed 664, failed 0, skipped 0. Every `scripts/test-*.sh`
exited 0: auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44,
land-work 86, queue 28 (its node part 156/0), release 14, task 98. That's 429 selftests, none failed.
- Afterwards no process had its cwd in the worktree, the out dir or TMPDIR, and the
`darkwing-r245_default` network was gone.
Logs: `/mnt/storage/scratch/darkwing-245-out/` (`step1.log`, `mut.out`, `mut/m1-16.log`, `gate.out`).
+191
View File
@@ -0,0 +1,191 @@
# Row 251 build: T27 lists no identities right after `ssh-add -t 1` (Vikunja #453)
Brief `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` (rev 2931), Mos ruling mos-1185 item 2, assigned in
sage-2075. Owner Darkwing, reviewer Dewey, gate owner Sage. Branch `darkwing/row251` from 3b0aef995,
claimed in-progress at rev 2938.
## Step 1: the failure names its cause (e5e4b2e5a)
T27's two lists now carry a message that prints only when they fail. It gives the time from addKey's
start to its resolve and to each `ssh-add -l` call, the hrtime at each with how far into its second it
fell, `/proc/uptime` at each, and whether the agent process and its socket were still there. The checks
themselves did not change. The commit holds nothing else.
## Step 2: the cause
ssh-agent counts a key's lifetime in whole seconds of CLOCK_BOOTTIME, at the add and at the reap. I read
OpenSSH 10.5p1, the version on dragon-lin (`ssh -V`: OpenSSH_10.5p1), at tag V_10_5_P1 (ssh-agent.c
sha256 873691c4, misc.c sha256 ee467f43):
- `misc.c:1790-1798` and `:1828`: `monotime()` returns `ts.tv_sec` of `clock_gettime(CLOCK_BOOTTIME)`,
falling back to MONOTONIC. The fraction of the second is dropped.
- `ssh-agent.c:1315`: the `-t` constraint sets `*deathp = monotime() + seconds`. Lines 1410 and 1598
do the same for the agent's default lifetime.
- `ssh-agent.c:1064`: the reaper drops a key when `now >= id->death`, `now` being `monotime()` again.
- `ssh-agent.c:2650`: the main loop runs `reaper()` after every poll, before it reads requests. A list
that arrives after the boundary sees the key gone.
So a key added at second s plus a fraction f dies at s + N, and `-t N` keeps it N - f seconds, between
N - 1 and N. With `-t 1`, an add late in a second leaves the key milliseconds to live. That matches
Sage's probe in the brief: 8 of 80 `-t 1` adds missed, each one made at 0.9947-0.9973 of a monotonic
second and finished at 0.998-0.0008, and none earlier in the second missed. It also matches proof 5's
failure, where the first list ran 31.9 ms into the test and found no identities.
Node's hrtime is CLOCK_MONOTONIC. BOOTTIME differs from it by the time the host spent suspended. On
dragon-lin the difference read 0.000001 s at 19:09Z, so a monotonic second boundary is a BOOTTIME one
here.
`addKey` and the agent start are not the cause. `addKey` passes the lifetime through as `-t`
(`host-run.mjs:90`), and the agent starts with no default lifetime (`:175`). Production adds the key
with `timeout + 30` (`:180`), where losing up to a second makes no difference. The only caller that
can't afford the lost fraction is T27, which asks for 1 s. The fix belongs in the test's lifetime choice,
and `host-run.mjs` doesn't change.
The slot confirms this against T27 itself (below), not just from the source.
## Step 3: the fix (this commit)
T27 adds the key with `-t 2`. The agent then keeps it more than 1 s and at most 2 s. The first list
runs milliseconds after the add, so it has over a second of margin even under load. The sleep before
the second list grows from 2.5 s to 3.5 s, which keeps the old 1.5 s margin past the new 2 s ceiling.
Both checks stay: listed after the add, gone after the lifetime. A comment above the add says why the
lifetime is 2.
I considered `-t 3` with a 4.5 s sleep for a 2 s floor. One second of floor is far above anything the
first list has needed (31.9 ms in proof 5), and the concurrent-suite run below tests it under load, so I
took the smaller change.
## Base
The branch stays on 3b0aef995. By 19:15Z origin/refactor was at 6096b051b, after rows 66 and 243
landed. `git diff 3b0aef995 6096b051b` touches neither `packages/bus/src/host-run.mjs` nor
`packages/bus/tests/host-run.test.mjs`, so there's no rebase (sage-2084). Row 243 did change
`packages/bus/tests/process.test.mjs`. My slot's bus suites run that file as it was at 3b0aef995, and the
gate rerun on Sage's slot runs it as landed.
## Prediction for part A on e5e4b2e5a (written before the slot)
The hook starts `ssh-add -t 1` at the given fraction of a monotonic second. From the brief's probe, the
agent stores the key about 3 ms later. The first list reaches the agent a few ms after addKey resolves:
Node's exit event, the mark, and `ssh-add -l`'s own start. A red needs the add to land before the second
boundary and the list to land after it.
- 0.990: green. The add and the list both land before the boundary.
- 0.993, 0.995, 0.996, 0.997: red expected. The add lands before the boundary, the list after. At 0.993
and 0.997 I'm less sure, because the list (at 0.993) or the add (at 0.997) can land right at the
boundary. A green at either is in line with the cause, and so is a red.
- 0.998: green. The add lands after the boundary, so the key gets almost a full second.
Every red should show "first list got" an answer with no SHA256 line. It should also show addKey
resolving before a second boundary and the first list starting after it or within a few ms of it, with
the agent alive and its socket present.
Under sage-2084's rule, on e5e4b2e5a only a red at 0.990 or 0.998 counts toward the stop, and two such
reds stop the run. On the fix commit any red counts, and two stop it. In parts B to D, the first red stops
the run.
## Slot plan
All scripts are in `/mnt/storage/scratch/darkwing-251-out/`, launched from `/`, each behind the busy
checks and chained with `&&`, TMPDIR under `/mnt/storage/scratch/tmp`.
1. Confirmation. `align-hooks.mjs` is a load hook, outside the repo, that serves `host-run.mjs` with
`addKey` wrapped to start `ssh-add` at a chosen fraction of a monotonic second. T27 runs unchanged
under it, alone (`--test-name-pattern`). It runs at fractions 0.990, 0.993, 0.995, 0.996, 0.997 and
0.998, three times each:
- on e5e4b2e5a (step 1, `-t 1`). Expected: some first lists fail, and the step 1 message shows the
add's resolve and the list on either side of a second boundary, with the agent alive and the
socket present. These reds are the point of the run.
- on the fix commit (`-t 2`). Expected: all 18 pass.
2. `host-run.test.mjs` whole, once, on the fix commit.
3. The bus suite whole, three times in a row.
4. The bus suite whole in two clean worktrees at the same commit, started together.
## Slot results
Sage's CLEAR came in sage-2154 at 20:58:55Z. `slot-251.sh` ran parts A and B from 20:59:22Z to 21:01:33Z,
on node v26.8.1 and OpenSSH_10.5p1, with TMPDIR `/mnt/storage/scratch/tmp/r251`. At the start, BOOTTIME
minus MONOTONIC read 0.000001 s, then 0.000000 s twice (dewey-0769 N1). So the hrtime fractions below
put the agent's second boundary in the right place.
### Part A: T27 alone under `align-hooks.mjs`, 3 runs per fraction
The fraction is when the hook started `ssh-add`. The hook logged each start within 0.0001 of its target.
| fraction | e5e4b2e5a (`-t 1`) | 68f2dfd4b (`-t 2`) |
|---|---|---|
| 0.990 | 3 pass | 3 pass |
| 0.993 | 3 pass | 3 pass |
| 0.995 | 3 red | 3 pass |
| 0.996 | 3 red | 3 pass |
| 0.997 | 3 pass | 3 pass |
| 0.998 | 3 pass | 3 pass |
e5e4b2e5a went red 6 times out of 18, every time at a fraction the prediction named, so none of them
counted toward the stop. 68f2dfd4b passed all 18. The fix runs used 68f2dfd4b, whose test blob is the same
as c19b4167e's (280132c7).
Every red failed on the first list and got `"The agent has no identities.\n"`, with the agent alive and
its socket present. The step 1 message gave these hrtime fractions. "addKey start" is the mark before the
hook's wait, so its fraction is earlier than the ssh-add start.
| run | addKey resolved | first list mark |
|---|---|---|
| 0.995 rep 1 | 0.9992 (+317.6 ms) | 0.9993 |
| 0.995 rep 2 | 0.9991 (+846.5 ms) | 0.9991 |
| 0.995 rep 3 | 0.9988 (+845.0 ms) | 0.9988 |
| 0.996 rep 1 | 0.9999 (+839.8 ms) | 1.0000 |
| 0.996 rep 2 | 0.0004 (+845.0 ms) | 0.0004 |
| 0.996 rep 3 | 0.0002 (+835.9 ms) | 0.0002 |
At 0.995 the add finished about 4 ms in, before the boundary, and the list mark came just before it. The
`ssh-add -l` process then reached the agent after the boundary. At 0.996 the agent stored the key before
the boundary, and addKey resolved just after it. In both cases the key's whole second ended between the
add and the list, which is the cause step 2 gives. The two edge fractions came out green on the first
build. At 0.993 the list still reached the agent before the boundary, and at 0.997 the add landed after
it, so the key got almost a full second. The prediction allowed either result at those two.
### Part B: `host-run.test.mjs` whole on 68f2dfd4b
24 tests, 24 passed (21:01:18Z, 14.0 s).
### The stop before C1, and the resume
At 21:01:33Z the script stopped before C1, with exit 4 and no red. My own `busy.sh` matched two `sleep 60`
processes, mine, in `darkwing-r251/packages/bus`. Part B left them. They come from T27 "the start sweep
keeps a live bus dir and kills only a matching live process" (`host-run.test.mjs:528` and `:536`). Its
`t.after` SIGKILLs the `/bin/sh -c 'sleep 60; :'` parent, but the `sleep` child lives on for up to 60 s.
That code is outside this row's diff, so the row leaves it alone, and it's recorded below as a follow-up.
Sage confirmed (sage-2159) that the right move is to resume only the scope that hadn't run.
`slot-251-cd.sh` runs C and D as `slot-251.sh` has them, with the same two busy checks. Before each check,
it waits up to 75 s for those orphans to exit: my uid, my trees, a command line of exactly `sleep 60`.
It logs them as it waits. Any other process still trips the checks.
### Parts C and D: the bus suite whole on 68f2dfd4b
| run | start | tests | pass | fail | time |
|---|---|---|---|---|---|
| C1 | 21:02:48Z | 598 | 598 | 0 | 21.6 s |
| C2 | 21:04:06Z | 598 | 598 | 0 | 21.4 s |
| C3 | 21:05:26Z | 598 | 598 | 0 | 22.1 s |
| D1, darkwing-r251 | 21:06:44Z | 598 | 598 | 0 | 24.2 s |
| D2, darkwing-r251b, TMPDIR `r251-b` | 21:06:44Z | 598 | 598 | 0 | 24.2 s |
The script waited 33 s before C2, 34 s before C3, 33 s before D and 32 s before the sweep. Each time it
was waiting for that run's `sleep 60` orphans (two per suite, four after D), and nothing else. After the
wait, the sweep of my trees, the out dir and TMPDIR found 0 processes left (21:08:04Z). Sage confirmed
(sage-2159, sage-2160) that the short gitea-api and `node --check` runs they made during the slot weren't
stops. None of my busy checks refused on any of them.
These runs used the bus files as they were at 3b0aef995. Since then, origin/refactor (666173292, then
7247d80c7 and c3d88840c with row 196) has changed `broker.mjs`, `runtime.mjs` and `server.mjs`, which T27's
file imports, along with other bus files. None of those commits touch this row's two paths or
`host-run.mjs`, and `git merge-tree` of c3d88840c with 68f2dfd4b is clean. The gate rerun in
a clean worktree on Sage's slot runs T27 against them.
### Follow-up, not in this row
T27's start-sweep test leaves the `sleep 60` child of each planted shell running for up to 60 s after
the test. Killing the process group (`process.kill(-pid)`; both shells are spawned `detached`) or
`exec sleep 60` in the shell would end the child along with its parent.
+98
View File
@@ -0,0 +1,98 @@
# Row 249 build notes (Gizmoduck)
Gitea #1751, Vikunja P19 445 under #436. Brief: docs/plans/2026-10-20_host-credentials.md, section
"Machine grant row b follow-up: the undo can't prove the remote add has ended" (6de1ebc62). Reviewer
Duckworth, gate owner Sage. Branch gizmoduck/row249-on-8a04, on row 248's landing 8a04f4aa4 (sage-2012).
No file under packages/bus/src changed between 6d5545d44 and 8a04f4aa4, so the brief's src line numbers
hold on the base. Line numbers below are on this branch.
## Correction to the brief
The brief, copying gizmoduck-0139, puts the sweep's host-unknown refusal at broker.mjs:1350. On
6d5545d44 it is :1349. On this branch it is :1354.
## The change
`#runHostGrant` in packages/bus/src/broker.mjs:
- :943 adds `installed = false` next to `installing`.
- :956 sets `installed = true` on the line after `await this.#hostGrant.install(...)`, before the
`stop()` that follows it. An abort that lands after install returned still ends revoked, with no hint,
because install returning means every request it sent has exited.
- :1025, in the catch, inside `if (installing)` and after the remove: `if (!installed) extra =
{ hostLineLeft: tag };`. The remove still runs first and its result doesn't matter. A remove that
throws sets the same hint as before (:1021).
The rule keys on install not returning, not on `signal.aborted`. That covers an abort (host-run.mjs:136,
SIGKILL to the local group), a timeout (host-run.mjs:131-134, SIGTERM, then SIGKILL after `graceMs`) and
a dropped connection, which fails the request with no signal at all. `#hostGrantFailed` (:845) already
adds "A line tagged X may be left on the host. Run: mosaic hosts sweep <host>" when `hostLineLeft` is
set (:852), so the message needed no change.
False hints. install is one call that runs `list`, maybe `remove`, then `add` (host-grant.mjs:329-333).
Any failure inside it now records the hint, including a `list` that exits 1 before any add was sent, and
an abort that lands before the first ssh starts (the G23 X5 case at "a revoke right after the spend").
The brief accepts this for an add that exits nonzero. The other cases are the same kind: the line isn't
on the host, and a sweep that doesn't list the tag drops the hint (G24, "a left tag no longer on the
host", broker.mjs:1358). Narrowing it would need install to report which request was in flight, which is
a change to host-grant.mjs's API that the brief doesn't ask for.
## Row a's enroll
The race can't happen there, because enrollment sends nothing to the host. `#runEnrollment`
(broker.mjs:772-842) writes the record, calls `this.#hostGrant.enroll(business, host)` (:800) and
commits the fingerprint. `enroll` (host-grant.mjs:247-286) runs ssh-keygen locally and writes the
private half to its file. It opens no ssh session. The installer line goes on the host by hand: the
notice tells ops-01 to write it, then to run `mosaic hosts check`. The undo at :835 is `unenroll`, which
deletes the local key file. There is no remote add to outlive a kill, and no remote undo to race with.
## Tests
packages/bus/tests/grant-executor.test.mjs:
- `lateAdd` (:451) is the shared tail. After the grant ends it asserts the code, `hostLineLeft` on
grant.failed, the sweep line in the notice, and that the undo's remove was the last request. Then it
writes `release`, so the held add writes its line and exits 0, and waits for the line in `granted`. It
checks there's no record, `hosts.status` lists the tag in `hostLineLeft`, `hosts.remove` refuses
host-line-left, `hosts.sweep` returns `removed: [tag]` and the line is gone, then `hosts.remove`
succeeds.
- :468, the abort case. `hold-add`, the human approves, the add holds, `abortClaim` aborts the
executor's claim. Ends revoked.
- :480, the timeout case. A 30 s wait is too long for the file, and making the timeout an option would
add a src seam, so the test wraps `install` through `opts.wrap` instead. The wrapper passes install its
own AbortController signal, waits for `holding`, aborts it, which SIGKILLs the local ssh group as the
timer does, then throws as a timed-out request does. The executor's signal isn't aborted, so it ends
host-failed. Nothing under src changes for it.
- G8 (:283) changes. fail-list and fail-add now carry `hostLineLeft` and the sweep line. noop-add fails at
verify, after install returned, so it keeps no hint and the old message.
- The comment at :436 (:432 at 6d5545d44) now says `skip` makes the held add exit without writing and that a
killed session's add can still write. G13 has no such comment.
Both new tests write `release`, so their held stub exits before `t.after` removes the dir. Row 248's dir
check (:67, :72) is the backstop.
## Mutant
/mnt/storage/scratch/gizmo-235-out/mutants-249.py, on row 235's runner. C1 removes the :1025 line and
runs grant-executor.test.mjs. Both new tests and G8 should fail under it. `--check` matches its anchor
once.
## Runs
On Sage's grant sage-2015, at 8457af076 (7cee1a1ab plus this file), TMPDIR /mnt/storage/scratch/g249t:
| Run | Window | Result |
|---|---|---|
| grant-executor.test.mjs whole | 13:29:01-13:29:21Z | 39 of 39 pass |
| C1 | 13:29:21-13:29:40Z | killed: both row 249 tests and G8 fail on `hostLineLeft` undefined |
After C1 the worktree was clean and broker.mjs matched HEAD. After the runs, no node, sshd or sh process had a
cwd under the worktree or g249t, and no `fake/add` or `fake/remove` stub was running. The bus suite runs on
Sage's gate.
## Rebase onto 8a04f4aa4 (sage-2012)
The first commit, 02bfd184b, sat on bc579f7a8 as gizmoduck/row249, which was never pushed. Row 248 landed
as 8a04f4aa4, changing grant-executor.test.mjs at :54, :67 and :72. gizmoduck/row249-on-8a04 starts at
8a04f4aa4 and cherry-picks 02bfd184b with no conflict. 248's lines stay as landed. The patch-id is the
same on both (c33823941e61). Test line numbers moved down by one, and this file was updated for that.
+87
View File
@@ -0,0 +1,87 @@
# Row 247 build notes, C2-20b persona import follow-up (#1749)
Brief: docs/plans/2026-10-20_case2-rows.md "C2-20b persona import follow-up: printed keys, the dry run
fence check and the mkdir race", landed in 335bb3e30. These are Webby's row 221 notes N1, N2 and N4
(webby-0073, REVIEW-r1 a0a00630e on webby/reviews). N3 is in DEFERRED (sage-1941).
## Base
Branch gosalyn/c2-20b from 335bb3e30, which is row 221's landing 300766450 plus the brief.
## What changed
- N1, `scripts/case2-persona-import.mjs`: the per-channel prompt line prints the platform key, with its
fixed `platforms.` or `gateway.platforms.` prefix, and the channel key through `JSON.stringify`. A key
holding a newline prints as `\n` inside one quoted line. The profile name on that line is not config
text: `pairing` already holds it to `PROFILE_ID`.
- N2: the dry run calls `personaTextFault` on each persona after its count line. A fault prints as
`profile P: persona <reason>, so C2-08's fence would refuse it`, where the reason is the fixed string
`personaTextFault` returns, never persona text or a line number. With any fault the run refuses, exit
1, and writes nothing, as stage would. The header comment no longer says a fault is counted, not
refused, and now says a SOUL.md over four times the fence in bytes refuses before any count, which is
what `soul()` already did (Webby's second point under N2).
- N4: `privateDir` catches an EEXIST from its mkdir and refuses with `<path> was made by something else
between the check and mkdir`. Any other mkdir error is thrown as before, since the brief names only
EEXIST.
- Tests, `packages/cli/tests/case2-persona-import.test.mjs`:
- The per-channel prompt test expects the quoted keys.
- New: a chet config.yaml with a newline in the platform key and in the channel key prints one quoted
line, and no output line starts with the forged text.
- The dry run heading test now expects the fence fault line, exit 1 and the refusal, still with
nothing written. Its persona has two heading lines, which the fence refuses.
- New: a persona of 8001 two-byte characters, 16002 bytes and under the 32000 byte cap, makes the dry
run print the over 8000 characters fault, write nothing and exit 1. At exactly 8000 it exits 0.
- New: the mkdir race refuses by name at stage, for the staging directory, and at install, for
`personas/<business>`. Each refused directory is left empty.
- New fixture `packages/cli/tests/fixtures/case2-persona-import/mkdir-race.mjs`, an `--import` preload
like `trace.mjs`. It makes the directory named by `$PERSONA_MKDIR_RACE` just before the script's own
`mkdirSync` of that path, so the script meets a real EEXIST from the kernel, not a thrown stub.
## Known limit of N1
`JSON.stringify` escapes C0 controls, `"` and `\`, but leaves DEL, C1, U+2028, U+2029 and the bidi
controls raw. None of those ends a line on a terminal, so the forged line N1 names can't happen. A bidi
control could still reorder how the rest of that one line displays. The brief asks for `JSON.stringify`,
and Sage kept it in sage-1964 because the keys come from Jason's own Hermes config. Note for Webby: row
231's `quote()` in packages/library covers the wider set, and this row doesn't import it. If you want it,
ask for it in review.
## Mutants
One per fix, plus a second for N1 so each of its two keys has its own. Each replaces one exact string
in the script, runs the test file, then restores the script from a copy and checks its sha256.
| Mutant | Fix | Change |
|---|---|---|
| M1 | N1 | the channel key prints raw |
| M2 | N1 | the platform key prints raw |
| M3 | N2 | the dry run's refusal after a fault is skipped |
| M4 | N4 | privateDir's EEXIST branch never matches, so EEXIST is thrown raw |
## Outside readers
`git grep` over packages/*/tests/* and scripts/test-*.sh finds `case2-persona-import` only in its own
test file and in two files of its fixture tree, README.md and trace.mjs, which name the test file in a
comment. No test pins a hash of the script.
## Tests
Two slot runs, `busy.sh && run.sh` in /mnt/storage/scratch/gosalyn-r247, on the test file alone.
Run 1, grant sage-1969, 11:47:45Z to 11:48:00Z, was red: 14 tests, 12 pass, 2 fail. The fault was in the
script. Adding `JSON.stringify` to the per-channel line dropped the space after the comma, so it printed
`"-1001",29 bytes`. The two per-channel tests caught it. run.sh also went on to the mutants with a red
base, so their counts meant nothing. Sage approved both fixes in sage-1971: the space goes back, and run.sh
now exits 3 before the mutants unless the base is rc=0 with fail 0.
Run 2, grant sage-1971, 11:49:04Z to 11:49:22Z, was green: 14 tests, 14 pass, 0 fail, rc=0.
| Mutant | Line | rc | Failed | Killed by |
|---|---|---|---|---|
| M1 | 237 | 1 | 2 | the per-channel prompt test and the newline key test |
| M2 | 237 | 1 | 2 | the same two |
| M3 | 238 | 1 | 2 | the dry run fence test and the dry run heading test |
| M4 | 192 | 1 | 1 | the mkdir race test |
After the run the script's sha256 was 7031d2c0dc9bd22bc748b364a35b461fc01cb98b795032a2e0ec436f3188d4e4, the
same as the copy taken before it. The /proc scan found no process in the worktree or the scratch dir.
+432
View File
@@ -0,0 +1,432 @@
# Row 197 execution ledger
Plan: MANIFEST.md r2, approved by Webby ca6b32d66.
Build grant: sage-1390. Suites, image builds and paired proof are not granted.
Rebased five documentation commits onto origin/refactor aa1bd209e before code.
Existing worktree herb/row197 and plain node_modules symlink retained.
- T1 preflight/steps: in progress.
- T2 locks/status/recovery: pending.
- T3 image/Docker: pending.
- T4 fixtures/timing: pending.
- T5 evidence/acceptance: historical timing done; implementation pending.
Preflight interfaces: T1 RunPaths supplies T2 ownership and T3 image checks.
T2 run receipt supplies T4 lane/port state and T5 attribution. T3 image identity
is checked before Docker-bearing steps. T4 timings supply T5 pair summaries.
No interface conflict found. No product API or assertion changes are authorized.
Ruling: use this approved BUILD.md as the execution ledger, rather than adding
skill workspace paths outside the manifest. This preserves the reviewed path
boundary. Missing ledger detail would cost recovery time, not product behavior.
## Implementation checkpoint 2026-10-20T20:11:58Z
T1-T5 source is present within the approved manifest. Full acceptance is pending.
Fresh single-file checks: preflight/supervisor 6/6; state/recovery 11/11; image and
Docker guard 4/4; port leases/attribution/proof summary 3/3. No skips or failures.
Existing files checked singly: board serve; WebUI serve, preview, proof-cli,
tester-cli and service; harness pi and Claude. Stack-release R1 selected single
passed 1/1. WebUI service also passed 31/31 with lane-a leases and a 30-byte root.
All existing assert lines are unchanged. Shell syntax and diff checks pass.
Observed defects fixed during the single-file cycles: cancellation incorrectly
returned zero when a TERM handler exited zero; recovery did not supersede an
unclean terminal; status omitted the lock-before-receipt window. Evidence logs
retain the failing and passing observations. Source still has no commit because
applicable suites have not run. No image build, proof, push or live operation.
Ruling: use an instrumented single-lane author gate on a frozen dirty-source
clone before committing source. The new wrapper requires a clean candidate and
proven image receipt, while repository rules require green suites before source
commits. The author gate retains every suite and assertion, caps concurrency at
two, uses own port/state/temp/project roots, and guards Docker against builds.
Cost if wrong: the author gate fails; clean-candidate wrapper baselines and the
paired proof remain mandatory after source is verified and committed.
Ruling: publish the initial run directory atomically after the start receipt is
complete. This avoids a second lane observing a partially written start. A
crash before publication has started no test or mutable Docker operation.
Cost if wrong: a lane refuses or quarantines; no protected state is bypassed.
## Implementation review corrections, 2026-10-20T20:33Z
Webby-0029 reviewed the first implementation packet at f942bd7d4.
B1 identified missing manifest tests. B2 identified a test that leased both real
bands through a private lock namespace. The old author-gate clear was withdrawn
in herb-0019. SOURCE.json, BUILD.patch and gate.sh still describe the old frozen
clone. They are not a clear for the edited working tree. No author gate ran.
B2 correction: the helper test uses only the active lane and MOSAIC_GATE_STATE.
It compares the other band arithmetically and has no unasserted listener. The
occupied-port probe first obtains its own real lease, binds that port, and
asserts that its sentinel listener survives refusal. No test reserves another
lane's band. Exhaustion narrows only the test process's band to one owned port.
B1 coverage added within the existing approved paths. No case is dropped:
| Manifest case | Added check |
| --- | --- |
| T1 canonical and builder checkout, existing output | gate-lane.test: direct validateRun refusals |
| T1 92-byte socket and over-107-byte socket | exact design example with a distinct gt- test root; 108-byte refusal |
| T1 both candidates carry tooling | validateTooling compares each launcher file, including missing and changed cases |
| T1 duplicates and NODE_TEST_CONTEXT | repeated package/script fixture is retained; sanitized environment is exercised by both supervised children |
| T2 one-winner race and inherited FDs | two actual shell wrappers race; a controlled supervisor verifies held FDs, and its child checks that no FD points to either lock file |
| T2 same checkout and legacy claim refusal | activeConflicts uses synthetic process observations with a live lane and a sibling landing gate |
| T2 surviving child and interrupted recovery | locked recovery kills a child proven by the private root, preserves a foreign child, is killed during fake Docker cleanup, leaves quarantine, and resumes under a new fixture grant |
| T3 missing receipt and missing image | verifyImage refuses absent provenance and failing inspection |
| T3 tag changed mid-gate and at gate end | runGate now uses the same executePinnedSteps boundary exercised by the tests; the next step never runs after a changed ID |
| T3 mutable-tag rebuild | fake preparation returns a new image ID with identical input digest; the old receipt refuses it |
| T3 cold and warm counters | preparation records one build and its time; warm execution records zero builds; repeated verification never builds |
| T4 parallel test-file leases | two generated Node test files hold ports in the same lane/state; locks are checked during child execution and shutdown |
| T4 exhausted band, occupied port and foreign listener | a one-port process-local band refuses both a held lease and an existing sentinel listener; the listener survives |
| T4 owner death | a killed allocator releases only its own holder; a foreign lease remains held |
| T4 lease through child shutdown | parent probes the lock while the fixture test runs and after its child has stopped |
| T4 fixtureCacheEnv | replacement child environment writes only under separate tmp, jiti and cache directories |
| T5 unrelated session append | a separate process waits for a running-step barrier, appends under a fixture HOME, and remains unrelated in the resulting metadata attribution |
N1 removes the unused closed binding. All leases reserve an unbound endpoint;
existing closed options need no call-site change. N2 makes child TMPDIR a tmp
subdirectory. N3 normalizes ss interface scopes before checking port 9. N4 uses
gt- for unit run roots. N5 documents the canonical node_modules dependency.
N6 is recorded: row 200 also edits stack-release.test.mjs; the second landing
must rebase and rerun that file singly. Sage was told in herb-0019.
Before Sage's hold, expanded preflight tests passed 11/11, state tests 14/14,
image tests 7/7, and the pi/Claude singles passed 8/8 each with separate tmp
subdirectories. Later source/test edits still need verification. In particular,
the new parallel test-file probe had a cleanup-order defect and is unverified.
Its root cleanup ran before child shutdown. Cleanup now stops children before
removing their root. Output is retained for a useful failure report. The initial
FD probe also assumed Node did not reopen FD numbers; it now checks the actual
lock targets. The early passing fixture file covered child allocators, before
they were changed to actual generated test files. It is not acceptance of the
latest file.
Sage-1448 stopped all node --test runs at 20:30Z for Rocko's browser slot.
The remaining owned process tree was stopped at 20:30:32Z. Inspection found no
remaining owned test processes afterward. Edits and static inspection continue.
20 module syntax checks, shell syntax and git diff checks pass. Existing-file
assertion lines remain identical to HEAD. No test is run during the hold.
Next: after Sage passes Rocko's clear, verify the corrected files singly,
including a lane-env helper-file check. Then refresh the frozen clone and all
three packet artifacts, send their hashes, and request a new author-gate clear.
Suites, image builds and paired proofs still need their own grants. No source
commit, push, suite, real Docker build or paired proof has run in this phase.
## Refrozen review packet, 2026-10-20T20:41Z
Sage-1454 lifted the single-file hold. Fresh runs passed without failures or
skips: review-t1-resume.log 11/11, review-t2-resume.log 14/14,
review-t3-resume.log 8/8, review-t4-resume.log 8/8. The helper file also passed
8/8 with lane-a environment in review-t4-lane-a.log. Its retained state had only
an a port-lock namespace, no b directory, and every lock was released. Generated
fixture Node test entries explicitly use --test-concurrency=2. The child-test
probe now finishes and cleans up before removing its root. All 41 new tests pass.
B1 and B2 fixes are ready for Webby's review. No manifest case was dropped.
All 25 source paths were copied to a new frozen clone and compared by mode and
SHA-256. This supersedes the earlier withdrawn source packet:
- SOURCE.json: 91ba149f2fc039a8bed0e6d1ad5da44b80fb8cbec84d0fa4e38616040138596b
- gate.sh: 48e06e23e45d3484755f8cd79ad5b6dfde5ef4f960081e7c455a2540e707fed1
- BUILD.patch: 0b3871bd9848ca223c7cda50266debc04643e5d986eb7f0fece096aaf25ff43e
- Frozen checkout: /mnt/storage/scratch/mg197-a-d22dacd92735/wt
- Output: /mnt/storage/scratch/mg197-a-d22dacd92735/out
- Temporary root: /mnt/storage/scratch/ga-VPXhG7 (30 bytes)
- Compose project: mg197-a-d22dacd92735
After an explicit author-gate grant, run this command with Sage's actual ID:
```sh
env MOSAIC_GATE_GRANT=ACTUAL_SAGE_GRANT_ID bash /home/jwoltje/.t3/worktrees/mosaic-stack/herb-row197/agents/herb/work/queue-197/gate.sh > /mnt/storage/scratch/mg197-a-d22dacd92735/out/gate.log 2>&1
```
No gate or suite has started. Queue order from sage-1454 is 192, 201, 204,
200, Gander, then 197. No branch deletion is authorized or attempted. Any later
rebase uses a new branch name, and pr-merge.sh -d stays off under #426(a).
Whichever of rows 197 and 200 lands second must rerun stack-release singly.
Clean-candidate wrapper baselines, image preparation and paired proofs remain
separately gated acceptance work. There is still no source commit or push.
## Implementation approval and final notes, 2026-10-20T20:47Z
Webby-0031 approved r2 in REVIEW-impl-r2.md at 1f404c973. Their static review
verified every source hash/mode and patch result, and all B1/B2 cases. No queue
review request comment exists yet; Sage owns creating it if needed.
Folded all three optional notes into the same approved paths. The helper file's
standalone fallback now shares /mnt/storage/scratch/mosaic-gate-lanes with real
lane-a leases and leaves its stable lock files in place. Band assertions use the
imported BANDS. Both T5 polling children fail after five seconds if their barrier
is missed. README records the shared standalone namespace and deadlines.
Fresh single-file logs review-r3-fixtures.log and review-r3-lane-a.log both pass
8/8 with zero skips. The lane-a run has no b lock directory, and every lock was
released. Syntax and diff checks pass. No existing test assertions or timeouts
changed. Remaining source matches the 11/14/8-file checks from r2.
Refreshed all 25 source rows in the existing frozen checkout. New digests replace
the preceding r2 packet. gate.sh and its command are unchanged:
- SOURCE.json: 3584763642506e81a532c839bdd07b111d25daa87e35dbe441d8e8c01a2c2aab
- gate.sh: 48e06e23e45d3484755f8cd79ad5b6dfde5ef4f960081e7c455a2540e707fed1
- BUILD.patch: 55fe390da17ab926a0593d39e79561abe52756a1b1a2c756ec57f45d90f10fde
Author gate remains unstarted and queued for Sage's explicit grant. No source
commit, suite, image build, paired proof, push or branch deletion ran.
## R3 approval receipt
Webby-0033 approved r3 in REVIEW-impl-r3.md at a5643f9bd. Static review verified
the three packet hashes, all 25 source rows, and the patch result. Their review
accepts N1-N3; no additional hang-guard test is requested. No queue review request
comment exists yet, so Webby has not recorded a queue review.
Read-only verification after receipt confirms that the approved packet hashes
and all 25 worktree/frozen source hashes and modes still match. Source and gate
script stay frozen. The author gate has not started and still needs Sage's grant.
## Queue review round 1
Sage-1476 authorized moving row 197 to in-review before the author gate.
Created CANDIDATE.sha256 from the same 25 reviewed SOURCE.json rows. Its digest
is 6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3.
Ran the queue move from the canonical checkout with op herb-197-review-r3.
The move succeeded at revision 2395, opening round 1 on #1695. The request post
failed before send because MOSAIC_GITEA_CREDENTIAL_FILE is not set; revision 2396
records the failed outcome. Exit code 1 describes the failed post, not a failed
row transition. Read-back confirms state in-review and the correct candidate.
Sage will post as jarvis and give Webby the request comment number. No credential
lookup, alternate post or retry was attempted here.
SOURCE.json, BUILD.patch, gate.sh and all 25 frozen source rows remain unchanged.
Latest slot order is row 190 r2, 201, 204, 200, Gander, then Herb. No gate starts
before Sage sends an explicit grant ID.
## Follow-on assignment
Sage-1503 assigns row 215, Case 2 C2-04, to Herb after row 197 (queue rev 2455,
Gosalyn design r11). Read docs/plans/2026-10-20_case2-rows.md, C2-04 section,
at 5b8def2f1 when starting that row. Its required base is origin/refactor
305857c8f. This assignment does not replace row 197 or authorize its gate.
Keep row 197's approved source frozen while awaiting Sage's grant. Start row
215 only after row 197, using a new branch name under the existing fleet rule.
## Round 2 merge preparation on row 233
Sage-1757 replaces history replay with one candidate commit on 57f59e0c3.
The attempted history replay on herb/row197-r2-233 had already finished when
that instruction arrived. Its only conflict was the append-only BUILD-LOG tail.
That branch remains for history; none of its replayed commits is in this round.
The current branch is herb/row197-r3-233, directly from
57f59e0c399ce29b1bb85ca6af686fdc10eb195d.
Stash correction: shared refs/stash was used incorrectly. The exact entry
2767b88549dfd8c7e810908b5416876fa50cb571 is now kept on
herb/row197-r3-wip. After verifying stash@{0} still named it, only that entry was
dropped. The two other entries remain. No further stash creation, force push,
branch deletion or refs/stash mutation is planned. A separate working-copy
backup is /mnt/storage/scratch/herb197-merge-W4oiLg.
Sage-1760 requires preserving every landed hunk on every changed manifest path.
Checked each of the 25 r1 paths with git diff aa1bd209e 57f59e0c3 -- PATH.
Exactly three changed upstream. Their r2 diff against 57f59e0c3 is captured in
r2-merge-only.patch:
| Merged path | Only row 197 changes against landed base | Landed content retained |
| --- | --- | --- |
| packages/cli/tests/stack-release.test.mjs | leasePort and laneAfter imports; freePort now calls the lease helper | All landed tests, including row 233's tests after R15 and BACKUP_STORE checks |
| packages/webui/tests/service.test.mjs | leasePort and laneAfter imports; closedPort now calls the lease helper | SEND_REFUSAL permits --sso; default unit rendering removes the @CONFIG_ENV@ line |
| scripts/test-discord.sh | fixture PI_CODING_AGENT_DIR setup; optional two-worker nested Node cap | Row 190's absent scratch MOSAIC_CONFIG selection and caller-config restoration |
The other 22 paths equal the r1 reviewed source bytes and modes. r2-MERGE.json
lists every path, its r1/r2 digest, mode, upstream-change flag and equality result.
Source copies came from the hash-verified r1 frozen checkout. Original r1 digest:
6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3.
No existing assertion or timeout line differs from the landed base. Static
checks only: 20 module syntax checks, four shell syntax checks and git diff check
pass. BUILD-LOG.md equals 57f59e0c3 byte for byte and is outside this commit.
Sage explicitly requested the local candidate SHA before granting tests. This
commit is an untested preparation checkpoint, not gate evidence. No tests or
push are authorized yet. r2-TEST-FILES.txt lists the requested grant scope:
16 Node test files singly and scripts/test-discord.sh, test-foundation.sh and
test-queue.sh. The four stack-release consumers are stack-release.test.mjs,
stack-release-external.test.mjs, stack-upgrade.test.mjs and stack-instances.test.mjs.
The shared stack-release-fixture.mjs is exercised by its importing test files,
not run as a standalone test. This covers all 13 touched Node test files plus
three additional stack-release consumers and all three touched shell suites.
Current slot order from sage-1760: Gizmoduck, Launchpad, Darkwing, then Herb,
around 06:30Z. Wait for an explicit grant. After granted checks and authorized
push, build the new candidate manifest from that pushed tip and open the
merge-scoped round 2. Old SOURCE.json, CANDIDATE.sha256 and gate.sh remain r1
historical artifacts and are not a clear to run the merged candidate.
## Round 2 rebuilt on row 234
Sage-1776 names landing c771a36f4bd0b6c359282425c668996a90072f93.
Built a new branch, herb/row197-r4-234, directly from that landing. No history
replay, stash, force push, branch deletion, test run or push is involved.
The prior candidate a3b7cbde8 remains untested and is not the queued candidate.
Checked git diff --name-only 57f59e0c3 c771a36f4 against all 25 source paths.
Only packages/cli/tests/stack-release.test.mjs overlaps; the other 24 do not.
The full comparison is in r2-on234-OVERLAP.txt. The stack-release file starts
from the landing and keeps both T46 untimed entries, host_grants and hosts.
Its only changes against the landing are the leasePort and laneAfter imports
and freePort's call to the lease helper. r2-on234-merge-only.patch records those
hunks. Existing assertions and timeouts match the landing exactly.
Every other source path equals a3b7cbde8 byte for byte and retains its mode.
This includes the previously merged service and test-discord files, preserving
all landed --sso, CONFIG_ENV and scratch-config handling. The earlier evidence
r2-MERGE.json still records those three pre-234 merge cases. The new
r2-on234-MERGE.json records each current digest, prior digest, mode and equality
result. BUILD-LOG.md equals the new landing byte for byte and is excluded.
Static check of the changed Node file and the candidate diff pass. No tests
were run. The scope stays r2-TEST-FILES.txt: 16 Node files singly plus the three
shell suites. No manifest or queue round is rebuilt until granted checks and
authorized push. Old SOURCE.json, CANDIDATE.sha256 and gate.sh remain historical
r1 evidence. The test slot follows Darkwing 240 and needs Sage's explicit grant.
## Round 6 preparation (sage-1883, mos-1162)
New branch herb/row197-r6 starts at fefd54e13, after the standalone row
212 landing ee7b2f137. No r5 manifest path changed between e7078e6a8 and
this base; all 32 r5 blobs and modes were copied intact before the two
umask changes. No history replay, stash, force push or branch deletion.
executeSteps now launches each argv through /bin/bash with umask 022
and exec, preserving the process group and argv boundaries. The supervisor
and gate-lane.sh retain 077, including state, locks, private logs and roots.
New gate-lane tests assert the actual child umask and created file mode,
the unchanged parent mask, private log mode, and actual wrapper-created
state/lock modes. No shell, image preparation, credentials test or other
existing assertions changed. process.test.mjs equals the landed base;
lane-image.mjs equals r5. N7 remains DEFERRED at fefd54e13; rows 243 and
244 own the explicit chmod and PI_AUTH_FILE fixtures respectively.
Read Sage's permitted failed pkg-bus log: process.test startup token refusal
saw ready.ok true, consistent with inherited 077 narrowing its fixture mode.
No further diagnostic read of the retained ga-8I3iZc root is needed.
No r6 tests or probes have run. Source commit, push and new manifest await
the explicit granted checks. r6-OVERLAP.json records the empty overlap.
Sage-1892 focused grant: gate-lane.test whole 13/0, process.test whole
through executeSteps 8/0, zero skips/cancellations. Syntax checks passed.
All new umask/mode assertions passed. No Docker, image, lane or proof run.
Empty /proc cwd/argv scan and removal recorded in r6-granted/cleanup.json;
clear herb-0068 at 09:21Z. The source matches r5 on 29 of its 32 paths,
including modes. Only BUILD.md, gate-lane.test.mjs and gate-lane.mjs differ.
The new r6-OVERLAP.json records the base comparison. No process test edit.
Sage-1895 authorizes one source commit and seat-only push after range check.
## Round 7 preparation (sage-1951, sage-1955)
The r6 proof passed all package suites with the original process.test,
then failed script-release. The guard injected --no-build into compose run;
Compose 5.5.1 rejects it before container creation. The old unit test only
asserted the same invalid argv, so it could not detect this compatibility
error. This supersedes no-build-flag claims in the historical packet.
herb/row197-r7 starts at b1fece6f0. No r6 manifest path changed between
fefd54e13 and this base; all 33 r6 blobs/modes were copied intact before
r7 tests were authored. No history replay, stash, force push or deletion.
Sage-1955 accepts the private tempRoot/compose-no-build.yaml override,
mode0600, with build: !reset null and image pinned to the receipt's imageId.
The guard owns the -f arguments and forces run --pull never --no-deps.
Caller-supplied Compose config/build/pull options remain refused.
The existing proof removes successful temp roots and retains failed roots;
the override stays within that same cleanup ownership.
Tests first: installed-binary run --help before SERVICE checks parser
acceptance without starting a container. A separate compose config --format
json check uses the actual candidate compose.yaml, made-up env/auth paths,
and an empty Docker config directory, asserting no build key and the exact
pinned image. The no-build-restoration mutant must fail that parser test.
No test, probe or Docker command has run on r7; waiting for Sage's grant.
Primary references: Docker's merge reference documents !reset null:
https://docs.docker.com/reference/compose-file/merge/#reset-value
Docker's run reference documents --pull and --no-deps, with no --no-build:
https://docs.docker.com/reference/cli/docker/compose/run/
Installed 5.5.1 acceptance and merged configuration are still unverified.
Sage-1956 focused grant, 2026-10-21 11:19-11:21Z: busy check CLEAR.
Whole gate-lane-image.test on the r6 guard: 9 pass, 2 fail. The installed
Compose parser emitted unknown flag: --no-build; the other expected failure
was the missing override helper. No cancellation or skip. After the fix,
whole file 11/0. The committed r7-no-build.mutant.patch restores the invalid
flag. Applied only for the installed parser regression: 0/1 with the same
unknown-flag error, killed. Restored source and whole file passed 11/0 again.
Both approved node --check commands passed. Empty /proc cwd/argv scan;
clear herb-0073 at 11:21:04Z. No container start, image build, lane, proof or
full suite ran. Logs/counts/scan are in r7-granted/ (local run evidence).
The guard supplies both absolute -f paths, suppressing automatic override
selection, pins receipt imageId, removes build via !reset null and adds
--pull never --no-deps. Its main entry publishes the override before Docker
runs. A unique private staging file plus hard-link publication prevents
partial writes or replacement under concurrent guard calls; existing bytes,
mode0600, file type and ownership must match, with O_NOFOLLOW on reads.
The file stays under the run's short private tempRoot for existing proof
cleanup. Caller Compose options remain allowlisted and config/build/pull
options refuse. The actual installed Compose config test reports no build
key and the exact image ID; parser help is before SERVICE, so no container
operation can run. Fixtures supply made-up env/auth paths and empty Docker
configuration. Existing image provenance, cleanup and umask behavior remain.
Range check: one source commit on b1fece6f0, same 33 r6 paths plus the
mutant patch and r7 overlap receipt (35 paths). Thirty r6 paths retain the
same blob/mode. Only lane-docker.mjs, gate-lane-image.test.mjs and BUILD.md
differ from r6. process.test and BUILD-LOG equal the landing. No force push,
branch deletion, history replay or other source edits. Sage-1956 authorizes
the source commit and seat-only push; fresh proof remains Sage's action.
## Round 8 preparation (sage-1979)
The r7 D5 proof passed business and bus, then failed the two installed
Compose probes in pkg-cli. Their subprocess fixture retained the lane's
shim-leading PATH but omitted MOSAIC_GATE_DOCKER_RECORD, so the shim read
an undefined path. This is a test context error; the production guard stays
byte-identical to r7.
New herb/row197-r8 starts at 5c6f73ed7. None of r7's 35 manifest paths
changed from b1fece6f0 to this base. Tests choose the record's real absolute
Docker path when a lane record is set; outside lanes they keep using docker.
Only the two read-only help/config probes use this helper. The subprocess
env still holds fixture-only configuration; the record need not propagate
to it. No run or policy allowlist is widened.
Sage-1979 grants 215's 16 singles first, then r8's normal and lane-shaped
shim-leading PATH whole-file checks and the record-lookup-removal mutant.
The shim fixture invokes the actual unchanged guard, with a private made-up
record naming the real Docker binary. No image build, container start, lane
or proof is part of r8 checks. No r8 checks have started yet.
Sage-1979 r8 checks followed 215's early singles, then clear herb-0078 at
2026-10-21 12:09:28Z. Whole gate-lane-image.test normal PATH11/0; whole
actual guard-shim-leading PATH plus private record11/0. The committed
r8-record-lookup.mutant.patch removes the lookup: installed-Compose argv
regression0/1, killed with the original Received undefined path error.
Restored code, repeated whole shim-PATH file11/0. All zero skips/cancellations.
Logs/counts/fixture metadata/empty /proc cwd+argv scan are in r8-granted/.
Both 215 and r8 private roots were removed after an empty process scan.
No image build, container start, suite, lane or proof ran for r8.
Range: one commit on5c6f73ed7. Of r7's35 paths,33 keep the same blob/mode;
only gate-lane-image.test and this BUILD.md change. The r8 mutant and overlap
receipt are added. The production guard and all other tooling match r7.
BUILD-LOG and process.test equal the landing. No history replay, force push,
branch deletion, live state change or policy widening. Fresh proof remains
Sage's action after Webby's merge review.
+207
View File
@@ -0,0 +1,207 @@
{
"r1Base": "aa1bd209e",
"r1Candidate": "6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3",
"r2Base": "57f59e0c399ce29b1bb85ca6af686fdc10eb195d",
"rows": [
{
"path": "packages/cli/tests/fixtures/gate-lane-child.mjs",
"r1Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"r2Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/gate-lane-fixtures.test.mjs",
"r1Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"r2Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/gate-lane-image.test.mjs",
"r1Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"r2Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/gate-lane-state.test.mjs",
"r1Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"r2Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/gate-lane.test.mjs",
"r1Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"r2Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/stack-release.test.mjs",
"r1Sha256": "8ca733a600d5f5534d7e14d8533e1b16d710ebd43080ef8421238b5dd145fade",
"r2Sha256": "b95c0c125b6a283961e24adc07d43150bcbd05d2f11005180b836043015c52a6",
"mode": "0o644",
"landedPathChanged": true,
"r1BlobEqual": false
},
{
"path": "packages/control-board/tests/serve.test.mjs",
"r1Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"r2Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/harness/tests/claude-session.test.mjs",
"r1Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"r2Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/harness/tests/pi-session.test.mjs",
"r1Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"r2Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/webui/tests/preview.test.mjs",
"r1Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"r2Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/webui/tests/proof-cli.test.mjs",
"r1Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"r2Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/webui/tests/serve.test.mjs",
"r1Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"r2Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/webui/tests/service.test.mjs",
"r1Sha256": "c915239f0c223209baf84c96ec9f5ae2845fce7dd98ad46b073a96033aa7d6df",
"r2Sha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
"mode": "0o644",
"landedPathChanged": true,
"r1BlobEqual": false
},
{
"path": "packages/webui/tests/tester-cli.test.mjs",
"r1Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"r2Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/test-discord.sh",
"r1Sha256": "c10af53b0717344026cb22462964f253c7401c5082ac6ef3722a89d9dfa5c661",
"r2Sha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
"mode": "0o755",
"landedPathChanged": true,
"r1BlobEqual": false
},
{
"path": "scripts/test-foundation.sh",
"r1Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"r2Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"mode": "0o755",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/test-queue.sh",
"r1Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"r2Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"mode": "0o755",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/README.md",
"r1Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"r2Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/gate-lane.mjs",
"r1Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"r2Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/gate-lane.sh",
"r1Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"r2Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"mode": "0o755",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-docker.mjs",
"r1Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"r2Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-evidence.mjs",
"r1Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"r2Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-fixtures.mjs",
"r1Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"r2Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-image.mjs",
"r1Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"r2Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-state.mjs",
"r1Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"r2Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
}
]
}
@@ -0,0 +1,19 @@
packages/cli/tests/gate-lane-fixtures.test.mjs
packages/cli/tests/gate-lane-image.test.mjs
packages/cli/tests/gate-lane-state.test.mjs
packages/cli/tests/gate-lane.test.mjs
packages/cli/tests/stack-instances.test.mjs
packages/cli/tests/stack-release-external.test.mjs
packages/cli/tests/stack-release.test.mjs
packages/cli/tests/stack-upgrade.test.mjs
packages/control-board/tests/serve.test.mjs
packages/harness/tests/claude-session.test.mjs
packages/harness/tests/pi-session.test.mjs
packages/webui/tests/preview.test.mjs
packages/webui/tests/proof-cli.test.mjs
packages/webui/tests/serve.test.mjs
packages/webui/tests/service.test.mjs
packages/webui/tests/tester-cli.test.mjs
scripts/test-discord.sh
scripts/test-foundation.sh
scripts/test-queue.sh
@@ -0,0 +1,45 @@
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
index 8788158c3..a78f52062 100644
--- a/packages/cli/tests/stack-release.test.mjs
+++ b/packages/cli/tests/stack-release.test.mjs
@@ -0,0 +1,2 @@
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
+import { after as laneAfter } from 'node:test';
@@ -1777 +1779 @@ const TESTER='7499';
-const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
+const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
index a6e66608a..adb460d07 100644
--- a/packages/webui/tests/service.test.mjs
+++ b/packages/webui/tests/service.test.mjs
@@ -0,0 +1,2 @@
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
+import { after as laneAfter } from 'node:test';
@@ -48,9 +50 @@ function filled(flags, root = REPO) {
-async function closedPort() {
- const server = createServer();
- server.listen(0, '127.0.0.1');
- await once(server, 'listening');
- const { port } = server.address();
- server.close();
- await once(server, 'close');
- return port;
-}
+async function closedPort() { return leasePort({after:laneAfter}, {closed:true}); }
diff --git a/scripts/test-discord.sh b/scripts/test-discord.sh
index 114994936..4daf0d143 100755
--- a/scripts/test-discord.sh
+++ b/scripts/test-discord.sh
@@ -73,0 +74,2 @@ TOOLS_JSON="{\"roots\":[{\"name\":\"docs\",\"path\":\"$SANDBOX/toolroot\"}],\"ma
+export PI_CODING_AGENT_DIR="$SANDBOX/pi-agent"
+mkdir -p "$PI_CODING_AGENT_DIR"
@@ -145 +147,8 @@ fi
-node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
+node_tests() {
+ local lane_concurrency=()
+ if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
+ [ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
+ lane_concurrency=(--test-concurrency=2)
+ fi
+ env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
+}
@@ -0,0 +1,235 @@
{
"base": "c771a36f4bd0b6c359282425c668996a90072f93",
"priorCandidate": "a3b7cbde8ef51c206a83769a7c272e4f9a1de5bc",
"originalCandidate": "6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3",
"overlap": [
"packages/cli/tests/stack-release.test.mjs"
],
"rows": [
{
"path": "packages/cli/tests/fixtures/gate-lane-child.mjs",
"r1Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"priorCandidateSha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"newSha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/gate-lane-fixtures.test.mjs",
"r1Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"priorCandidateSha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"newSha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/gate-lane-image.test.mjs",
"r1Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"priorCandidateSha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"newSha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/gate-lane-state.test.mjs",
"r1Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"priorCandidateSha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"newSha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/gate-lane.test.mjs",
"r1Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"priorCandidateSha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"newSha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/stack-release.test.mjs",
"r1Sha256": "8ca733a600d5f5534d7e14d8533e1b16d710ebd43080ef8421238b5dd145fade",
"priorCandidateSha256": "b95c0c125b6a283961e24adc07d43150bcbd05d2f11005180b836043015c52a6",
"newSha256": "071ea2803b8c7e0450bb4c86ac9b49ae7c180af1599dca52bfce6d0274ca205b",
"unchangedFromPrior": false,
"overlaps234": true,
"mode": "0o644"
},
{
"path": "packages/control-board/tests/serve.test.mjs",
"r1Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"priorCandidateSha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"newSha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/harness/tests/claude-session.test.mjs",
"r1Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"priorCandidateSha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"newSha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/harness/tests/pi-session.test.mjs",
"r1Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"priorCandidateSha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"newSha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/preview.test.mjs",
"r1Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"priorCandidateSha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"newSha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/proof-cli.test.mjs",
"r1Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"priorCandidateSha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"newSha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/serve.test.mjs",
"r1Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"priorCandidateSha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"newSha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/service.test.mjs",
"r1Sha256": "c915239f0c223209baf84c96ec9f5ae2845fce7dd98ad46b073a96033aa7d6df",
"priorCandidateSha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
"newSha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/tester-cli.test.mjs",
"r1Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"priorCandidateSha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"newSha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/test-discord.sh",
"r1Sha256": "c10af53b0717344026cb22462964f253c7401c5082ac6ef3722a89d9dfa5c661",
"priorCandidateSha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
"newSha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o755"
},
{
"path": "scripts/test-foundation.sh",
"r1Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"priorCandidateSha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"newSha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o755"
},
{
"path": "scripts/test-queue.sh",
"r1Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"priorCandidateSha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"newSha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o755"
},
{
"path": "scripts/testing/README.md",
"r1Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"priorCandidateSha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"newSha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/gate-lane.mjs",
"r1Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"priorCandidateSha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"newSha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/gate-lane.sh",
"r1Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"priorCandidateSha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"newSha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o755"
},
{
"path": "scripts/testing/lane-docker.mjs",
"r1Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"priorCandidateSha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"newSha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/lane-evidence.mjs",
"r1Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"priorCandidateSha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"newSha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/lane-fixtures.mjs",
"r1Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"priorCandidateSha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"newSha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/lane-image.mjs",
"r1Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"priorCandidateSha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"newSha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/lane-state.mjs",
"r1Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"priorCandidateSha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"newSha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
}
]
}
@@ -0,0 +1,47 @@
Compared 57f59e0c3 to c771a36f4bd0b6c359282425c668996a90072f93
agents/gizmoduck/work/queue-234/BUILD.md
docs/SESSIONS.md
packages/bus/README.md
packages/bus/schema.sql
packages/bus/src/broker.mjs
packages/bus/src/credentials.mjs
packages/bus/src/host-grant.mjs
packages/bus/src/host-run.mjs
packages/bus/src/migrations/3i-3j.sql
packages/bus/src/runtime.mjs
packages/bus/src/server.mjs
packages/bus/src/store.mjs
packages/bus/tests/account-migration.test.mjs
packages/bus/tests/chief-of-staff.test.mjs
packages/bus/tests/comment-migration.test.mjs
packages/bus/tests/fixtures/schema-3i.sql
packages/bus/tests/grant-enroll.test.mjs
packages/bus/tests/grant-migration.test.mjs
packages/bus/tests/hard-waits.test.mjs
packages/bus/tests/host-fixtures.mjs
packages/bus/tests/host-run.test.mjs
packages/bus/tests/host-tools.test.mjs
packages/bus/tests/name-migration.test.mjs
packages/bus/tests/project-runtime.test.mjs
packages/bus/tests/schema.test.mjs
packages/bus/tests/spec-fixtures.mjs
packages/bus/tests/spec-migration.test.mjs
packages/bus/tests/thread-migration.test.mjs
packages/bus/tests/usage-migration.test.mjs
packages/business/src/business.mjs
packages/business/src/hosts.mjs
packages/business/tests/hosts.test.mjs
packages/cli/README.md
packages/cli/src/cli.mjs
packages/cli/src/hosts.mjs
packages/cli/tests/hosts.test.mjs
packages/cli/tests/stack-release.test.mjs
scripts/mosaic
tools/host/mosaic-keys
tools/host/mosaic-keys-write
tools/host/mosaic-keys.sshd.conf
tools/host/mosaic-keys.sudoers
Overlap with row 197 source manifest:
packages/cli/tests/stack-release.test.mjs
Other 24 source paths: no overlap.
@@ -0,0 +1,10 @@
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
index daae6efb7..910785bc4 100644
--- a/packages/cli/tests/stack-release.test.mjs
+++ b/packages/cli/tests/stack-release.test.mjs
@@ -0,0 +1,2 @@
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
+import { after as laneAfter } from 'node:test';
@@ -1779 +1781 @@ const TESTER='7499';
-const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
+const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
@@ -0,0 +1,5 @@
{
"base": "fefd54e13",
"prior": "9aa6d52d9",
"overlaps": []
}
@@ -0,0 +1,6 @@
{
"base": "b1fece6f0",
"r6": "df0c59608",
"r6Base": "fefd54e13",
"overlaps": []
}
@@ -0,0 +1,11 @@
--- a/scripts/testing/lane-docker.mjs
+++ b/scripts/testing/lane-docker.mjs
@@ -34,7 +34,7 @@
const service=argv.indexOf('mosaic-agent',2);
if(service<0||argv.slice(2,service).some(a=>!['--rm','-T','--no-deps','--interactive','-i'].includes(a)))refuse('Docker Compose run options not allowed');
const [base,override]=composeFiles(run);
- return ['compose','-f',base,'-f',override,'run','--pull','never','--no-deps',...argv.slice(2)];
+ return ['compose','-f',base,'-f',override,'run','--no-build','--pull','never','--no-deps',...argv.slice(2)];
}
refuse('Docker mutation or unowned command');
}
@@ -0,0 +1,6 @@
{
"base": "5c6f73ed7",
"r7": "9c3166915",
"r7Base": "b1fece6f0",
"overlaps": []
}
@@ -0,0 +1,11 @@
--- a/packages/cli/tests/gate-lane-image.test.mjs
+++ b/packages/cli/tests/gate-lane-image.test.mjs
@@ -85,7 +85,7 @@
// including when the lane's guard is first on PATH. Child fixture env stays
// isolated; passing the real absolute path avoids re-entering that guard.
function installedDocker(){
- const file=process.env.MOSAIC_GATE_DOCKER_RECORD;
+ const file=undefined; // mutant: lose the lane's real-binary lookup
if(!file)return 'docker';
const binary=JSON.parse(readFileSync(file,'utf8')).docker;
assert.ok(typeof binary==='string'&&isAbsolute(binary),'lane record must name the real absolute Docker binary');
@@ -0,0 +1,17 @@
# Row 197 proof observations (Sage)
Kept here, not in Herb's BUILD.md, because that file is one of row 197's 37 paths and the landing check
(mos-1174, mos-1185 item 5) requires them equal to c45ddeb6b.
## Open observation (mos-1185 item 3), 2026-10-21
Proofs 4 and 5 each stopped on a test outside 197's paths, in a pair wave, and both times in lane b:
- Proof 4, pair1-b, pkg-conversation, K12 (`cohort.test.mjs:292`): `populated` undefined. Row 250, Vikunja #450.
- Proof 5, pair2-b, pkg-bus, T27 (`host-run.test.mjs:469`): no identities right after `ssh-add -t 1`. Vikunja #453.
Mos checked the step log birth times: the twin step started in both lanes within half a second each time
(proof 4 pkg-conversation a 11:03:59.343, b .855 CDT; proof 5 pkg-bus a 12:07:25.354, b .573). Waves run in
lockstep, so that holds for every pair wave, the passing ones too. Lane b is the twin that starts later.
Mos's reading: contention exposing fragile tests, not a 197 defect. Paired lanes add contention, and that
is 197's cost, covered by #451.
+149
View File
@@ -0,0 +1,149 @@
# Row 218 draft notes: claims and sources
Drafts for `docs/case2/SETUP-SHEET.md` and `docs/case2/SURVEY-RUN.md`. Repo base: `refactor` at
947101d8a, which holds 29ce1e360 (row 231). "Design" means
`git show 75fb3e10e:agents/gosalyn/work/case2/DESIGN.md`, cited as `D:<line>`. "Brief" means
`docs/plans/2026-10-20_case2-rows.md`. Code paths are relative to the repo root. Nothing was run
except reads, greps and `git merge-base --is-ancestor 29ce1e360 HEAD`.
## SETUP-SHEET.md
| Claim | Source |
|---|---|
| She keeps writing in Obsidian, obsidian-git pushes to git.mosaicstack.dev, nothing changes on her machine, nothing commits to her repo | D:939-940 (7.1) |
| Agents read what she pushed; a push reaches them in about a minute | D:1006-1007 (7.5); D:972-973 (60-second sync timer) |
| Agents read `.md` notes inside book folders only | D:457-461 (1.2); `packages/library/src/scope.mjs:9-19` |
| Not read: top-level notes, `.canvas`, `.base`, attachments, anything outside a book folder | D:461 |
| Other repos under `~/src` are not read | D:489 (1.3), D:494-497 (N4) |
| Agents can't write to her repo: canon, playground, anything | D:389 (D3); D:1008 |
| Folder roles live in the `projects` entry of the business file, hand-edited, agents can't change it | D:409-429, D:443-451 (1.1, M9) |
| Jason asks her to confirm the mapping first | D:431; D:1324-1328 (10 step 7) |
| A folder named in `playground` is her playground | Brief:22; D:434 |
| Each playground prefix sits inside one book folder | D:434-436; `packages/library/src/mapping.mjs:77-80` |
| One per book, e.g. `<book>/playground/`; until then everything mapped is canon | D:438-439 |
| Agents search canon unless they ask for the playground | D:463 |
| Playground may back an idea or question, not a fact, finding or correction; refused before she sees it | D:858 (`playground-cited-as-canon`); D:378-382, D:835-839 (guard runs before store) |
| Only the folder decides; a tag or property can't change scope | D:388 (D2); D:440-441; `scope.mjs:1` |
| Graph colour group via Groups and a `path:` query, her own vault setting, changes nothing for agents | Brief:23; D:941-943. Obsidian UI steps from the task prompt, not the design |
| Path starts with the books repo folder because the vault is `~/src` | D:939 |
| N2: moving a note into a book folder makes it canon, agents may cite it as fact | D:944-945; D:259 |
| Rough drafts outside the playground are canon | Inference from D:460 (scope rule) |
| N4: Obsidian resolves short links across `~/src`, the reader sees one repo, the two can differ, the reader can't tell | D:494-497; D:262-263 |
| A name only in another repo leads nowhere for agents | D:489 (R4); `packages/library/src/links.mjs:61-87` |
| A full-path link resolves the same in both | D:497-498. Hub side: `links.mjs:77-78` (`vault-prefix` step) |
| "New link format: Absolute path in vault" setting | Obsidian UI knowledge. Not in design or code. Reviewer may strike it |
| `appears_in` and `relationships` links are read as links | D:489-490; `packages/library/src/reader.mjs:15`, `:153-162` |
| Quoted flow list `["[[A]]", "[[B]]"]` parses | `reader.mjs:81-96` (flow), `:66-73` (quoted scalar), `:159` |
| Block list with quoted items parses | `reader.mjs:117-122`, `:66-73` |
| Unquoted `[[[A]], [[B]]]` is R3 | Brief:28-29; `reader.mjs:87` (bracket inside a flow list stops parsing), `:137` |
| R3 effect: whole front matter unread, note still indexed by path, name and body, listed as a problem | D:480; `reader.mjs:103-105`, `:193`; `packages/library/README.md:69` |
| Problems show in Mosaic's status | D:473-474; D:999-1000 (7.4 status line) |
| A value without `[[ ]]` is a name | D:489-490; `reader.mjs:159` |
| Bean and Chet keep names and personalities | D:660-661 (Q180) |
| Hermes: free text and story text. Mosaic: short cited items, a prose request gets a question or idea | D:778-781 (design 4) |
| Bean and Chet's item kinds: idea, question, finding, fact; web cites allowed | D:649 (`companion`); D:860 (`citation-unfetched`) |
| Limits 10 items, 5 ideas of 200, others 300 | D:819 (M5) |
| She or Jason can ask for lower limits | D:1329-1330 (10 step 8); D:831 |
| "No cited answer" notice | D:870-871, D:996-997 |
| One item can't mix two worlds | D:859 (`citation-cross-universe`) |
| She talks to them in the Console; Hermes keeps running until she moves over; stopping it is her choice | D:381-382; D:1331 |
| Five more helpers; she chooses the start set and can add one later | D:647-654; D:783-784; D:1327-1328 |
| What Jason will ask | D:1325-1327 (10 step 7) |
## SURVEY-RUN.md
| Claim | Source |
|---|---|
| Runs on `<host>` 10.1.1.54 under design 10 step 7, after step 4 | D:1324; D:1266 (host) |
| Prints names and counts, never note text, property values, file names or link targets | `packages/library/src/survey.mjs:1-6`; README:118-125 |
| Sending to Melanie is Jason's call | Brief:36; D:1324-1328 |
| Token slot `library-gitea` is step 4 | D:1314-1316 |
| Run as `sudo -u mosaic -H` from `localadmin`; `mosaic` has no login shell | D:1195-1197; D:1281-1282 |
| Copy at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`, `<id>` = commit in Jason's go | D:1286-1292 (2c); `docs/TOOLS.md:417`; home `/srv/mosaic` per D:1314-1315 |
| The copy carries `packages/library/src` | `scripts/stack-copy.sh:36` |
| Row 231 adds the control and bidi escapes | `git log` 29ce1e360; `survey.mjs:78-82`; README:121-122 |
| `<node dir>` is the `--node` directory of step 2c; `scripts/mosaic` execs `node` from PATH | D:1290-1291; `scripts/mosaic:41` |
| Dispatch: `mosaic library survey` goes to `survey.mjs` with `survey <args>` | `scripts/mosaic:37-41` (one `shift` leaves `survey` as argv[0]); `survey.mjs:102-103` |
| Command and flags: `<repo>` plus optional `--mapping <file>` once; anything starting with `-` refuses | `survey.mjs:16`, `:104-110` |
| No fetch; reads `HEAD` | `survey.mjs:5-6`, `:27`; `reader.mjs:22-24` |
| Top folder only: ceiling is the path's parent | `packages/project/src/git.mjs:12`, `:62` |
| Owner must be `mosaic`: git gets no global config | `packages/seat/src/checkout.mjs:79-89` (`GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`). The dubious-ownership refusal is git's own behaviour |
| Bus mirror path `<dataRoot>/projects/<business>/<project>` | Brief:300; `packages/project/src/paths.mjs:34-40` |
| C2-04 and C2-03a not landed | `grep` of `packages/*/src` finds no library entry handling or `refs/heads/served`; `docs/guides/host-run-sheet.md:269` says "not landed" |
| Served ref `refs/heads/served` | D:963 (7.2); Brief:676 |
| Token kept out of URL, config, argv, logs | D:955-956 (7.2) |
| Mapping file is the entry alone; `remote`, `branch` required | `survey.mjs:114-116`; `mapping.mjs:38-47` |
| Output lines and fields | `survey.mjs:83-97`; data from `:26-76` |
| Folder lines list folders that directly hold an entry, full path | `survey.mjs:30-35` |
| Key names: short identifiers print, others as `(other key)` | `survey.mjs:17-20`, `:47` |
| `unresolved` equals the R4 count | `survey.mjs:72` |
| Scope counts cover all tree entries | `survey.mjs:66` |
| Refusal exit codes 0, 2, 3, 4 | `survey.mjs:99-124`; README:127-129 |
| Mapping checked before repo | `survey.mjs:112-121` |
| stderr format `library survey: <message>` | `survey.mjs:101`; `problems.mjs:3-5` |
| `(project-git)` suffix on git failures | `reader.mjs:18-20`; `paths.mjs:4-6` |
| `library-playground-outside-book` message | `mapping.mjs:79-80` |
| 100-second git timeout | `checkout.mjs:49`, `:52`; `git.mjs:68` |
| Exit 127 when `node` is missing | `scripts/mosaic:18`, `:41` (bash `exec` failure). Not run |
| Other errors rethrow as a stack trace | `survey.mjs:117`, `:123`, `:128-131` |
| Don't work around a refusal | AGENTS.md invariant 6 |
## Code and design disagreements
1. Design 1.1 shows the mapping nested in `"projects": {"books": {...}}` (D:412-429). The survey's
`--mapping` file must be the inner entry alone (`survey.mjs:114-116`). The run sheet follows the code.
2. Design 1.3 calls `vaultPrefix` part of the mapping (D:418). The code makes it optional
(`mapping.mjs:48`, README:30). Without it, a full vault path link, the N4 advice, resolves to
nothing in the hub (`links.mjs:73-87`: `root` fails, `vault-prefix` is skipped, `shortest` refuses a
`/`). The setup sheet's full-path advice holds only when Jason sets `vaultPrefix` to her books repo
folder name.
3. The task prompt calls the unquoted form "refused". R3 is not a refusal. It is a reported problem,
and the note stays indexed (D:473, D:480). The sheet says what happens instead.
## Open questions
1. Where `<repo>` comes from on .54. The survey reads a local repo's `HEAD` and never fetches. The bus
mirror needs C2-04 and C2-03a, neither landed. C2-03a moves `refs/heads/served`, not `HEAD`, so the
mirror's `HEAD` may name no commit and the survey would refuse with exit 2. The sync also needs a
checked mapping with at least one book (`mapping.mjs:59`, `:70`), and the mapping's values are
meant to come from this survey (D:431). Sage needs to rule: a placeholder mapping for the first
sync, a `HEAD` the sync sets, a `--commit` flag on the survey, or another source.
2. The brief says Obsidian's property editor writes `appears_in: ["[[A]]", "[[B]]"]` (Brief:28-29,
webby-0041 N4). I didn't read webby-0041 or check Obsidian's output. Both the flow and the block
quoted forms parse, so the sheet shows both and says only that the editor adds the quotes.
3. Whether `/srv/mosaic/.npm-global/bin/mosaic` exists on .54. The run sheet uses the copy's
`scripts/mosaic` by full path to avoid depending on a wrapper.
## Sage's rulings on the open questions (2026-10-21)
1. Survey source: the bus's bare mirror after the first `library.sync`, the only copy on the host
(design 2.1). The C2-03a brief now requires the mirror's `HEAD` to be a symbolic ref to
`refs/heads/served`, with a test (5c6f73ed7), so the survey reads the served commit with no ref flag.
2. Property list forms: the sheet shows both quoted forms, since both parse. Webby checks the claim
that Obsidian's property editor writes the quoted form.
3. The "New link format: Absolute path in vault" tip stays. It is an Obsidian setting, not a design
claim, and Webby may strike it.
4. The run sheet calls the copy's `scripts/mosaic` by full path. That holds whether or not a wrapper
is on `PATH`.
5. `vaultPrefix`: the run sheet's step 4 tells Jason to set it, because the setup sheet's full-path
link advice depends on it.
## Round 2 (2026-10-21), from Webby's round 1 notes (webby-0075, rev 2848)
- N1, by Mos's ruling mos-1165 (fix before landing): SETUP-SHEET.md now gives the 1,200 character
total of item text per reply (design M5, step 8). The word "Today" is gone. No code enforces these
caps until C2-06 lands, so my ruling is that the sheet goes to Melanie only after C2-06 lands, and
then only on Jason's go (#419). The wording stays as is, because by then it holds.
- N2: SURVEY-RUN.md says tab, newline, carriage return, backspace and form feed print as `\t`, `\n`,
`\r`, `\b` and `\f`, and other control and bidi characters as `\uXXXX` (survey.mjs quote()).
- N3: SURVEY-RUN.md has a refusal row for `library-commit:` (reader.mjs listTree). survey.mjs turns
every LibraryError into exit 2 with its message.
- N4: SURVEY-RUN.md's new step 2 checks `command -v node` under the same sudo and PATH before the
survey runs.
## Round 3 (2026-10-21), from Webby's round 2 note N5 (webby-0077, rev 2856)
- N5: the library-commit row no longer says a moved `HEAD` causes it. The survey reads `HEAD` once,
and listTree raises library-commit only for an id that isn't 40 hex or an object that isn't a
commit (reader.mjs:29, :31). In practice that means a SHA-256 repository, so a rerun doesn't help.
The row now says "for example in a SHA-256 repository" and "Report to Sage".
+200
View File
@@ -0,0 +1,200 @@
# Row 66: managed sessions run the unslop check on both harnesses
Sage, 2026-10-20. Brief: `docs/plans/2026-10-11_managed-session-config.md`, section "Managed
sessions run the unslop check on both harnesses", blob 21ae25a328d4. Issue #1559. Reviewers:
Dewey and Filbert. Built in the worktree `/mnt/storage/scratch/sage-b66` (branch `sage/row66`)
on base 5487ed39d.
Candidate: see the commit line in the review request. `candidate-manifest.sha256` in this
directory has the sha256 of each changed file at the candidate.
## What changed
**Wiring.** Both harness modules mount `tools/unslop-hook/lists.json`, `unslop-check.js` and
the harness's own entry (pi `extension.ts`, Claude Code `claude-hook.js`) read-only at their
own paths, and the bundle manifest lists the three in `sources` with their digests. Pi loads
`extension.ts` as the second entry of `MOSAIC_EXTENSIONS`, after the gate extension. Claude
Code's `settings.json` gains a Stop hook and a UserPromptSubmit hook, each
`timeout -k 2 10 '<node>' '<dir>claude-hook.js' stop|prompt; exit 0`, hook timeout 20, no
matcher. Neither can block a reply or a prompt.
**claude-hook.js** (new). `stop` checks each assistant message with text since the turn's
prompt and logs `checked` or `flagged`. `prompt` checks the last reply and, when it has tells,
prints the extension's notice, word for word, as `additionalContext`, once per reply (it reads
the log for an earlier `notice_injected` with the same entry id). A broken lists.json logs
`broken_lists`; any other fault logs `hook_failed`; the exit is always 0.
**The operator channel.** The runner sets `MOSAIC_UNSLOP_LOG` to `<run>/unslop.jsonl` after the
module's variables, so a module can't point it elsewhere. After the landing turn and after each message turn it reads what the
log gained (whole lines only, from an offset). The next RESULT it sends ends with a note. The
whole note, both parts and the blank line before them, is at most 600 characters (r2):
- `[unslop: N reply|replies of this session flagged since the last note: <details>. The session gets a correction notice on its next turn.]`
- `[unslop: the check is broken, so this session's replies go unchecked: <reason>]`, once a run.
That RESULT is what `mosaic talk` and `mosaic follow` print. A turn that sends no reply (an
incoming RESULT, the landing turn) keeps its note for the next one. `note()` doesn't change
state and `sent()` runs only after `message.send` succeeds, so a failed send keeps the note.
`clip()` caps the body at 32000 characters plus its 33-character cut line before the note, so the
sum is at most 32633, under the bus limit of 32768. The broken reason is cut at 300 where the
runner takes it, from the log or from its own read failure, so the broken part is at most 371
characters and the flagged part is cut to what is left.
**Counts.** `unslopCounts(path)` reads the log after the session ended:
`{checked, flagged, patterns: {<source>: n}, notices, broken}`. A missing log gives zeros.
Findings now carry `source`, the entry's pattern id (`ms-unslop:7`), so `patterns` is per id.
**The stack copy (r2).** `scripts/stack-copy.sh` `COPY_PATHS` gains `tools/unslop-hook`. Both
modules join their files under `REPO`, and a session launched from a pinned copy has
`REPO = copies/<sha>`. Without it every managed session from a copy past this row would fail at
bundle build with ENOENT (Dewey P1, comment 29440).
## Round 2
Round 1 (candidate 8ec03b45a, manifest e68c6bce5ec4) got changes from Filbert (29439) and Dewey
(29440). Round 2 carries:
- F1 (both): a note with both parts wasn't bounded. A 300-character reason, a long flagged part
and a clipped answer came to 32934 (Filbert) or 33004 (Dewey), over 32768, and the broker
refused the send. `note()` now gives the flagged part only the room the broken part leaves, and
`fresh()` cuts every reason at 300, the runner's own read-failure reason included. Tests: the
runner test with an answer of 40000 characters, a 400-character `hook_failed` reason and 30
flagged replies (note ≤ 600, body ≤ 32768, cut line at 32000), and a unit check that both
parts come to exactly 600 and that a read failure on a 250-character path is cut.
- F2 (Filbert; Dewey's M3): no test failed a send through `main()`. The new runner test has the
broker refuse the first RESULT. The note then rides the next RESULT. One correction to the
F1 consequence as written: the broker marks a received message delivered, so the refused
message isn't received again in that run. It stays unread and its sender gets no reply; the
turn doesn't rerun.
- P1 (Dewey): `COPY_PATHS` gains `tools/unslop-hook`. The copy fixtures in
`stack-copy.test.mjs` and `pinned-copy.test.mjs` copy `tools/unslop-hook` too, and the
pinned-copy top-level list gains `tools`. A new `stack-copy.test.mjs` test makes a copy, loads
both harness modules from it, and checks that the adapter, the six unslop paths and the mounts
are under the copy and exist.
- N1 (Filbert): the two session-test comments say `<dir>/unslop.jsonl`.
- N2 (Filbert), doc-only follow-up: the brief's `<run>/session/unslop.jsonl` (:463) and "print
mode notifies nobody" (:477) are stale. The brief is Jason's text for this row; the deviation
below records the path.
- Dewey's notes, no change: the Claude Code Stop timing (finding 3 below) gets a look in the
landing session; `patterns` keys per entry source, which is the brief's "per pattern".
r2 mutants, each killed by its intended test (`mutants-r2.log`):
| Mutant | Killed by |
|---|---|
| K1 the r1 `note()` cut | the clipped-answer runner test (the send is refused and it times out) and the unit check |
| K2 `fresh()` doesn't cut the reason | the unit check |
| K3 `sent()` before `message.send` | the refused-send runner test |
| K4 `COPY_PATHS` without `tools/unslop-hook` | the copy test |
## Ship list against the brief
| Brief item | Where |
|---|---|
| A reply with a tell gets a correction notice on the next turn, both harnesses | `pi-session.test.mjs`, `claude-session.test.mjs`, row 66 four-turn tests: notices per request `[0, 1, 1, 2]`, notice text matched in the second request |
| A clean reply gets none | same tests: turn 2 is clean and turn 3's request carries no new notice |
| Broken lists.json fails closed: no clean verdict, operator told once, session alive | the row 66 broken-lists tests per harness: checked 0, flagged 0, first note broken, second note empty, the session answers both turns |
| Counts readable after the session ends | `unslopCounts` on each session's log: `{checked: 4, flagged: 2, notices: 2, broken: null}`, patterns non-empty |
| Interface 1 and the export list unchanged | no harness module export added or removed; the runner gains `unslopLog`, `unslopCounts`, `unslopNotes` |
## Findings made while building
1. **extension.ts read the wrong reply.** Pi 0.85.1 `getBranch()` returns entries root first
(`session-manager.js:958-967` reverses the leaf-to-root walk). `before_agent_start` took the
first assistant entry it met, which was the session's first reply, every turn. In the
four-turn test the old code gave notices `[0, 1, 2]` over three turns (turn 3 re-noticed
turn 1); walking from the end gives `[0, 1, 1]`. Fixed in `extension.ts`; this also affects
the fleet copy under `~/.mosaic/tools`, which I didn't touch.
2. **claude-hook replies() merged replies.** Grouping by `message.id` across the transcript
merged two replies that shared an id (the test API answers `msg_mock` for all). It now groups
adjacent assistant lines only, keyed by the first line's uuid. Before the fix turn 4 got no
notice (`[0, 1, 1, 1]`).
3. **Claude Code Stop timing.** On the first turn of a new session the transcript didn't yet
hold the reply when Stop ran, so the hook checked `last_assistant_message` with an empty
`entryId`. On resumed turns the reply was in the transcript. The verdict is the same either
way; only the entry id differs.
4. The finding detail format is `banned word "delve" x1`.
## Deviations
- **The log path is `<run>/unslop.jsonl`, not the brief's `<run>/session/unslop.jsonl`.** A
thread run's session directory is the thread's store, and
`packages/cli/tests/thread-launch.test.mjs:179` pins that a thread run keeps no per-run
`session/`. My first build made `<run>/session` for the log and failed that test in the gate
(452/453). The brief's other line, "`unslop.jsonl` in the run directory", still holds, and each
run keeps its own log.
- **`packages/seat/tests/sandbox.test.mjs`** isn't an owned file. Its pin
`binds(args).length === list.length - 1` broke because the modules now mount three more files.
It is now `- 4`, and the test asserts the three unslop read-only binds by name.
- **`scripts/stack-copy.sh`** and the two copy tests aren't owned files. The copy change is P1
above. PB3 of the Part B window (`repin-partB-proof.md`) holds `scripts/stack-copy.sh` still
until B7, so this row lands after the Part B window has run.
- `MOSAIC_EXTENSIONS` is set through the pi module's `env`, not in the runner.
- `unslop-check.js` gained the `source` field on findings (needed for per-pattern counts).
## Limits
- The log is in the run directory, which the session can write. A session could forge or erase
its own counts. They are the session's report, not host evidence.
- The pi extension's dedupe is in memory: after a restart, or when a turn's final message is
tool-only, the same earlier reply can get a second notice.
- `hook_failed`, `skipped_broken` and `broken_lists` all count as broken for the operator note.
- No blocking (Q84 option B), no list changes, no skill changes: out of scope by the brief.
## Gate
Recorded with the candidate in the review request: test-unslop-check, the harness, plugins,
seat and cli suites, and every `scripts/test-*.sh`. At landing Sage runs one managed session per
harness and attaches the notice, the correction and the counts.
## Round 4
Round 3 (2369c00e3 on sage/row66-r3, base c40d846cb, manifest 42e3876ca168) had approvals from
Filbert (29770) and Dewey (29771). Since then rows 200, 199, 232, 233, 240 and 238 changed three
of the 23 paths: `docs/TOOLS.md`, `scripts/stack-copy.sh` and
`packages/cli/tests/stack-copy.test.mjs`. Round 4 is the two round 3 commits cherry-picked onto
4ced5e63d (sage/row66-r4). Neither pick had a conflict, and the tree equals a hand merge of each
path with `git merge-file`. No code changed: `git diff BASE TIP | git patch-id --stable` is
6d085621fd35 for both rounds.
- Row 232 added `copy --bundle` (`copy_bundle`). It builds the bundle with `copy_tree`, which
copies `COPY_PATHS`, so a bundle copy also carries `tools/unslop-hook`. No extra change needed.
- The Part B hold under Deviations is void. Part B was superseded by the Q175 SSO work and will
not run, so nothing holds `scripts/stack-copy.sh`.
- Review scope: the merge on the three moved paths and this section. The other 19 paths have the
same blobs as round 3.
Rebased again on fefd54e13, after row 212 landed (ee7b2f137), as branch `sage/row66-r4b`, before the round 4
request went out. 212 added `packages/library/src` to `COPY_PATHS` and a library line to the
`stack-copy.test.mjs` file list. None of the four picks had a conflict. Every file's patch-id equals the 4ced5e63d
build's except `scripts/stack-copy.sh`, where the only change is the hunk's context line
(`packages/queue/src packages/library/src`). Row 66's two added lines are the same.
Rebased again on 29ce1e360, after rows 195, 213 and 231 landed, as branch `sage/row66-r4c` (r4b stays at
49ad61963). Two of row 66's paths moved: `adapters/README.md` and `docs/TOOLS.md`. TOOLS.md merged without a
conflict. In adapters/README.md, row 213 rewrote the skill-cap paragraph, because `claude` now gets the prompt
as a file. The merge keeps 213's paragraph whole and adds row 66's "The unslop check (row 66)" section after
it. Row 66's old cap sentence is dropped, since it described the argument limit that 213 removed. Every other
path has the same blob as on r4b. Row 213 also changed the harness and both adapter scripts, so all of row 66's
harness tests run whole again on this base before the request.
Rebased again on 7b9737953, after rows 245, 248 and 249 landed, as branch `sage/row66-r4d` (r4c stays at
670b83f65). Only `docs/TOOLS.md` moved under row 66: row 245 rewrote the paragraph on what root reads in the
host check. The cherry-pick had no conflict. `git patch-id --stable` of row 66's whole diff is 508bb684eade on
both bases, and so is each file's own patch-id, TOOLS.md included. So row 66's hunks are byte-identical and only
their context moved. The one test that names TOOLS.md, `packages/cli/tests/stack-release.test.mjs`, uses it in a
message string and is in the singles list.
Rebased on a36523713 after row 197 landed (mos-1186 item 4), as branch `sage/row66-r5` (r4d stays at
82c830779). Row 197 also touched `packages/harness/tests/claude-session.test.mjs` and `pi-session.test.mjs`.
The cherry-pick had no conflict. The range-diff shows commit 1 differing only in one hunk header of
pi-session.test.mjs, where git now names the context line. Commits 2-7 are `=`. `git patch-id --stable` is
0049d05fb585 for the whole diff and d67275ac4251 without `agents/` on both bases. Correction: the r4d note
above gives 508bb684eade for the whole diff. That number came from e25f76f54 before the 7b9737953 rebase. The
whole-diff number on r4d is 0049d05fb585. The harness suite runs whole again on this base before landing.
Correction (Filbert round 4 N2, comment 30086): the r4c paragraph says "Row 66's old cap sentence is
dropped." That's wrong. In round 3's diff the 64 KiB cap sentence is unchanged context, so row 66 never
wrote it. Row 213 replaced it, and none of row 66's lines were dropped. The landing sessions are in
LANDING-SESSION.md.
@@ -0,0 +1,43 @@
# Row 66 landing sessions (mos-1186 items 2-3)
Sage, 2026-10-21. Both ran on a scratch data root built from the candidate tree, branch `sage/row66-r5`
at 273fff6f9 on a36523713, as row 64's D2 did (rc13bcd555652). The scripts were
`/mnt/storage/scratch/sage-r66r5x/d2s/d2-build.mjs` and `d2-drive.mjs`. They used the business example
with stand-in token files, tracker and Gitea URLs pointing at 127.0.0.1:9, and a scratch project repo.
Nothing live was touched. The six skills were installed and enabled on each data root with
`scripts/skill.sh` from the candidate tree.
Before the sessions, the harness suite ran whole on 273fff6f9: 149 pass, 0 fail, 12 s
(2026-10-21T18:47:42Z).
## Claude Code, run r4ab24630d323 (18:48Z)
The chief and cto were bound, and the coder was launched through `launcher.fromSession` on claude-code,
model claude-sonnet-5-5. The launch bundle names `tools/unslop-hook/claude-hook.js`, `unslop-check.js`
and `lists.json`.
- Turn 1. cto asked for one sentence with four banned words. The RESULT body `mosaic talk` would print
ends with the operator note:
`[unslop: 1 reply of this session flagged since the last note: banned word "additionally" x1; banned word "crucial" x1; banned word "testament" x1; banned word "vibrant" x1. The session gets a correction notice on its next turn.]`
- Turn 2. `unslop.jsonl` logs `notice_injected` for that turn. The coder's transcript has the notice as
UserPromptSubmit hook additional context in the API request:
`UNSLOP NOTICE (mechanical style check, not the user speaking): your previous reply contained violations of the fleet writing standard (SYSTEM.md / ms-unslop): ...`.
cto asked the coder to quote it. The coder answered "I can't answer that one, and I won't quote
anything from it." So the notice reached the model, which chose not to repeat it. Its reply checked
clean.
- Counts from `unslopCounts`: checked 2, flagged 1, patterns `ms-unslop:7` 4, notices 1, broken null.
This scratch instance has no `mosaic talk` view of its own, so the note above is the RESULT body the bus
returned to cto. That is the text `mosaic talk` and `mosaic follow` print.
## pi, run r1dd94171bede (launch only)
The coder launched on pi, model claude-sonnet-5-5. The launch bundle names
`tools/unslop-hook/extension.ts`, `unslop-check.js` and `lists.json`. No turn ran. This host has no
credential pi reads until P19 #383 settles, and mos-0865 D2 rules out mapping the Claude Code token into
a pi variable. No `unslop.jsonl` was written. Counts: checked 0, flagged 0, notices 0.
## Still open
Vikunja #454 tracks both live checks. Claude Code runs in the first re-pin whose tree includes row 66,
backup first. Pi runs one full session with a tell after #383. See `docs/plans/DEFERRED.md`.
+10
View File
@@ -1410,3 +1410,13 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T09:20:46Z | Sage (Opus 5.5, 1ef1e4f8) | 212+197 combined landing gate (node 981/0, selftests 429/0, 08:51-09:03Z); 197 lane proof stopped after two failures (try 1 lane-image base inspect, N7; try 3 solo-a process.test.mjs:82 under umask 077), recovered cf48efccf083; mos-1162: row 212 landed alone as ee7b2f137 (paths match a2c72ac95, patch-id ea84d3e35470), #1713 closed 29996, revs 2759-2760; DEFERRED N7 plus brief docs/plans/2026-10-21_gate-env-rows.md at fefd54e13, rows 243 (chmod) and 244 (fixture PI_AUTH_FILE) revs 2761-2762; row 66 rebuilt as sage/row66-r4b 49ad61963 on fefd54e13 (manifest 99cab8275af4, not yet requested); slots: Herb 215 focused 26/0, Gosalyn 231 singles 60/0, Herb 197 r6 focused granted; kept root ga-8I3iZc removed (sage-1827..1893) | in progress
2026-10-21T10:12:45Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) built, slot asked (darkwing-0105) | claim rev 2795; darkwing/row245 0590a29fc pushed, on 62ddc96cc: 5273de0b7 cherry-pick of 1e3c4767c (case1-accept.sh, test, TOOLS.md blobs identical) and 0590a29fc notes to queue-245/BUILD.md (DESIGN.md:336-338 superseded by gosalyn-0087, unedited, sage-1877); slot scripts in /mnt/storage/scratch/darkwing-245-out pinned to 0590a29fc, nothing run; told Sage the untracked queue-195 DESIGN.md was r2 (fcbb4aa9b = 818b79696), nothing lost; flagged 245 and 246 both closing #1693
2026-10-21T11:28:49Z | Sage (Opus 5.5, 1ef1e4f8) | 235 landing gate (bus 596/0, business 200/0, cli 666/0, scripts green) and landing b1fece6f0, #1741 closed 30026; 197 r6 D5 proof stopped at solo-a script-release (Compose run has no --no-build, reproduced in lane env), #1695 30031; 221 landed before 197 r7 (sage-1954): gate 11:07-11:19Z (test 11/0, harness 140/0, cli 677/0, scripts green), 300766450, #1722 closed 30033, revs 2812-2813; C2-20b brief and DEFERRED N3 in 335bb3e30, row 247 #1749 briefed; 197 r7 9c3166915 focused run on my slot (red 9/2, green 11/0, mutant killed), request rev 2819 (30036), proof candidates A 2be6d07fa and B cceb258ff ready; slots Herb sage-1956, Darkwing 245 sage-1961 (sage-1894..1962) | in progress
2026-10-21T11:50:41Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) slot green, round 1 in review (rev 2842, request comment 30040) | grant sage-1961 on 0590a29fc: singles 23/0, mutants 16/16 killed (13-15 N1, 16 N3), gate cli 664/0 and every scripts/test-*.sh green (429/0), /proc scan empty; results commit ef8b4439e pushed on darkwing/row245; manifest ~/darkwing-scratch/r245/manifest-ef8b4439e*.sha256, 5 paths; slot handed back (darkwing-0107); Duckworth woken (darkwing-0108)
2026-10-21T13:43:38Z | Filbert (Opus 5.5, 048f3c63) | row 215 C2-04 candidate review r1 (herb-0083/0085, sage-2019) | APPROVE on 8d5695d7 tip 14a03d10f, static, no tests; #1716 c30071, queue rev 2897; N1-N4 non-blocking
2026-10-20T23:34:58Z | Dewey (Opus 5.5, 84fb346c) | #436b design r4 review (megavolt-0055, DESIGN.md 3e66f5acf) | CHANGES: revoke on an open rotation deletes one of two live tokens; N8 refuse a new grant while held; items 1 (sage-1578 b), 2, N7, duckworth N1 settled; verdict design-r4-verdict.md 19fdb59a; dewey-0761 Megavolt, dewey-0762 Sage
2026-10-20T23:35:58Z | Dewey (Opus 5.5, 84fb346c) | #436b design r5 review (megavolt-0057, DESIGN.md 1be535aca) | APPROVE; build carries N9 (settle row skips a revoked rotation, 404 delete is done) and N10 (serviceLeft list); verdict design-r5-verdict.md de52be1b; dewey-0763 Megavolt, dewey-0764 Sage
2026-10-20T23:41:36Z | Dewey (Opus 5.5, 84fb346c) | row 222 askpass vs 199 helper (megavolt-0061, design 7.2 8614ba87c) | accepted (a) http.followRedirects=false and (b) host/user-checked helper, plus exact prompt match, credential.helper reset or mirror config check, tests and mutants listed; dewey-0765 Megavolt, -0766 Gosalyn, -0767 Sage
2026-10-21T05:03:38Z | researcher | row 228 (#1729) round 2 in review (revs 2667, 2668) | round 1 4fa2ed1e3 (Webby 29677) drifted on packages/cli/README.md; rebased onto 9b5362018 as researcher/row228-r2 (3a2b547c3 + 5f2aad593 carrying the N1 trim), pushed; manifest ab40b92f (3 files); slot sage-1720: singles 94/0 (13+10+42+29), mutant M1 killed, restore sha OK; busy-check copy used for the 16-hour launchpad-test-dex container (Sage ok, sage-1729); request failed (no token), Sage posts as jarvis; packet agents/researcher/work/queue-228/r2
2026-10-21T06:47:54Z | researcher | row 228 (#1729) round 3 in review (rev 2702; start rev 2700) | rebase of r2 onto the 234 landing c771a36f4 as researcher/row228-r3 cf18c42b9 (one commit, pushed) with Webby's round 2 N1 (:711 and :718); manifest 82cf98c6 (3 files); slot sage-1796: session.test 13 + host.test 42 = 55/0, manifest OK before and after, busy228.sh copy named in the clear; r2 94/0 and M1 killed stand (seat blobs unchanged); request attempt left in requesting state (lock held by sage-238-review-2b when the outcome rev was written, exit 3; transport said failed pre-send, no token), Sage resolves and posts as jarvis; packet agents/researcher/work/queue-228/r3
2026-10-21T07:29:23Z | researcher | row 228 (#1729) landed as 86249ac55 (pushed 07:25Z by Sage) | patch-id b6ffe31feb0d matches my r3 candidate cf18c42b9 (checked here against origin/refactor); Sage combined gate with 238: seat 165/0, cli 640/0, scripts 585/0; queue note rev 2725, done rev 2727, #1729 closed comment 29966 (sage-1824); q228r2 worktree removed, branches researcher/row228, -r2 and -r3 stay on origin and locally; packets agents/researcher/work/queue-228 r1 r2 r3 uncommitted
2026-10-21T15:19:14Z | Sage (Opus 5.5, 1ef1e4f8) | MVP push 10-21 afternoon: rows 249, 197 proof 3/4, 222 singles, 66 r4d, queue-commit | 249 landed 9de5a57ab (+7b9737953 DEFERRED N1, #1751 closed, Vikunja 445 c442); 197 proof 3 rc=1 on a driver defect, Herb's fix a5b636750 to Webby, proof 4 set up in sage-r197p4; 222 singles 34/34 and 5 mutants killed (sage-2028); 66 round 4 in review (82c830779, c30083/30084); queue revs 2642-2904 committed 0636721e6
2026-10-21T15:20:58Z | Filbert (Opus 5.5, 048f3c63) | row 66 r4 review (sage-2037, sage/row66-r4d 82c830779, manifest 6cfd812da4ca) | APPROVE 30086, rev 2906; static, no tests; rebase only, row 66 +/- lines identical to r3 next to rows 212/213/232/245; N1 code-only patch-id d67275ac4251 (508bb684eade includes BUILD.md), N2 BUILD.md cap-sentence wording; packet-only notes
+8 -3
View File
@@ -418,6 +418,7 @@ One read-only tree supplies the bus, Console and human CLI (row 85,
`copy` requires a canonical checkout (.git directory); --allow-worktree
is a test override. COMMIT must be reachable from both local refactor and
origin/refactor. It archives the runtime/import closure, adapters, roles,
`tools/unslop-hook` (the unslop check both harness modules mount, row 66),
RELEASE, package.json and package-lock.json; refuses missing manifests;
runs npm ci inside the temporary copy; removes write permissions; and
renames beside the target. A published existing copy stays unchanged.
@@ -905,9 +906,13 @@ args file (`/etc/mosaic-stack/dev.args` on .53), which must pass
`stack-upgrade.sh`'s rules, say `--profile factory` and name `--business`.
Every command that runs the stack's code runs as
`sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from `/`.
Root only reads systemd, `ss`, `/proc`, the account database and file
metadata. The one write is F3's message and its reply, and it never opens
`claude-session.env`.
Root itself reads `ss`, `/proc`, the account database and file metadata. It
runs `systemctl show` and `is-enabled`, `curl` on the Console's loopback
`/healthz`, `sudo -l`, `sudo -u jarvis bwrap --version`, `hostcheck.sh
--check`, and node to parse `managed-settings.json` for F9. F9 runs node only
when `--node` is the `--node-pin` path, and the file and every directory above
it are root's with no group or other write. The one write is F3's message and
its reply, and it never opens `claude-session.env`.
It prints one line per check, `<id> <path> <mark> <evidence>`, then
`count <mark> N` for each mark and a result line. The functional checks
+125
View File
@@ -0,0 +1,125 @@
# Your books in Mosaic: a setup sheet
This sheet is for you, Melanie. You keep writing in Obsidian the way you do now. Mosaic reads your
books repo after obsidian-git pushes it to git.mosaicstack.dev. It changes nothing on your machine and
commits nothing to your repo.
## What the agents read
The agents read your books repo as you last pushed it. A push reaches them in about a minute.
They read notes, the `.md` files, inside your book folders. They don't read:
- notes at the top of the repo, outside any book folder
- `.canvas` and `.base` files, even inside a book folder
- images and other attachments
- anything in your other repos under `~/src`
They can't write to your repo at all. That covers canon, the playground and everything else.
Which folders are book folders, and which hold characters, places, research, your tracker or chapter
summaries, is set in one place. It is the `projects` entry of your business file in Mosaic. Jason
edits it by hand, and the agents can't change it. He will ask you to confirm it before the agents start.
## Your playground
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
names and tell Jason. Until you have one, every note in a book folder is canon.
What the playground means for the agents:
- They search canon unless they ask for the playground on purpose.
- An agent may quote a playground note in an idea or a question. It may not use one to back a fact,
a finding or a correction. Mosaic refuses that reply before you see it.
- Only the folder decides. A tag or a property can't make a note playground or canon.
### Colour the playground in your graph
This is a setting of your own vault. It changes nothing for the agents.
1. Open the graph view and open its settings panel.
2. Under Groups, choose New group.
3. In the query box, type the playground's path with `path:`. Your vault is `~/src`, so the path
starts with your books repo folder: `path:"<books repo folder>/<book>/<playground folder>"`.
4. Pick a colour.
Add one group per playground folder.
## Two things you might not expect
### Moving a note into a book folder makes it canon
The folder decides, so a move changes what the agents may do with a note. Once you move a note out of
the playground into a book folder and push, it is canon. The agents may then cite it as fact. A note
that isn't settled yet belongs in the playground. Any note in a book folder outside the playground is
canon, rough drafts included.
### A short link can point somewhere else for the agents
Obsidian looks up a short link like `[[Mara]]` across your whole vault, `~/src`. The agents see only
your books repo. Say a note called `Mara` exists in your books repo and also in another of your repos.
Obsidian and the agents can then open different notes for the same link, and the agents can't tell.
If the name exists only in the other repo, the link leads nowhere for the agents.
A link with the full path from the top of your vault reaches the same note in both:
```
[[<books repo folder>/<book>/<characters folder>/Mara]]
```
Obsidian can write new links this way. In Settings, open Files and links and set New link format to
Absolute path in vault.
## Lists of links in properties
The agents read the links in two properties, `appears_in` and `relationships`. Put quotes around each
link in the list:
```yaml
appears_in: ["[[A]]", "[[B]]"]
```
One link per line works too:
```yaml
appears_in:
- "[[A]]"
- "[[B]]"
```
Obsidian's property editor adds the quotes for you. If you type the list by hand, don't leave them out:
```yaml
appears_in: [[[A]], [[B]]]
```
The agents' reader can't read that form. It then ignores every property of that note and lists the
note as a problem in Mosaic's status. It still finds the note by its path, its name and its text. A
value written without `[[ ]]` counts as a plain name, not a link.
## What changes for Bean and Chet
They keep their names and personalities. How they answer changes.
In Hermes they answer in free text and may write story text when you ask. In Mosaic every reply is a
short list of items. Each item is an idea, a question, a finding or a fact. Each one quotes a line
from your repo or from a web page the agent read for you.
They don't write passages. If you ask for prose, you get a question or an idea instead.
A reply holds at most 10 items and at most 1,200 characters of item text in all. It has at most 5
ideas of up to 200 characters each. A question, finding or fact runs up to 300 characters. You or
Jason can ask for lower limits. When an agent can't back an answer with a quote, you see "No cited
answer" instead.
Each book world stays separate. One item can't mix quotes from two worlds.
You talk to them in the Console. Hermes keeps running until you have moved over, and stopping it is
your choice.
Five more helpers can join them: ideas, research, proofreader, fact-checker and continuity. You
choose which ones to start with, and you can add one later.
## What Jason will ask you
He will ask you to confirm your book folders and type folders, which books share a world, the name of
each playground folder, and which helpers to start with and when.
+122
View File
@@ -0,0 +1,122 @@
# Survey run sheet for the library host
This sheet is for whoever runs `mosaic library survey` on `<host>`, 10.1.1.54, under design 10 step
7. The survey reads one commit of a git repo and prints names and counts. It never prints note text,
property values, file names or link targets. Jason fills in the mapping for Melanie's books repo
from its output and her answers.
Out of scope: sending anything to Melanie. Design 10 step 7 makes that Jason's call.
## Before you start
- Jason's go for step 7. Step 4, the bot token in the `library-gitea` slot, comes first.
- You run as `mosaic` with `sudo -u mosaic -H` from `localadmin`. `mosaic` has no login shell.
- The stack copy from step 2 is in place at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`.
`<id>` is the full commit id in Jason's go. It must hold row 231, which escapes control and bidi
characters in folder names. In a canonical checkout, `git merge-base --is-ancestor 29ce1e360 <id>`
exits 0 when it does.
- `<node dir>`, the directory that holds `node`. It is the directory of the `--node` path given to
`case2-unit.sh` in step 2c. `scripts/mosaic` runs `node` from `PATH`.
- `<repo>`, an absolute path. See the next section.
## The repo to survey
The survey reads a local repo. It doesn't fetch. `<repo>` must:
- be the repo's top folder, or a bare repo's own folder. A folder inside a repo refuses, because the
survey stops git from looking above the path's parent.
- belong to `mosaic`. The survey gives git no global config, so a `safe.directory` entry can't
admit a repo another user owns.
- have a `HEAD` that names a commit. The survey reads `HEAD` and has no flag for another ref.
`<repo>` is the bus's bare mirror at `<dataRoot>/projects/<business>/<project>`, which C2-04 (row 215)
creates and C2-03a (row 222) fetches into. It is the only copy of her repo on the host (design 2.1), and
`mosaic` owns it. Survey it after the first `library.sync` succeeds. The mirror's `HEAD` points at
`refs/heads/served` (C2-03a brief), so the survey reads the commit the agents are served.
Don't survey anything else. A hand clone with the bot token would put the token where design 7.2 keeps
it out, and the mirror's refs belong to the bus. If `HEAD` names no commit, stop and report to Sage.
## Steps
1. `ssh [email protected]`.
2. Check which `node` runs. `PATH` ends in `/usr/bin:/bin`, so a wrong `<node dir>` would quietly run
another `node`:
```sh
cd /
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" sh -c 'command -v node; node --version'
```
The first line must be `<node dir>/node`. If it isn't, fix `<node dir>` before going on.
3. Run the survey without a mapping:
```sh
cd /
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo>
echo "exit $?"
```
4. On exit 0, give the output to Jason. On any other exit, see "Refusals" below.
5. When Jason has a draft mapping, run it again with `--mapping`. The file holds the library entry
alone, `{"kind": "library", "remote": ..., "branch": ..., ...}`, without the business file or the
`"projects"` wrapper around it. `remote` and `branch` are required even though the survey doesn't
fetch. `mosaic` must be able to read the file. Set `vaultPrefix` to her books repo's folder name
in her vault. Without it, a link with the full path from the top of her vault resolves to nothing,
and the setup sheet tells her to write links that way.
```sh
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo> --mapping <file>
echo "exit $?"
```
The usage line is `mosaic library survey <repo> [--mapping <file>]`. `--mapping <file>` is the only
flag, and it may appear once. `--mapping=<file>` and every other flag refuse.
## What the output holds
Plain text on stdout, one line per item:
| Line | What it holds |
|---|---|
| `commit <id>` | the 40-hex commit `HEAD` names |
| `entries <n>` | every entry in that commit's tree |
| `folders (direct children: md canvas base other)` | then one line per folder that holds a file directly: its path as a JSON string and four counts. `(root)` is the repo's top |
| `markdown <n> files, <n> read, ...` | `.md` files, how many were read, how many have no front matter, how many have front matter the reader can't fully parse |
| `front matter key sets` | then `<count> [<keys>]` per set of property names. A name that isn't a short identifier prints as `(other key)` |
| `body links plain .., label .., heading .., embed .., self .., markdown ..; attachment ..` | link forms in note bodies, and embeds of a target with an extension other than `.md` |
| `field appears_in wikilink .., name ..` | values written as links and as plain names. A `relationships` line follows |
With `--mapping` three lines follow:
| Line | What it holds |
|---|---|
| `scopes canon .., playground .., unmapped ..; indexed ..` | tree entries per scope, and notes indexed |
| `resolved root .., vault-prefix .., relative .., shortest .., unresolved ..` | links per resolver step. `unresolved` is the R4 count |
| `problems R1 .., ..., R7 ..` | problems per rule |
Folder paths and short property names are the only words from her repo in the output. In a folder
name, tab, newline, carriage return, backspace and form feed print as `\t`, `\n`, `\r`, `\b` and `\f`.
Any other control or bidi character prints as `\uXXXX`.
## Refusals
A refusal prints nothing on stdout and one line on stderr. The survey checks the mapping before the
repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
| Exit | stderr | Cause | What to do |
|---|---|---|---|
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
| 2 | `library survey: library-commit: <reason>` | `HEAD` names something the reader can't take as a commit, for example in a SHA-256 repository | Report to Sage |
| 127 | a line ending `exec: node: not found` | `node` isn't in `<node dir>` | Fix `<node dir>` |
| other | a Node stack trace | a fault in the survey | Stop and report the exit code to Sage |
A refusal is the answer, not an obstacle. Don't run as another user, change modes on her repo or edit
its refs to get past one.
+6
View File
@@ -106,11 +106,17 @@ apt-get download apparmor-profiles=4.0.1really4.0.1-0ubuntu0.24.04.9
dpkg-deb --fsys-tarfile apparmor-profiles_*.deb | tar -x ./usr/share/apparmor/extra-profiles/bwrap-userns-restrict
echo "11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 usr/share/apparmor/extra-profiles/bwrap-userns-restrict" | sha256sum -c -
install -o root -g root -m 0644 usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict
echo "11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 /etc/apparmor.d/bwrap-userns-restrict" | sha256sum -c -
apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict
dpkg-statoverride --update --add root mosaic 0750 /usr/bin/bwrap
```
Run as root in a `mktemp -d` dir, `apt-get download` prints a line starting `W: Download is performed
unsandboxed as root`, because the `_apt` user can't write that dir. The line is expected and the
download still succeeds. Each `sha256sum -c -` line must print `OK`. The second one checks the copy in
`/etc/apparmor.d`, the file that loads. Stop on any other result.
`aa-status` then lists `bwrap` and `unpriv_bwrap` in enforce mode. Install the one profile file, never the
whole `apparmor-profiles` package, whose 20 other profiles would load at the next boot. Rollback, in
reverse order: `dpkg-statoverride --remove /usr/bin/bwrap`, then `chown` and `chmod` to the values in
+50
View File
@@ -121,3 +121,53 @@ tells Mos before row b lands.
Gate: as row a, with section 10's row b tests and mutants, G27, G28 and the upsert mutant included, and
the creative refusal tests and mutants (writer and spend).
## Machine grant row b follow-up: the held stubs end with their test
The row 197 r8 D5 proof stopped on a leftover process on 2026-10-21. In
`packages/bus/tests/grant-executor.test.mjs`, the `add` stub waits for `release` when `hold-add`
is set. G23 ("a revoke during install") and G13 write `release`, finish, and `t.after` removes the
test dir. The stub polls every 50 ms, so it usually misses `release` and then loops forever on a
dir that is gone. Four runs of the file on Sage's slot left 7 stubs, every run 37/0.
Build: each hold loop in that file, `hold-add` and `hold-remove`, also stops when `$D` no longer
exists. The test file is the only change. Nothing under `src` changes.
Gate: Webby approves the commit. On Sage's gate the whole file passes three times in a row with no
stub left, checked by pgrep on the run's TMPDIR, and the bus suite is green. A mutant that drops the
dir check from `hold-add` leaves a stub behind.
## Machine grant row b follow-up: the undo can't prove the remote add has ended
Mos asked this in mos-1166. Gizmoduck answered in gizmoduck-0137 and gizmoduck-0139, and Duckworth
checked the answer in duckworth-0091. Line numbers are at 6d5545d44.
An abort during install SIGKILLs the local ssh process group (host-run.mjs:136). A timeout does the
same (host-run.mjs:131-134). sshd gives a non-pty command no SIGHUP when the client dies, so the
remote `add` can keep running. The undo runs `remove <user> <tag>` once (broker.mjs:1017). If the add
writes its line after that, the remove has already exited 0 and `hostLineLeft` stays unset (:1019).
Then hosts.status shows the host clean (:1396), reconcile skips a grant that already ended
(:1042), and nothing prompts a sweep. hosts.remove's host-line-left check (:1362) passes, and once
the record is gone a sweep refuses host-unknown (:1350), so no verb reaches the line. The line
authorizes a key nobody holds, under the installer's restrict prefix. No test covers this case. G24
seeds its failed grant with `hostLineLeft` already set.
Build:
- In `#runHostGrant`, mark install done right after `await install(...)` returns. In the catch, when
install started and never returned, record `hostLineLeft: tag` whatever the remove's exit was. The
rule keys on install not returning. It covers an abort, a timeout and a dropped connection, and
doesn't depend on `signal.aborted`. The message names `mosaic hosts sweep <host>`.
- An add that exits nonzero on the host also records the hint. That hint is false but harmless,
since a sweep that doesn't list the tag drops it (G24, "a left tag no longer on the host").
- Fix the comment at grant-executor.test.mjs:432, which says a killed session's add exits.
- Row a's enroll add: check it for the same race, then fix it or record in BUILD.md why it can't
happen.
- Owner Gizmoduck. Reviewer Duckworth.
Tests: one abort case and one timeout case. Each uses a held stub add that writes after the remove
and exits 0. Each asserts `hostLineLeft` on grant.failed and in hosts.status, that hosts.remove
refuses host-line-left before the sweep, that the sweep removes the line, and that hosts.remove then
succeeds. A mutant that drops the new catch branch fails a test.
Gate: Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files
are green, and the mutant is killed.
@@ -0,0 +1,57 @@
# Brief: Console inbox Approve and Deny for grant requests (Vikunja #435, J2)
Sage, 2026-10-21, for Mos's order mos-1193 (Jason asked at 18:35Z to test J2 sooner). Vikunja #435, parent
#424, DECISIONS Q188. Owner Gander, reviewer Webby, gate owner Sage. Chained on row 196's candidate (Q177):
the design starts now on Gander's row 196 build. The code lands after row 196 lands and rebases onto that
landing. Design first. The first deliverable is `agents/gander/work/queue-ROW/DESIGN.md`.
## What Jason asked for
Q188, as #435 records it: the chief surfaces each grant request to Jason's inbox in the WebUI, and he clicks
Approve or Deny. It needs the Console to know who is signed in (Q175 SSO, #412). Until then Approve is
`mosaic decide`.
## Where things stand
- The Console inbox shows decisions and prints `mosaic decide <id> <key>` for each choice
(`packages/webui/README.md`, Inbox section). It takes no human writes: without `--send` every method
other than GET and HEAD answers 403 `read-only`, and the live unit runs without it (row 189).
- Q175 phase 1 (row 192, landed 4429a104f) builds the Console's own OIDC session. It is live only after the
phase 2 re-pin window turns on `--sso`. Under `--sso` every request other than `GET /healthz` needs a
verified session.
- Row 196 (Gander, Duckworth) settles how the bus tells the human's own call from a same-uid process. A
Console write that resolves a decision must go through whatever row 196 lands, not around it.
- The PRD doesn't allow this yet. REQ-DEC-3 says a human resolution comes only from a `mosaic` CLI session
started outside any agent run. Jason approved REQ-WEB-2 and the REQ-DEC-3 amendment in decision
00ed58e2 (Vikunja 19/266), and the PRD holds the id REQ-WEB-2 for it (1.8, "held for the Console decide
buttons amendment"). That amendment must land before this row's code does. PRD wording goes to Mos first,
then Jason. This row doesn't edit the PRD.
## What the design must settle
1. Which decisions get the buttons. #435 names grant requests (Q188). Say whether the first cut covers only
those or every open decision under REQ-WEB-2, and why.
2. The identity. The resolving human is the verified OIDC subject from the Console session (row 192), mapped to
an authorized human the bus knows. Nothing comes from a proxy header or a shared secret (Q175). A request
from an agent session or a seat credential is refused.
3. The write path. How the Console's POST reaches the bus's decide, using row 196's human boundary, and what
the decision event records: `via=console` and the subject.
4. The guards: CSRF, a confirmation step, a decision that is no longer open, a stale option key, a double
click, and the read-only and `--no-send` modes. State each refusal's status code and message.
5. Without `--sso` the buttons don't exist or are disabled. Say which, and what the inbox shows instead
(today's `mosaic decide` line).
6. Tests on stand-ins only: a fake OIDC provider and the test bus, as row 192's tests do.
## Boundaries
- No change to who can reach the Console from outside. That stays Jason's.
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. No request to the
live 127.0.0.1:7332 or 7333, mosaic.woltje.com or auth.diversecanvas.com.
- Turning the buttons on live needs the phase 2 window (`--sso`) and then a re-pin whose tree has this row.
Both are windows with Jason. This row ships the code and the tests.
## Gate
Webby approves DESIGN.md, then the manifest. The webui, bus and cli suites and every `scripts/test-*.sh` are
green on Sage's gate, and the mutants named in DESIGN.md are killed. The PRD amendment for REQ-WEB-2 has
landed first.
@@ -0,0 +1,48 @@
# Brief: K12 reads cgroup.events after cgroup.kill under load
Sage, 2026-10-21, for Mos's ruling mos-1182 item 3. Vikunja #450. Gate owner Sage. Owner and reviewer are
named when the row is briefed. Base: refactor at the brief commit. The row comes after row 197, unless
proof 5 fails on K12 again (mos-1182 item 4). Then it goes ahead of 197, and 197's candidates rebase on it.
## Why
`packages/conversation/tests/cohort.test.mjs` K12 (line 268) force-stops a member that forks in a loop.
Then it asks the shim for `events` and asserts `ev.populated === 0` (line 292). It never checks `ev.ok`.
`events()` in `packages/conversation/src/shim.mjs:83-96` returns `{ ok: false, unavailable }` when
`engine/cgroup.events` can't be read or has no `populated` field. Under load the test then fails on
`undefined !== 0` and never says why.
This has happened twice:
- BUILD-LOG.md:4092: conversation went 181/1 at a load of about 12 while mutants ran, and 182/0 alone.
- Row 197 proof 4, wave pair1-b, 2026-10-21T16:04Z: 147/1 with two conversation suites running at once.
The log is `/mnt/storage/scratch/sage-r197p4/proof-run/pair1-b/out/pkg-conversation.log`.
## Steps, in order
1. Make the failure name its cause. The K12 assertion prints `ev` (with `ok` and `unavailable`) when it
fails. Commit this first, on its own.
2. Find why the file is unreadable or lacks `populated` after `cgroup.kill` under load, and fix that at the
source. Use the shim, the cohort controller or the test's setup and teardown, whichever the evidence points
to. Record the evidence in `agents/OWNER/work/queue-ROW/BUILD.md`.
3. Do not loosen the assertion. Do not treat `unavailable` as empty, or `populated` as 0 when it can't be
read. Retrying a read that is still in progress is allowed only if step 2 shows a race in the read
itself, and the retry has a bound and a test.
## Files owned
`packages/conversation/src/shim.mjs`, `packages/conversation/src/cohort.mjs` if step 2 needs it, and
`packages/conversation/tests/cohort.test.mjs`. Nothing in row 197's 37 paths.
## Tests
- K12 still asserts `populated` 0 and an empty member list.
- A new case shows `events` returning `unavailable` with its reason, and that the controller treats that as
unavailable, not as empty.
- The conversation suite passes whole three times in a row, and once while a second conversation suite
runs at the same time. That is the load that broke it in proof 4.
## Gate
The reviewer approves on the row, naming the candidate manifest. The conversation suite and the
control-board suite pass whole on a gate rerun in a clean worktree, on Sage's slot.
@@ -0,0 +1,55 @@
# Brief: T27 lists no identities right after ssh-add -t 1
Sage, 2026-10-21, for Mos's ruling mos-1185 item 2. Vikunja #453. Gate owner Sage. Owner and reviewer are
named when the row is briefed. Base: refactor at the brief commit. The row comes after row 197, unless
proof 6 fails on a test outside 197's paths in a pair wave (mos-1185 item 4). Then this row and row 250 go
ahead of 197, and 197's candidates rebase on them. The pattern is row 250's
(`docs/plans/2026-10-21_k12-cohort-events.md`).
## Why
`packages/bus/tests/host-run.test.mjs:453`, "T27: ssh-add -t drops the key from an agent the test keeps
alive", starts its own `ssh-agent -D -a SOCK`, calls `addKey(sock, key, 1)` from
`packages/bus/src/host-run.mjs:87`, and asserts that `ssh-add -l` lists the key (line 469). Then it sleeps
2.5 s and asserts the key is gone.
Row 197 proof 5, wave pair2-b, 2026-10-21T17:07Z: the first assertion got "The agent has no identities."
31.9 ms into the test. The bus suite went 289/1. The log is
`/mnt/storage/scratch/sage-r197p5/proof-run/pair2-b/out/pkg-bus.log`. The same test passed in solo-b and
pair1-b of that run.
The cause is not known yet. One lead, unverified (Mos, mos-1185): ssh-agent may count lifetimes in whole
seconds of CLOCK_MONOTONIC, so a key added just before a monotonic second boundary expires within
milliseconds. A test aligned to wall-clock seconds did not test that idea.
Sage checked the lead on dragon-lin, OpenSSH 10.5p1, 2026-10-21T17:19Z (`/mnt/storage/scratch/sage-r64/t27-mono.mjs`,
outside any gate). It made 80 adds of `ssh-add -t 1`, each at a chosen fraction of a CLOCK_MONOTONIC second
(`process.hrtime`), and listed at once. 8 of 80 missed. Every miss was an add made at 0.9947-0.9973 of a
monotonic second that finished at 0.998-0.0008. No add earlier in the second missed. So a 1-second lifetime
can end within milliseconds of the add. Step 2 still confirms this against the T27 failure itself.
## Steps, in order
1. Make the failure name its cause. When the first assertion fails, print the time from addKey's start
to its resolve and to the `ssh-add -l` call, the monotonic clock (`process.hrtime.bigint()` or
`/proc/uptime`) at each, and the agent's state (alive, socket present). Commit this first, on its own.
2. Find the cause and fix it at the source: the test's lifetime choice, `addKey`, or how the agent is
started. Record the evidence in `agents/OWNER/work/queue-ROW/BUILD.md`.
3. Do not loosen the check. The test still proves the key is listed after the add and gone after its
lifetime. A longer lifetime is a fix only if step 2 shows whole-second rounding is the cause, and then
the sleep grows with it.
## Files owned
`packages/bus/tests/host-run.test.mjs`, and `packages/bus/src/host-run.mjs` only if step 2 points there.
Nothing in row 197's 37 paths.
## Tests
- T27 still asserts the key is listed, then gone.
- The bus suite passes whole three times in a row, and once while a second bus suite runs at the same time.
## Gate
The reviewer approves on the row, naming the candidate manifest. The bus suite passes whole on a gate
rerun in a clean worktree, on Sage's slot.
+22
View File
@@ -322,6 +322,28 @@ at every gate. Started 2026-09-12 during the control board MVP.
base. This matters only if someone reruns that set. Fix the anchor
in that tooling before any rerun, and check that a stale anchor
refuses instead of skipping the mutant (sage-1941). (2026-10-21)
- **Grant executor test X5 starts a `sleep 30` that can outlive its test.**
(Webby N1 on row 248 r1, webby-0080; deferred by Duckworth in the row
249 review, REVIEW-r1 28174fe08, #1751 comment 30070.) The test at
`packages/bus/tests/grant-executor.test.mjs:989` (:934 before row
249) runs `sleep 30` on the sshd side, where an abort doesn't reach it. Fix it with a stub
that exits once its test dir is gone, as row 248 did for the hold
stubs, or with a shorter sleep. This binds the next row that touches
that test file. Until then, a lane cleanup that runs after this file
waits 30 s before it calls a lone `sleep 30` a leak. The row 197
lanes scan only at lane end, about 15 minutes after pkg-bus, so they
can't see it (mos-1169). (2026-10-21)
- **Row 66 live checks: the unslop notice on a live managed session, both harnesses.**
(mos-1186, Vikunja #454.) Row 66's gate asked for one landing session per
harness with a prompt that draws a known tell. Mos changed that gate. The
Claude Code session ran before landing on a scratch root from the
candidate tree (run r4ab24630d323, landed 2c38325ec). Pi ran launch only there (run r1dd94171bede):
this host has no credential pi reads until P19 #383 settles. Two live
checks remain. Claude Code: in the first re-pin whose tree includes row 66,
backup first (Q173). Pi: one full session with a tell after #383. Each
attaches the operator note on the RESULT, the next-turn correction and the
`unslop.jsonl` counts. (2026-10-21)
## Queue
+25 -18
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2904 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -102,7 +102,7 @@ Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not e
| 63 | Multi-project verbs: task verbs across the tree | darkwing; reviewers dewey, filbert | #1556 | done | 62 done | dewey and filbert approve on the issue naming the candidate manifest; tasks, harness and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_multi-project-sync.md` § Multi-project verbs: task verbs across the tree (row B) | lands before row 90 (decision 125): both share packages/tasks/README.md and tests/verbs.test.mjs, so row 90 rebases after this lands |
| 64 | Managed sessions get their role's skills on both harnesses | sage; reviewers darkwing, filbert | #1557 | done | 65 done | darkwing and filbert approve on #1557 naming the candidate manifest; business, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the role's skills (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions get their role's skills on both harnesses | Landed (mos-0863/0864): 9f0039711 = c6d7d538f (range-diff =, patch-id 7e1948e2f2e9), F1 doc 6b6567b8b, packet ebfc23c43; gate-land GATE_PASSED 2384/0 on 6b6567b8b. D2 on #1557 comment 29228: claude-code coder rc13bcd555652 replied with its 4 skills, pi coder r3455505dd5da launch only (no pi credential on host, to Mos); both manifests list the 4 coder skills, sha256 = landed files. D3 window step 46de60893, D4 #1672 row 174. |
| 65 | Model routing table: sessions resolve harness, model and thinking from one table | sage; reviewers darkwing, dewey | #1558 | done | 49 done, 131 done | Mos clears the routing table's public/ diff (decision 123, mos-0840) before in-review; darkwing and dewey approve on #1558; plugins, bus, business, harness, seat, cli, webui, test-*.sh green on Sage's rerun; landing session shows module, route, counted model, thinking; report names the mos-0840 view (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Model routing table: sessions resolve harness, model and thinking from one table | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 3, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 4, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Drift: row 245 (6d5545d44) changed docs/TOOLS.md, which is in this manifest. r4c must take 245's TOOLS.md hunks before its gate and new manifest; the approved TOOLS.md blob is now stale (sage-1992 sweep). |
| 67 | Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | filbert; reviewers darkwing, dewey | #1560 | done | — | darkwing and dewey approve on #1560 naming the candidate manifest; bus, webui and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_decisions-read-follow-up.md` § Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | — |
| 68 | Bus host runs an explicit Claude Code binary | sage; reviewers dewey, filbert | #1561 | done | — | Filbert and Dewey approve; cli, seat, harness suites and every scripts/test-*.sh green on Sage's gate rerun; unit reinstalled and opus probe through MOSAIC_CLAUDE_BIN (sage) | `docs/plans/2026-10-11_bus-claude-binary.md` § Bus host runs an explicit Claude Code binary | — |
| 69 | Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | darkwing; reviewers dewey, filbert | #1562 | done | 55 done | dewey and filbert approve on the issue naming the candidate manifest; cli and queue node suites plus every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_terminal-app-follow-up.md` § Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | Sage accepts the two shortened texts in round 1 (hidden box way out, status line), both forced by the 40x10 frame. Follow-up at landing: when help is cut to fit, draw() points at mosaic inbox --json though help has no whole text; open it as its own row (build.md Found, not fixed) |
@@ -231,9 +231,9 @@ Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not e
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | sage-1610 (honker-0049): a human add takes tracker {bot, botId}, required for a vikunja definition, refused otherwise, checked by checkBot and bot uniqueness; CLI and API only. Delete accepts a tracker bot when all credentials are store refs, and its result names the bot for removal by hand in Vikunja. No Vikunja account is created (Q188 untouched). Mutants: drop the requirement, refuse the bot on delete, skip checkBot on add. |
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done, 233 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | done | 190 done, 226 done, 233 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | LANDED b65014647 (pushed 361ba2387 tip). Patch-id 656e43ff25de = 4ced5e63d..37171f65b; verify-commit ok, 5 paths. Combined gate with 213 on 38b83ea5c+paths 09:45:52-09:57:52Z: business 200/0 bus 542/0 harness 140/0 cli 663/0, all 10 scripts green, L1 killed. N1-N3 to DEFERRED next; follow-up darkwing/row195-n1 1e3c4767c. |
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 1, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 6, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | r7 APPROVED (webby-0074 rev 2822). r7 proof sage-1973 11:50-11:51Z STOPPED at solo-a pkg-cli (#1695 c30044): gate-lane-image.test.mjs:93 and :103 call docker via PATH, which in a lane is the guard shim; composeFixture env drops MOSAIC_GATE_DOCKER_RECORD, shim exits 2. business 200/0, bus 596/0, cli 197/2. r8 Herb: test-only fix + run under a lane-shaped PATH. N10 still open. |
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | done | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | Landed cb5fdb064 on refactor (base 76134dfde), pushed. Patch-id 828fa21a matches git diff 00d4cc1f6..86963d281 on the 37 manifest paths; docs/TOOLS.md 3-way with row 200, hunk unchanged. Manifest 35f900fd, Duckworth approve r1 29779. Gate on 9e1085816+patch: packages 2735/0, scripts 429/0; landed blobs equal the gate tree. |
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
@@ -248,37 +248,44 @@ Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not e
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | done | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Landed f5753c0e8 on refactor (base 9d165be8c), pushed. Patch-id 723b1c83 matches git diff ad95c888c..0485384e9 on the 23 manifest paths; landed files pass r5 manifest 688d38b9. Duckworth approve r2 rev 2592, r3 29851. Mutants 52/53, M22b equivalent (rev 2584). Gate on ad95c888c+patch: packages 2796/0, scripts 429/0; landed blobs equal the gate tree. |
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | in-review, round 1, request posted | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | — |
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | in-review, round 2, request posted | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | — |
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | done | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | Landed ee7b2f137 (pushed 09:13Z) on f6c70f944, alone per mos-1162: the 21 manifest paths from a2c72ac95. Patch-id ea84d3e35470 equals the candidate's; blobs and modes match; verify-commit ok (21 paths). No 212 path changed after c771a36f4. Gate: Sage's landing gate 08:51-09:03Z on 4ced5e63d plus 212's and 197's paths, GATE_PASSED, node 981/0, selftests 429/0. Gosalyn r3 slot 451/0, test-queue 28/0. Row 197 waits for r6 (lane proof stopped, sage-1881). |
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | briefed | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | — |
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | in-review, round 1, request posted | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | Sage 15:01Z drift sweep after 249 landed 9de5a57ab (+7b9737953): manifest 8d5695d7 shares packages/bus/src/broker.mjs with 249, merge-tree was clean. Order stays 197 then 215 (sage-2029, sage-2030): after 197 lands Herb rebases 215 on that tip for a merge-scoped round with Filbert (broker.mjs from 249, stack-release.test.mjs from 197), then Sage's full landing gate. Rows 13, 66, 197, 202 have no overlap. |
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | — | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | — |
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | — | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | briefed | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | — |
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | done | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | LANDED f8276c5eb (pushed, on Darkwing sessions b65f3a0e8). Webby APPROVE r1-r3 (revs 2848, 2856, 2860; r3 candidate 1a38baf5af2a = 89ba79d94). Patch-id 18ba1e40ad29 seat 5c6f73ed7..89ba79d94 equals landed b65f3a0e8..f8276c5eb; three blobs equal 89ba79d94. Gate: doc only, 0 test readers (control 3). Melanie handover waits on C2-06 and Jason (#419). |
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | briefed | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | — |
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | in-progress | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | — |
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | done | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | LANDED 300766450, pushed. Cand 8f403d0d4, manifest 20f05e3a6ad5, 22 paths, verify-commit ok, patch-id dfbb754c6fb4 equals 62ddc96cc..8f403d0d4. Gate 11:07:50-11:19Z on b1fece6f0: test file alone 11/0, harness 140/0, cli 677/0, all scripts green. Brief Gate met: webby-0073 approve, test file alone, cli, scripts. No live run in the Gate; the import itself waits on Jason. N1 N2 N4 go to C2-20b, N3 to DEFERRED. |
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | in-progress | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | sage-2024 on megavolt-0089: core.createObject=rename in the fetch argv is accepted as a brief departure, since C2-04 inspectMirror refuses nlink 2. Dewey checks it. The askpass env keys, export SAFE in project/src/git.mjs, bus/tests/library-sync.test.mjs and the SURVEY-RUN user line are accepted. The 5 s client timeout residual goes in BUILD.md and Dewey rules fix or defer. Slot after 197 proof 3 and the 249 gate. |
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | rocko; reviewers dewey | #1725 | briefed | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
| 225 | Case 2 C2-10b Console chat: cited items and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: cited items and the status line | — |
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | 215 done, 221 done, 222 done, 230 done | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | in-review, round 1, request posted | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | — |
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | done | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | Landed 86249ac55 (pushed 07:25Z) on 59f817c32: the 3 manifest paths from cf18c42b9. Patch-id b6ffe31feb0d equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. |
| 229 | Row B: per-instance pi agent dir for Chet's Codex login, design only, starts after 10-27 (#428) | gosalyn; reviewers webby | #1730 | queued | — | A live check, with Jason's or Mos's go, that two clients on one ChatGPT login don't revoke each other's refresh tokens. No build row starts before it passes. Held until after 10-27. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428) | — |
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | done | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | Landed c8bc5cf4c on refactor (base 390ea5261), pushed. Patch-id f4b49eb68997 matches git diff 722136772..96768766c on the 5 manifest paths (manifest fbb0d7e42c67, Webby approve r2 rev 2563). packages/cli/README.md 3-way with row 199, hunks apart. Gosalyn gate on 96768766c: cli 541/0, scripts 429/0, mutants 10/10. Merged tree check: case2-unit 30/0, host 42/0. |
| 231 | Case 2 C2-01b survey escapes, list item lines and attachment embeds (row 212 N1-N3) | gosalyn; reviewers webby | #1733 | briefed | 212 done | Webby approves the manifest. The library tests are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01b survey escapes, list item lines and attachment embeds | — |
| 231 | Case 2 C2-01b survey escapes, list item lines and attachment embeds (row 212 N1-N3) | gosalyn; reviewers webby | #1733 | done | 212 done | Webby approves the manifest. The library tests are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01b survey escapes, list item lines and attachment embeds | LANDED 29ce1e360 (sage gate 10:05:31-10:07:52Z GREEN on 62ddc96cc: library 45/0, every scripts/test-*.sh). verify-commit ok, 9 paths; patch-id 59b98b1b3e00 equals fefd54e13..f37a65857. Pushed. Webby N4 stays as restored (sage-1917), doc-only. |
| 232 | Installer I1: stack-release external units and stack-copy bundle copies (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1734 | done | — | Filbert approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I1 stack-release external units and bundle copies | Landed e65a3ab4d on refactor (base 0444acd08), pushed. Patch-id 5adfbab9 matches git diff 8fede5fc6..da2da2f6c on the 9 manifest paths; tree entries (modes too) equal da2da2f6c, files pass manifest 7af3c943. Filbert approve r1 29829, r2 29865. Darkwing's landing gate on da2da2f6c: cli 593/0, scripts 429/0 (+156 queue node tests). Mutants 17/17. |
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | in-progress | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | — |
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | in-progress | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | — |
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | queued | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | — |
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | done | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | queued | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | in-review, round 1, request posted | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | — |
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
| 240 | Installer I4: hostcheck, the bwrap profile check, pins and boot unit, from r6 B2 (MVP, Q187, Vikunja #432; split from I3) | darkwing; reviewers filbert | #1746 | queued | 233 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) (sage) | `docs/plans/2026-10-20_installer-rows.md` § I4 hostcheck: the bwrap profile check, pins and boot unit | — |
| 240 | Installer I4: hostcheck, the bwrap profile check, pins and boot unit, from r6 B2 (MVP, Q187, Vikunja #432; split from I3) | darkwing; reviewers filbert | #1746 | done | 233 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) (sage) | `docs/plans/2026-10-20_installer-rows.md` § I4 hostcheck: the bwrap profile check, pins and boot unit | Landed c515273db (pushed 06:57Z) on da3051ccc: the 9 manifest paths from ad1b23ab2. Patch-id 48e376efe8a4 equals the candidate's; modes and blobs match; verify-commit ok (9 paths). Sage's gate on 060a17b54 plus the 9 paths, 06:45:05Z to 06:56:21Z: cli 639/0, all ten scripts/test-*.sh 585/0. Darkwing's r2: singles 37/0, M1-M8 8/8. Filbert N1 and N2 in DEFERRED (da3051ccc); N3 by design. |
| 241 | Row 237 follow-up: one decision lands once, a two-ticket broker land test and three mutants on the landing guards (megavolt-0069, sage-1651, P19 441) | megavolt; reviewers duckworth | #1747 | done | 237 done | Duckworth approves the manifest. The bus, seat and cli suites and every scripts/test-*.sh are green on Sage's gate, and the three mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up, one decision lands once, with tests at the broker | Landed 5d4a61776 on refactor, pushed (base e65a3ab4d). Blobs from 3e3ed303e on 2 paths; patch-id a1649f80 equals git diff f5753c0e8..3e3ed303e on those paths; ls-tree modes equal; manifest e8266864 verifies on the landed files. Sage slot on bbf3b1ee8: M1-M3 killed; gate 15 packages 2797/0, 10 script suites 429/0. Duckworth approve 29870 rev 2628. |
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | done | 237 done | Duckworth approves the manifest. The bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up N1, a retry after publish-timeout pushes once | Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639. |
| 243 | process.test.mjs startup token refusal sets its file mode (chmod), umask sweep of packages/*/tests | morgana; reviewers webby | — | briefed | 197 done | Webby approves the manifest. The test file passes whole under umask 022 and under umask 077 on Sage's slot. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § The startup token refusal test sets its file mode | mos-1162: lands after 197 r6, whose proof runs on the unchanged test. |
| 244 | Gate runs use a fixture PI_AUTH_FILE (DEFERRED 059577886, Webby N4) | morgana; reviewers webby | — | briefed | 197 done | Webby approves the manifest. The new test and every touched file are green on Sage's slot, along with test-task.sh and test-release.sh. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § Gate runs use a fixture pi auth file | — |
| 245 | row 195 follow-up: Duckworth's N1-N3 on scripts/case1-accept.sh (root never runs an unchecked --node; the header lists what root runs; F6 refuses .. after the claude pin dir) | darkwing; reviewers duckworth | #1693; closes none | done | — | Duckworth approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, and a mutant for each of N1 and N3 is killed. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 follow-up: Duckworth's N1-N3 on the Case 1 acceptance script | LANDED 6d5545d44 on 856c1826b, pushed. Patch-id 36a621ac3966 equals 62ddc96cc..ef8b4439e. Gate: Duckworth r1 APPROVE rev 2849; N1 mutants 13-15 and N3 mutant 16 killed (sage-1961, darkwing-0107); Sage gate 12:24-12:35Z: case1-accept alone 23/0, cli 681/0, scripts/test-*.sh 429/0. Lands before row 246 as the brief asks. #1693 stays open for 246. |
| 246 | row 195 window: the Case 1 acceptance run on 10.1.1.53 (carries row 195's done condition; needs a Jason or Mos go) | darkwing; reviewers duckworth | #1693 | queued | 226 done, 245 done | The window's acceptance run passes on .53, Duckworth reads its output, and Mos confirms. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 window: the Case 1 acceptance run on 10.1.1.53 | — |
| 247 | Case 2 C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | gosalyn; reviewers webby | #1749 | done | 221 done | Webby approves the manifest. The test file runs green on its own, and the three mutants are killed on Sage's slot. The cli suite and every scripts/test-*.sh are green on Sage's gate. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | LANDED 856c1826b on f8276c5eb, pushed. Patch-id 373ab2cc4c50 equals 6d43618a3's. Gate: Webby r1 APPROVE rev 2852; test file alone 14/0 and M1-M4 killed (sage-1971); Sage gate 12:10-12:21Z: file 14/0, cli 680/0, scripts/test-*.sh 429/0. M1/M2 at :248 not :237 (gosalyn-0111). |
| 248 | row 235 follow-up: the grant-executor hold-add and hold-remove stubs stop when their test dir is gone (found by the 197 r8 proof) | sage; reviewers webby | #1750 | done | — | Webby approves the commit. On Sage's gate the whole grant-executor.test.mjs passes three times with no stub left, the bus suite is green, and a mutant that drops the dir check leaves a stub. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the held stubs end with their test | LANDED 8a04f4aa4 on refactor, pushed. Temp-index landing of candidate 4d99c178b's one path onto bc579f7a8. No drift on the path since 540239b6b. Patch-id 84974d6b08d6 matches the candidate's. Gate per the brief: Webby APPROVE r1 rev 2883, Sage's gate 13:05-13:07Z (file 3x 37/0 no stub, bus 596/0, mutant left 2). |
| 249 | row 235 follow-up: the grant undo records hostLineLeft when install never returned, since the remote add can outlive an abort or timeout (mos-1166) | gizmoduck; reviewers duckworth | #1751 | done | — | Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files are green, and a mutant that drops the new catch branch is killed. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the undo can't prove the remote add has ended | LANDED 9de5a57ab on refactor, pushed with 7b9737953 (DEFERRED N1). Range-diff: 8a04f4aa4..05595794b vs 8ca064158..9de5a57ab, same 3 paths, patch-id 99551b122e66 on both. Gate sage-2026 14:53Z: grant-executor whole 39/0, bus 598/0, no stub left, C1 (drop :1025) killed by G8 and both G23. Duckworth approve c30070. |
<!-- mosaic-queue:end -->
+8614 -114
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -940,6 +940,7 @@ export class Broker {
if (signal.aborted) fail('revoked');
};
let installing = false,
installed = false,
registered = false,
committed = false;
try {
@@ -952,6 +953,7 @@ export class Broker {
step('install');
installing = true;
await this.#hostGrant.install(business, g.host, record, tag, key.publicLine, signal);
installed = true;
stop();
await this.#hostGrant.verify(g.host, record, key, signal);
stop();
@@ -1018,6 +1020,9 @@ export class Broker {
} catch {
extra = { hostLineLeft: tag };
}
// An abort or a timeout kills only the local ssh, so an add that hasn't returned may still write
// its line after the remove has run. Only install returning proves it ended (row 249).
if (!installed) extra = { hostLineLeft: tag };
}
try {
this.#store.transaction(() => this.#hostGrantFailed(business, decision, g, code, extra));
+61 -5
View File
@@ -51,6 +51,7 @@ async function until(fn, ms = 10000) {
// The stubs read flag files in the test dir: fail-<verb> exits 1, noop-add exits 0 and writes nothing,
// hold-add waits for `release` (then exits 1 if `skip` is there), and hold-remove waits for `release`.
// Both also stop when the test dir is gone, since a held stub on the sshd side outlives its test (#1750).
// wrap.sh logs every request. The sudo stub logs its arguments to sudo.log and runs the command as the
// test's user.
async function sshd(t, dir) {
@@ -63,12 +64,12 @@ async function sshd(t, dir) {
stub('list', '[ -e "$D/fail-list" ] && exit 1\n[ -e "$D/granted" ] && grep " mosaic-[0-9a-f]*$" "$D/granted"\nexit 0');
stub(
'add',
'if [ -e "$D/hold-add" ]; then : > "$D/holding"; while [ ! -e "$D/release" ]; do sleep 0.05; done; [ -e "$D/skip" ] && exit 1; fi\n' +
'if [ -e "$D/hold-add" ]; then : > "$D/holding"; while [ ! -e "$D/release" ] && [ -d "$D" ]; do sleep 0.05; done; [ -e "$D/skip" ] && exit 1; fi\n' +
'[ -e "$D/fail-add" ] && exit 1\n[ -e "$D/noop-add" ] && exit 0\n' +
'grep -q " $4\\$" "$D/granted" 2>/dev/null && exit 0\necho "restrict $2 $3 $4" >> "$D/granted"',
);
stub('sudo', 'printf "%s\\n" "$*" >> "$D/sudo.log"\nshift 2\nexec "$@"');
stub('remove', 'if [ -e "$D/hold-remove" ]; then : > "$D/holding-remove"; while [ ! -e "$D/release" ]; do sleep 0.05; done; fi\n' +
stub('remove', 'if [ -e "$D/hold-remove" ]; then : > "$D/holding-remove"; while [ ! -e "$D/release" ] && [ -d "$D" ]; do sleep 0.05; done; fi\n' +
'[ -e "$D/fail-remove" ] && exit 1\n[ -e "$D/granted" ] || exit 0\ngrep -v " $2\\$" "$D/granted" > "$D/granted.tmp"\nmv "$D/granted.tmp" "$D/granted"');
writeFileSync(
join(dir, 'wrap.sh'),
@@ -286,14 +287,17 @@ test('G8: a failure at install or verify removes the line and leaves no record,
const { id, end } = await f.approve();
assert.equal(end.code, 'host-failed', flag);
assert.equal(end.tag, f.tag);
assert.equal('hostLineLeft' in end, false);
// An install that didn't return keeps the sweep hint, since its add may still be running (row 249).
// Here the hint is false, and a sweep drops it (G24). noop-add fails at verify, after install returned.
const returned = flag === 'noop-add';
assert.equal(end.hostLineLeft, returned ? undefined : f.tag, flag);
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`, flag);
assert.deepEqual(f.granted(), []);
assert.equal(f.record(), undefined);
assert.equal(existsSync(f.keyFile), false);
assert.equal(f.state(), undefined);
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action), ['grant.step', 'grant.failed']);
assert.match(f.messages(id)[0].body, /failed: host-failed\.$/);
assert.match(f.messages(id)[0].body, returned ? /failed: host-failed\.$/ : /Run: mosaic hosts sweep web1$/);
}
});
@@ -429,7 +433,8 @@ test('G23: a revoke during install ends grant.failed revoked with no key file, n
await until(() => existsSync(join(f.dir, 'holding')));
assert.deepEqual(f.credentials.abortClaim('demo/ops', 'host.web1'), { pending: true });
await until(() => f.ended(d.id));
// The held add then exits without writing, as a killed session's would.
// `skip` makes the held add exit without writing. A killed session's add can still write; the row 249
// tests below cover that.
f.flag('skip');
f.flag('release');
assert.equal(f.ended(d.id).code, 'revoked');
@@ -441,6 +446,57 @@ test('G23: a revoke during install ends grant.failed revoked with no key file, n
assert.equal(f.credentials.busy('demo/ops', 'host.web1'), false);
});
// Row 249. An abort and a timeout kill only the local ssh, and sshd sends a non-pty command no SIGHUP,
// so the held add goes on. Released after the undo's remove, it writes its line and exits 0.
const lateAdd = async (f, id, code) => {
await until(() => f.ended(id));
const end = f.ended(id);
assert.equal(end.code, code);
assert.equal(end.hostLineLeft, f.tag);
assert.match(f.messages(id)[0].body, new RegExp(`tagged ${f.tag} may be left on the host\\. Run: mosaic hosts sweep web1$`));
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`);
f.flag('release');
await until(() => f.granted().some((l) => l.endsWith(' ' + f.tag)));
assert.equal(f.record(), undefined);
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, [f.tag]);
await refusesWith(f.call('human', 'hosts.remove', { host: 'web1' }), 'host-line-left');
assert.deepEqual(await f.call('human', 'hosts.sweep', { host: 'web1' }), { host: 'web1', removed: [f.tag], hostLineLeft: [] });
assert.deepEqual(f.granted(), []);
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
};
test('G23 (row 249): an add that writes after an abort\'s remove leaves hostLineLeft until the sweep', async (t) => {
const f = await setup(t);
f.flag('hold-add');
const d = await f.raise();
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => existsSync(join(f.dir, 'holding')));
assert.deepEqual(f.credentials.abortClaim('demo/ops', 'host.web1'), { pending: true });
await lateAdd(f, d.id, 'revoked');
});
// The timeout's kill without its 30 s wait: install gets its own signal, which SIGKILLs the local ssh
// group as host-run.mjs's timer does, and the call then fails as a timed-out request does.
test('G23 (row 249): an add that writes after a timed-out install\'s remove leaves hostLineLeft until the sweep', async (t) => {
const f = await setup(t, {
wrap: (hg) => ({
...hg,
install: async (business, host, record, tag, line) => {
const timer = new AbortController();
const out = hg.install(business, host, record, tag, line, timer.signal);
await until(() => existsSync(join(f.dir, 'holding')));
timer.abort();
await out.catch(() => {});
throw new Error('timed out');
},
}),
});
f.flag('hold-add');
const d = await f.raise();
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await lateAdd(f, d.id, 'host-failed');
});
test('G23: a revoke recorded after the raise and before the start ends step 1 revoked, and a held slot ends host-busy', async (t) => {
const f = await setup(t);
const revoked = f.fixtureGrant();
+24 -5
View File
@@ -458,17 +458,36 @@ test('T27: ssh-add -t drops the key from an agent the test keeps alive', async (
const agent = spawn('ssh-agent', ['-D', '-a', sock], { stdio: 'ignore' });
t.after(() => agent.kill('SIGKILL'));
assert.ok(await until(() => existsSync(sock)));
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 1);
const list = () => {
// A failed list names its cause: when each step ran on the monotonic clock (hrtime) and on
// /proc/uptime (CLOCK_BOOTTIME, which ssh-agent's lifetime counts in whole seconds), and
// whether the agent and its socket were still there.
const marks = [];
const mark = (name) => marks.push({ name, ns: process.hrtime.bigint(), uptime: readFileSync('/proc/uptime', 'utf8').split(' ')[0] });
const timing = () => {
const at = (m) => `${m.name} +${(Number(m.ns - marks[0].ns) / 1e6).toFixed(1)} ms (hrtime ${(Number(m.ns % 1_000_000_000n) / 1e9).toFixed(4)} s into its second, uptime ${m.uptime})`;
const alive = agent.exitCode === null && agent.signalCode === null;
return `${marks.map(at).join('; ')}; agent ${alive ? 'alive' : `gone (${agent.exitCode ?? agent.signalCode})`}, socket ${existsSync(sock) ? 'present' : 'missing'}`;
};
// ssh-agent sets a key's death to the whole second of CLOCK_BOOTTIME at the add plus the
// lifetime, and drops it once that whole second is reached. So `-t N` keeps a key between N - 1
// and N seconds: `-t 1` added just before a second boundary was gone within milliseconds (#453).
// `-t 2` keeps it at least 1 s, and the sleep waits past the 2 s ceiling.
mark('addKey start');
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 2);
mark('addKey resolved');
const list = (name) => {
mark(name);
try {
return execFileSync('ssh-add', ['-l'], { env: { PATH: '/usr/bin:/bin', SSH_AUTH_SOCK: sock }, encoding: 'utf8' });
} catch (e) {
return e.stdout;
}
};
assert.match(list(), /SHA256:/);
await sleep(2500);
assert.match(list(), /no identities/);
const first = list('first list');
assert.match(first, /SHA256:/, `first list got ${JSON.stringify(first)}: ${timing()}`);
await sleep(3500);
const second = list('second list');
assert.match(second, /no identities/, `second list got ${JSON.stringify(second)}: ${timing()}`);
});
// The test's own limit makes a hung addKey (M37a, M37b) fail in seconds, not at the batch timeout.
+45 -5
View File
@@ -7,7 +7,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmp } from "./helpers.mjs";
@@ -77,10 +77,13 @@ args=(); for a in "$@"; do if [[ "$a" == /* ]] && [ -e "$ROOT_DIR$a" ]; then arg
HOME="$ROOT_DIR/srv/mosaic" PATH="$np$PATH" exec "$ROOT_DIR$cmd" "\${args[@]}"`,
};
// Executables inside the tree, run by the fake sudo or by the script itself.
// Executables inside the tree, run by the fake sudo or by the script itself. Node marks a run outside
// the fake sudo, which sets HOME to the stack's home, so a test can tell whether root ran it.
const NODE_STUB = `[ "$HOME" = "$ROOT_DIR/srv/mosaic" ] || touch "$HC/node-as-root"
[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
exec "$REAL_NODE" "$@"`;
const TREE = {
[NODE]: `[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
exec "$REAL_NODE" "$@"`,
[NODE]: NODE_STUB,
[CLAUDE]: `[ "\${1:-}" = --version ] && cat "$HC/claude-version"; exit 0`,
[CLI]: `${log("mosaic")}
case "$1" in
@@ -151,6 +154,9 @@ function host(t) {
for (const [name, text] of Object.entries(state)) put(join(hc, name), text);
for (const [name, body] of Object.entries(FAKES)) put(join(bin, name), `#!/usr/bin/env bash\n${name === "stat" ? "" : log(name)}\n${body}\n`, 0o755);
for (const [path, body] of Object.entries(TREE)) put(join(root, path), `#!/usr/bin/env bash\n${body}\n`, 0o755);
// F9 runs node only when it and each directory above it have no group or other write, whatever the umask.
for (let d = dirname(NODE); d !== "/"; d = dirname(d)) chmodSync(join(root, d), 0o755);
chmodSync(root, 0o755);
return { dir, root, hc, bin, args };
}
@@ -204,6 +210,7 @@ test("row 195 accept: every check passing exits 0, with each P mark and counts t
assert.match(r.out, /^F5 bwrap-label pass bwrap 300 reads 'bwrap \(enforce\)' and its claude descendant 302 reads/m);
assert.match(r.out, /^F7 pm-live pass one live launch: pm run r-0002$/m);
assert.ok(!(r.out + r.err).includes(CRED_VALUE));
assert.ok(existsSync(join(h.hc, "node-as-root")), "F9 runs the pinned, root-only node as root");
});
test("row 195 accept: exits 1 with each F check failing alone, and its line names it", (t) => {
@@ -274,7 +281,7 @@ test("row 195 accept: F5 fails with no bwrap of mosaic's, and passes on comm alo
assert.deepEqual(failed(mustRun(h)), []);
});
test("row 195 accept: F6 fails on a stub that prints nothing and on a path outside claude-<pin>", (t) => {
test("row 195 accept: F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment", (t) => {
let h = host(t);
put(join(h.hc, "claude-version"), "");
let r = mustRun(h);
@@ -286,6 +293,14 @@ test("row 195 accept: F6 fails on a stub that prints nothing and on a path outsi
r = mustRun(h);
assert.deepEqual(failed(r), ["F6"]);
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+claude-2\.0\.9\S+ is not under \/opt\/mosaic-stack\/claude-2\.1\.0\/$/m);
// Under claude-<pin>/ by prefix, but the binary it names is 2.0.9's, which answers with the pin.
h = host(t);
const dotdot = CLAUDE.replace(`claude-${CLAUDE_PIN}/`, `claude-${CLAUDE_PIN}/../claude-2.0.9/`);
put(h.args, ARGS.map((a) => (a === CLAUDE ? dotdot : a)).join("\n") + "\n", 0o644);
put(join(h.root, other), `#!/usr/bin/env bash\necho "${CLAUDE_PIN} (Claude Code)"\n`, 0o755);
r = mustRun(h);
assert.deepEqual(failed(r), ["F6"], r.out);
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+\/claude-2\.1\.0\/\.\.\/claude-2\.0\.9\/\S+ has a \.\. segment$/m);
});
test("row 195 accept: F7 fails on two live PM launches, one for another business, and no launch.by", (t) => {
@@ -328,6 +343,31 @@ test("row 195 accept: F9 fails on another owner, a group-writable mode and a num
}
});
test("row 195 accept: F9 won't run --node as root off the pin path or with a writable directory above it", (t) => {
// Another node that works for the stack's user: F9 refuses to run it, and F10 fails on the path.
let h = host(t);
const other = NODE.replace(NODE_PIN, "v24.0.9");
put(h.args, ARGS.map((a) => (a === NODE ? other : a)).join("\n") + "\n", 0o644);
put(join(h.root, other), `#!/usr/bin/env bash\n${NODE_STUB}\n`, 0o755);
let r = mustRun(h);
assert.deepEqual(failed(r), ["F9", "F10"], r.out);
assert.match(r.out, /^F9 autoupdater-off FAIL root won't run --node \/opt\/mosaic-stack\/node-v24\.0\.9\/bin\/node: it is not the --node-pin path$/m);
assert.ok(!existsSync(join(h.hc, "node-as-root")));
// The pinned path, under a group-writable directory and then a symlinked node: F9 alone fails, and
// root doesn't run it.
for (const [fault, bad] of [
[(x) => chmodSync(join(x.root, dirname(NODE)), 0o775), "/opt/mosaic-stack/node-v24\\.1\\.0/bin \\(uid \\d+, mode 775\\)"],
[(x) => { const f = join(x.root, NODE); renameSync(f, `${f}.real`); symlinkSync(`${f}.real`, f); }, "/opt/mosaic-stack/node-v24\\.1\\.0/bin/node \\(uid \\d+, mode 777\\)"],
]) {
h = host(t);
fault(h);
r = mustRun(h);
assert.deepEqual(failed(r), ["F9"], r.out);
assert.match(r.out, new RegExp(`^F9 autoupdater-off FAIL root won't run --node \\S+: ${bad} is not root's alone$`, "m"));
assert.ok(!existsSync(join(h.hc, "node-as-root")));
}
});
test("row 195 accept: F11 fails on one *.json under discord/ and passes on an empty directory", (t) => {
let h = host(t);
put(join(h.root, HOME, ".mosaic-dev", "discord", "chief.json"), "{}");
@@ -18,6 +18,7 @@ const REPO = fileURLToPath(new URL("../../../", import.meta.url));
const SCRIPT = join(REPO, "scripts", "case2-persona-import.mjs");
const FIXTURE = join(REPO, "packages", "cli", "tests", "fixtures", "case2-persona-import");
const TRACE = join(FIXTURE, "trace.mjs");
const MKDIR_RACE = join(FIXTURE, "mkdir-race.mjs");
const PAIRS = ["--pair", "default=Bean", "--pair", "chet=Chet"];
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
const mode = (path) => statSync(path).mode & 0o777;
@@ -36,15 +37,15 @@ function scratch(t) {
return { root, hermes, configDir, staging: join(root, "staging"), trace: join(root, "trace.log") };
}
function run(argv, { trace } = {}) {
const pre = trace ? ["--import", TRACE] : [];
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}) };
function run(argv, { trace, race } = {}) {
const pre = [...(trace ? ["--import", TRACE] : []), ...(race ? ["--import", MKDIR_RACE] : [])];
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}), ...(race ? { PERSONA_MKDIR_RACE: race } : {}) };
const r = spawnSync(process.execPath, [...pre, SCRIPT, ...argv], { encoding: "utf8", env, timeout: 30_000 });
return { status: r.status, out: r.stdout, err: r.stderr, all: r.stdout + r.stderr };
}
const stage = (s, extra = [], opts) => run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, ...extra], opts);
const install = (s, business = "case2") => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business]);
const install = (s, business = "case2", opts) => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business], opts);
// Every fixture marker and every line of fixture text the script reads.
function assertNoText(s, output) {
@@ -102,18 +103,38 @@ test("stage lists the per-channel prompts in all three places by name and byte c
assert.equal(r.status, 0, r.all);
const lines = r.out.split("\n").filter((l) => l.includes(": per-channel prompt "));
assert.deepEqual(lines, [
`profile default: per-channel prompt discord 111111111111111111, ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
`profile default: per-channel prompt discord 222222222222222222, ${Buffer.byteLength("Ünïcode prompt")} bytes`,
`profile default: per-channel prompt "discord" "111111111111111111", ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
`profile default: per-channel prompt "discord" "222222222222222222", ${Buffer.byteLength("Ünïcode prompt")} bytes`,
// Unquoted in the fixture, and past 2 ** 53: the id prints exactly.
"profile default: per-channel prompt discord 1333333333333333333, 5 bytes",
`profile default: per-channel prompt platforms.slack C0FIXTURE, ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
"profile default: per-channel prompt gateway.platforms.telegram -1001, 29 bytes",
"profile default: per-channel prompt gateway.platforms.telegram -1002, not text",
'profile default: per-channel prompt "discord" "1333333333333333333", 5 bytes',
`profile default: per-channel prompt "platforms.slack" "C0FIXTURE", ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
'profile default: per-channel prompt "gateway.platforms.telegram" "-1001", 29 bytes',
'profile default: per-channel prompt "gateway.platforms.telegram" "-1002", not text',
]);
assert.ok(r.out.includes("profile chet: no per-channel prompts"), r.out);
assertNoText(s, r.all);
});
// C2-20b N1 (webby-0073): a platform or channel key holding a newline prints as one quoted line, so
// it can't forge a line of the script's own output.
test("a config key with a newline prints as one quoted line", (t) => {
const s = scratch(t);
writeFileSync(join(s.hermes, "profiles", "chet", "config.yaml"), [
'"discord\\nstaged: /forged/bean.md":',
" channel_prompts:",
' "1\\npairing: profile chet is Bean": marker-prompt-forge-4c1e',
"",
].join("\n"));
const r = stage(s);
assert.equal(r.status, 0, r.all);
const lines = r.out.split("\n");
assert.deepEqual(lines.filter((l) => l.startsWith("profile chet: per-channel prompt ")), [
`profile chet: per-channel prompt "discord\\nstaged: /forged/bean.md" "1\\npairing: profile chet is Bean", ${Buffer.byteLength("marker-prompt-forge-4c1e")} bytes`,
]);
assert.ok(!lines.some((l) => l.startsWith("staged: /forged") || l.startsWith("pairing: profile chet is Bean")), r.out);
assertNoText(s, r.all);
});
test("a read trace shows stage and the dry run open SOUL.md and config.yaml and never memory, sessions or secrets", (t) => {
const s = scratch(t);
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
@@ -288,23 +309,66 @@ test("install refuses an open or foreign staging file, a digest off the manifest
assert.match(r.err, /the manifest doesn't list two personas/);
});
test("--dry-run writes nothing and prints only counts, two heading lines in a fixture counted", (t) => {
test("--dry-run writes nothing and prints only counts and the fence fault, two heading lines in a fixture counted", (t) => {
const s = scratch(t);
personaTree(s, { chet: "You are Chet. marker-chet-dry-7d21\n# Voice\nShort.\r\n## Habits\nNot # a heading, nor #nospace.\n" });
const before = readdirSync(s.root).sort();
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 0, r.all);
assert.equal(r.status, 1, r.all);
assert.deepEqual(readdirSync(s.root).sort(), before);
assert.deepEqual(r.out.split("\n").filter(Boolean), [
"files 2",
'profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
'profile chet: lines 5, lines starting "# " or "## " 2, lines naming a Hermes tool or channel 0',
'profile chet: persona holds </persona-file> or a line starting "# " or "## ", so C2-08\'s fence would refuse it',
]);
assert.equal(r.err, "");
assert.equal(r.err, "case2-persona-import: refused: C2-08's fence would refuse the persona of profile chet, so stage would write nothing\n");
assertNoText(s, r.all);
assert.ok(!r.all.includes("Voice") && !r.all.includes("Habits"));
});
// C2-20b N2 (webby-0073): a persona over the fence and under the byte cap used to pass the dry run
// and then refuse at stage. The dry run names the fault and refuses too, and passes at the limit.
test("--dry-run applies C2-08's fence: over it the fault prints, nothing is written and it exits 1", (t) => {
const atLimit = "é".repeat(PERSONA_CHARS);
const s = scratch(t);
personaTree(s, { bean: atLimit + "é" });
const before = readdirSync(s.root).sort();
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 1, r.all);
assert.deepEqual(readdirSync(s.root).sort(), before);
assert.deepEqual(r.out.split("\n").filter(Boolean), [
"files 2",
'profile default: lines 1, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
"profile default: persona is over 8000 characters, so C2-08's fence would refuse it",
'profile chet: lines 2, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
]);
assert.match(r.err, /refused: C2-08's fence would refuse the persona of profile default, so stage would write nothing/);
assert.ok(!r.all.includes("éé"));
personaTree(s, { bean: atLimit });
const ok = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(ok.status, 0, ok.all);
assert.equal(ok.err, "");
});
// C2-20b N4 (webby-0073): a directory made between privateDir's lstat and its mkdir refuses with a
// named reason, not a raw EEXIST. mkdir-race.mjs makes the directory just before the script's mkdir.
test("a directory made between the check and mkdir refuses by name, at stage and at install", (t) => {
const s = scratch(t);
let r = stage(s, [], { race: s.staging });
assert.equal(r.status, 1, r.all);
assert.equal(r.err, `case2-persona-import: refused: ${s.staging} was made by something else between the check and mkdir\n`);
assert.deepEqual(readdirSync(s.staging), []);
const s2 = scratch(t);
assert.equal(stage(s2).status, 0);
const dir = join(s2.configDir, "personas", "case2");
r = install(s2, "case2", { race: dir });
assert.equal(r.status, 1, r.all);
assert.equal(r.err, `case2-persona-import: refused: ${dir} was made by something else between the check and mkdir\n`);
assert.deepEqual(readdirSync(dir), []);
});
test("--dry-run counts three lines naming a Hermes tool or channel and prints none of the words", (t) => {
const s = scratch(t);
personaTree(s, {
@@ -0,0 +1,13 @@
// mkdir race for case2-persona-import.test.mjs: loaded with --import, it makes the directory named by
// $PERSONA_MKDIR_RACE just before the script's own mkdirSync of that path, as if another process won
// the race after the script's lstat. The script's mkdir then meets a real EEXIST.
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
const { mkdirSync } = fs;
const target = process.env.PERSONA_MKDIR_RACE;
fs.mkdirSync = function raced(p, ...rest) {
if (String(p) === target) mkdirSync(p, { mode: 0o700 });
return mkdirSync.call(this, p, ...rest);
};
syncBuiltinESMExports();
+20
View File
@@ -0,0 +1,20 @@
// Controlled child for lane crash tests; no live service, network or model.
import { writeFileSync } from 'node:fs';
const [mode,marker]=process.argv.slice(2);
if(marker&&mode!=='lock-probe')writeFileSync(marker,String(process.pid));
if(mode!=='lock-probe')process.stdout.write('READY\n');
if(mode==='ignore-term')process.on('SIGTERM',()=>{});
else process.on('SIGTERM',()=>process.exit(0));
setInterval(()=>{},1000);
if(mode==='lock-probe'){
const stateRoot=marker;
const {verifyLocks,executeSteps}=await import('../../../../scripts/testing/gate-lane.mjs');
verifyLocks({lane:'a',stateRoot,exclusive:false});
const {appendRecord,identity}=await import('../../../../scripts/testing/lane-state.mjs');
const {mkdirSync}=await import('node:fs');const {join}=await import('node:path');
const out=join(stateRoot,'out');mkdirSync(out);
appendRecord(join(stateRoot,'runs','abcdef123456'),{kind:'start',lane:'a',owner:identity()});
const result=await executeSteps([{name:'fds',cwd:stateRoot,argv:[process.execPath,'-e',"const fs=require('fs'); for(const fd of fs.readdirSync('/proc/self/fd')){let p;try{p=fs.readlinkSync('/proc/self/fd/'+fd)}catch{continue}if(p.endsWith('/a.lock')||p.endsWith('/image.lock'))process.exit(7)}"]}],{out,env:process.env,record:()=>{}});
if(result.rc)process.exit(result.rc);
writeFileSync(join(stateRoot,'ready'),'verified');
}
@@ -0,0 +1,94 @@
import test from 'node:test';import assert from 'node:assert/strict';
import { mkdtempSync,rmSync,mkdirSync,writeFileSync } from 'node:fs';import { join } from 'node:path';import { createServer } from 'node:net';import { once } from 'node:events';
const api=await import('../../../scripts/testing/lane-fixtures.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
const evidence=await import('../../../scripts/testing/lane-evidence.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
test('parallel lease holders use only the active lane and its shared state',async t=>{
const ctx=leaseContext(t),lane=ctx.lane;
const [a,b]=await Promise.all([api.leasePort(ctx.fixture,ctx),api.leasePort(ctx.fixture,ctx)]);
const bands=BANDS,[lo,hi]=bands[lane];
assert.ok(a>=lo&&a<=hi);assert.ok(b>=lo&&b<=hi);assert.notEqual(a,b);
assert.ok(bands.a[1]<bands.b[0]);assert.equal(bands.a[1]-bands.a[0],127);assert.equal(bands.b[1]-bands.b[0],127);
});
test('unrelated home changes do not become lane writes, and own-root references do',()=>{
assert.equal(typeof evidence.attributeEntries,'function','attribution is required');
const result=evidence.attributeEntries([{path:'/home/person/.claude/projects/other-session',link:null},{path:'/home/person/.cache/lane-abcdef123456',link:null},{path:'/home/person/.pi/item',link:'/mnt/storage/scratch/gt-ABCDEF/work'}],{nonce:'abcdef123456',tempRoot:'/mnt/storage/scratch/gt-ABCDEF',fixturePaths:[]});
assert.equal(result.owned.length,2);assert.equal(result.unrelated.length,1);
});
test('proof summaries refuse missing timings, non-overlap and a pair without a lane swap',()=>{
const solos=[{candidate:'one',wallMs:100},{candidate:'two',wallMs:100}];
const make=(lane,candidate,startMs=0,endMs=120)=>({lane,candidate,startMs,endMs,rc:0,clean:true});
const pairs=[[make('a','one'),make('b','two',5,125)],[make('b','one'),make('a','two',5,125)],[make('a','one'),make('b','two',5,125)]];
const result=evidence.summarizePairs(solos,pairs);assert.equal(result.length,3);assert.equal(result[0].makespanMs,125);assert.equal(result[0].runs[0].slowdown,1.2);assert.equal(result[0].throughput,1.6);
const bad=structuredClone(pairs);delete bad[0][0].endMs;assert.throws(()=>evidence.summarizePairs(solos,bad),/timing/);
const noSwap=structuredClone(pairs);noSwap[1]=structuredClone(pairs[0]);assert.throws(()=>evidence.summarizePairs(solos,noSwap),/swap/);
const serial=structuredClone(pairs);serial[0][1].startMs=130;serial[0][1].endMs=250;assert.throws(()=>evidence.summarizePairs(solos,serial),/overlap/);
});
import { BANDS } from '../../../scripts/testing/lane-state.mjs';
import { spawn,spawnSync } from 'node:child_process';
import { readFileSync,existsSync } from 'node:fs';
function leaseContext(t){
const lane=process.env.MOSAIC_GATE_LANE??'a',stateRoot=process.env.MOSAIC_GATE_STATE??'/mnt/storage/scratch/mosaic-gate-lanes',callbacks=[];
mkdirSync(stateRoot,{recursive:true,mode:0o700});
t.after(async()=>{for(const f of callbacks.reverse())await f();});
return {lane,stateRoot,fixture:{after:f=>callbacks.push(f)},cleanup:f=>callbacks.push(f)};
}
const delay=ms=>new Promise(r=>setTimeout(r,ms));
async function waitFor(check){for(let i=0;i<100;i++){if(check())return;await delay(20);}throw Error('fixture not ready');}
test('an exhausted single-port band refuses, and an occupied foreign listener survives the probe',async t=>{
const ctx=leaseContext(t),callbacks=[];
const port=await api.leasePort({after:f=>callbacks.push(f)},ctx),original=BANDS[ctx.lane];
const server=createServer();
try{
BANDS[ctx.lane]=[port,port];
await assert.rejects(api.leasePort(t,ctx),/exhausted/);
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',resolve);});
// Release only our lock. The sentinel listener remains foreign to the next lease.
await callbacks.pop()();
await assert.rejects(api.leasePort(t,ctx),/exhausted/);assert.equal(server.listening,true);
}finally{BANDS[ctx.lane]=original;if(server.listening)await new Promise(r=>server.close(r));for(const f of callbacks)await f();}
});
test('parallel child allocators share real lane locks; a lease remains held through child shutdown',async t=>{
const ctx=leaseContext(t),root=mkdtempSync('/mnt/storage/scratch/gf-');
const modulePath=new URL('../../../scripts/testing/lane-fixtures.mjs',import.meta.url).href;
const code=`import test from 'node:test';import fs from 'node:fs';const {leasePort}=await import(${JSON.stringify(modulePath)});test('lease held until fixture child finishes',async t=>{const port=await leasePort(t,JSON.parse(process.env.LEASE_CONTEXT));fs.writeFileSync(process.env.LEASE_PORT_FILE,String(port));await new Promise(r=>{const timer=setInterval(()=>{if(fs.existsSync(process.env.LEASE_STOP_FILE)){clearInterval(timer);r()}},5)});});`;
const start=number=>{const file=join(root,`lease-${number}.test.mjs`),portFile=join(root,`port-${number}`),stopFile=join(root,`stop-${number}`);writeFileSync(file,code);const env={...process.env,LEASE_CONTEXT:JSON.stringify(ctx),LEASE_PORT_FILE:portFile,LEASE_STOP_FILE:stopFile};delete env.NODE_TEST_CONTEXT;const child=spawn(process.execPath,['--test','--test-concurrency=2','--test-reporter=tap',file],{env,stdio:['ignore','pipe','pipe']});child.output='';child.stderr.on('data',b=>child.output+=b);child.stdout.on('data',b=>child.output+=b);child.done=new Promise(r=>child.once('exit',code=>{child.exitedCode=code;r(code);}));child.portFile=portFile;child.stopFile=stopFile;return child;};
const children=[start(1),start(2)];ctx.cleanup(async()=>{try{for(const c of children)writeFileSync(c.stopFile,'stop');await Promise.all(children.map(c=>c.done));}finally{rmSync(root,{recursive:true,force:true});}});
await waitFor(()=>{for(const c of children)if(c.exitedCode!==undefined)throw Error(`allocator exited ${c.exitedCode}: ${c.output}`);return children.every(c=>existsSync(c.portFile));});
const ports=children.map(c=>Number(readFileSync(c.portFile,'utf8')));assert.notEqual(...ports);
const lock=join(ctx.stateRoot,'ports',ctx.lane,`${ports[0]}.lock`);
assert.equal(spawnSync('flock',['-n',lock,'true']).status,1);
writeFileSync(children[0].stopFile,'stop');assert.equal(await children[0].done,0,children[0].output);
assert.equal(spawnSync('flock',['-n',lock,'true']).status,0);
assert.equal(spawnSync('flock',['-n',join(ctx.stateRoot,'ports',ctx.lane,`${ports[1]}.lock`),'true']).status,1);
});
test('abrupt allocator death releases its holder without touching a foreign lease',async t=>{
const ctx=leaseContext(t),foreign=await api.leasePort(ctx.fixture,ctx),modulePath=new URL('../../../scripts/testing/lane-fixtures.mjs',import.meta.url).href;
const code=`const {leasePort}=await import(${JSON.stringify(modulePath)});const port=await leasePort({after:()=>{}},JSON.parse(process.argv[1]));console.log(port);setInterval(()=>{},1000);`;
const child=spawn(process.execPath,['--input-type=module','-e',code,JSON.stringify(ctx)],{stdio:['ignore','pipe','pipe']});const done=new Promise(r=>child.once('exit',r));ctx.cleanup(async()=>{child.kill('SIGKILL');await done;});
const port=await new Promise(r=>child.stdout.once('data',b=>r(Number(String(b).trim()))));assert.notEqual(port,foreign);
child.kill('SIGKILL');await done;
const lock=join(ctx.stateRoot,'ports',ctx.lane,`${port}.lock`);
await waitFor(()=>spawnSync('flock',['-n',lock,'true']).status===0);
assert.equal(spawnSync('flock',['-n',join(ctx.stateRoot,'ports',ctx.lane,`${foreign}.lock`),'true']).status,1);
});
test('fixture caches survive a replacement environment with a separate tmp subdirectory',async t=>{
const root=mkdtempSync('/mnt/storage/scratch/gf-');t.after(()=>rmSync(root,{recursive:true,force:true}));
const env=api.fixtureCacheEnv(root);assert.equal(env.TMPDIR,join(root,'tmp'));assert.equal(env.JITI_FS_CACHE,join(root,'jiti'));assert.equal(env.XDG_CACHE_HOME,join(root,'cache'));
const code="const fs=require('fs'),p=require('path');for(const k of ['TMPDIR','JITI_FS_CACHE','XDG_CACHE_HOME'])fs.writeFileSync(p.join(process.env[k],k),'fixture');if(process.env.HOME)process.exit(7)";
const result=spawnSync(process.execPath,['-e',code],{env,encoding:'utf8'});assert.equal(result.status,0,result.stderr);
for(const key of Object.keys(env))assert.equal(readFileSync(join(env[key],key),'utf8'),'fixture');
});
test('an unrelated session can append while a supervised step runs without becoming an attributed write',async t=>{
const root=mkdtempSync('/mnt/storage/scratch/gf-');t.after(()=>rmSync(root,{recursive:true,force:true}));
const home=join(root,'home'),out=join(root,'out'),file=join(home,'.claude','other-session');mkdirSync(join(home,'.claude'),{recursive:true});mkdirSync(out);writeFileSync(file,'before');
const before=evidence.homeMetadata(home),prior=new Map(before.map(e=>[e.path,JSON.stringify(e)]));
const {executeSteps}=await import('../../../scripts/testing/gate-lane.mjs');
const barrier=join(root,'step-running');
const append=spawn(process.execPath,['-e',"const fs=require('fs');const deadline=setTimeout(()=>{console.error('append barrier timed out');process.exit(8)},5000);const timer=setInterval(()=>{if(fs.existsSync(process.argv[2])){clearInterval(timer);clearTimeout(deadline);fs.appendFileSync(process.argv[1],'unrelated append')}},5)",file,barrier],{stdio:'ignore'});const appended=new Promise(r=>append.once('exit',r));t.after(()=>append.kill('SIGKILL'));
let appendedDuringStep=false;
const stepCode="const fs=require('fs');fs.writeFileSync(process.argv[1],'running');const deadline=setTimeout(()=>{console.error('session append timed out');process.exit(8)},5000);const timer=setInterval(()=>{if(fs.readFileSync(process.argv[2],'utf8').includes('unrelated append')){clearInterval(timer);clearTimeout(deadline)}},5)";
const result=await executeSteps([{name:'wait',cwd:root,argv:[process.execPath,'-e',stepCode,barrier,file]}],{out,env:process.env,record:r=>{if(r.kind==='step')appendedDuringStep=readFileSync(file,'utf8').includes('unrelated append');}});
assert.equal(await appended,0);assert.equal(result.rc,0);assert.equal(appendedDuringStep,true);
const changed=evidence.homeMetadata(home).filter(e=>prior.get(e.path)!==JSON.stringify(e));assert.ok(changed.some(e=>e.path===file));
const attributed=evidence.attributeEntries(changed,{nonce:'abcdef123456',tempRoot:join(root,'temp'),fixturePaths:[]});assert.equal(attributed.owned.length,0);assert.ok(attributed.unrelated.length>0);
});
+136
View File
@@ -0,0 +1,136 @@
import test from 'node:test';import assert from 'node:assert/strict';
import { mkdtempSync,rmSync,writeFileSync,mkdirSync,cpSync,readFileSync,statSync,symlinkSync,unlinkSync } from 'node:fs';import { join, isAbsolute } from 'node:path';
import { spawnSync } from 'node:child_process';
const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
test('a changed COPY input or actual image ID refuses compatible image sharing',t=>{
assert.equal(typeof image.imageFingerprint,'function','image fingerprint required');
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
writeFileSync(join(root,'RELEASE'),'0.0.1\n');
for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');}
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
const inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
assert.equal(image.verifyImage(receipt,root,inspect).imageId,id);
assert.throws(()=>image.verifyImage(receipt,root,()=>({...inspect(),Id:'sha256:'+'c'.repeat(64)})),/image ID/);
writeFileSync(join(root,'adapters','one'),'different');assert.throws(()=>image.verifyImage(receipt,root,inspect),/inputs/);
});
test('warm Docker guard disables implicit build and pull and refuses image mutation',t=>{
assert.equal(typeof docker.guardDocker,'function','Docker guard required');
const run=composeFixture(t);
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
for(const args of [['build','.'],['image','prune'],['compose','build'],['tag','a','b'],['network','rm','foreign']])assert.throws(()=>docker.guardDocker(args,run),/refused/);
});
test('exclusive image preparation records actual image ID and build wall time and requires a grant',t=>{
assert.equal(typeof image.prepareImage,'function','prepareImage is required');
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/);
const result=image.prepareImage(root,{grant:'fixture',tempRoot:root},invoke);assert.equal(result.imageId,'sha256:'+'a'.repeat(64));assert.equal(result.builds,1);assert.ok(result.buildWallMs>=0);assert.ok(calls.some(x=>x.join(' ')==='compose build'));
});
test('cleanup refuses a foreign Docker label and removes only an exact owned project',()=>{
const project='mg197-a-abcdef123456',id='123456789abc';let removed=false,label='foreign';
const fake=(args)=>{if(args[0]==='ps')return removed?'':id;if(args[0]==='network')return '';if(args[0]==='container'&&args[1]==='inspect')return label;if(args[0]==='container'&&args[1]==='rm'){removed=true;return '';}throw Error('unexpected command');};
// The injectable executor exercises ownership checks without touching Docker.
assert.throws(()=>docker.cleanupProject({project},fake),/foreign/);assert.equal(removed,false);
label=project;assert.deepEqual(docker.cleanupProject({project},fake).removed,[{kind:'container',id}]);assert.equal(removed,true);
});
function imageFixture(t){
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
return {root,receipt};
}
test('missing receipt and missing image refuse without permitting a gate',t=>{
const {root,receipt}=imageFixture(t);assert.throws(()=>image.verifyImage(null,root),/provenance receipt/);
assert.throws(()=>image.verifyImage(receipt,root,()=>{throw Error('No such image');}),/No such image/);
assert.throws(()=>image.verifyImage({...receipt,tag:'mosaic-poc-agent:wrong'},root,()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'})),/release identity/);
});
test('a tag changed between supervised steps fails the next boundary and suppresses gate success',async t=>{
const {root,receipt}=imageFixture(t),out=join(root,'out');mkdirSync(out);
let actual=receipt.imageId;const inspect=()=>({Id:actual,Os:'linux',Architecture:'amd64'}),events=[];
const {executePinnedSteps}=await import('../../../scripts/testing/gate-lane.mjs');
await assert.rejects(executePinnedSteps([
{name:'first',cwd:root,argv:[process.execPath,'-e','process.exit(0)']},
{name:'second',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}
],{out,env:process.env,record:r=>events.push(r)}, {receipt,checkout:root,inspect,afterStep:()=>actual='sha256:'+'c'.repeat(64)}),/image ID/);
assert.equal(events.filter(e=>e.kind==='step').length,1);
assert.equal(events.some(e=>e.kind==='terminal'&&e.rc===0),false);
});
test('mutable-base rebuilds with identical files have distinct image IDs; cold and warm counters stay separate',t=>{
const {root}=imageFixture(t);let builds=0,id='sha256:'+'a'.repeat(64);
const fake=args=>{if(args[0]==='compose'){builds++;return 'fixture build';}return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:id,Os:'linux',Architecture:'amd64'});};
const cold=image.prepareImage(root,{grant:'fixture-cold',tempRoot:root},fake);
assert.equal(cold.builds,1);assert.ok(cold.buildWallMs>=0);assert.equal(builds,1);
for(let i=0;i<3;i++)image.verifyImage(cold,root,()=>({Id:id,Os:'linux',Architecture:'amd64'}));
assert.equal(builds,1,'warm verification builds zero images');
id='sha256:'+'c'.repeat(64);const rebuilt=image.prepareImage(root,{grant:'fixture-rebuild',tempRoot:root},fake);
assert.equal(rebuilt.inputDigest,cold.inputDigest);assert.equal(rebuilt.builds,1);assert.equal(builds,2);
assert.throws(()=>image.verifyImage(cold,root,()=>({Id:rebuilt.imageId,Os:'linux',Architecture:'amd64'})),/image ID/);
});
test('warm pinned execution records zero builds and checks the tag again after its final step',async t=>{
const {root,receipt}=imageFixture(t),out=join(root,'out');mkdirSync(out);let id=receipt.imageId;
const {executePinnedSteps}=await import('../../../scripts/testing/gate-lane.mjs');
const options={out,env:process.env,record:()=>{}},inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
const result=await executePinnedSteps([{name:'warm',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}],options,{receipt,checkout:root,inspect});
assert.equal(result.rc,0);assert.equal(result.imageBuilds,0);
await assert.rejects(executePinnedSteps([{name:'last',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}],options,{receipt,checkout:root,inspect,afterStep:()=>id='sha256:'+'c'.repeat(64)}),/image ID/);
});
// These two read-only compatibility probes must reach the installed binary,
// including when the lane's guard is first on PATH. Child fixture env stays
// isolated; passing the real absolute path avoids re-entering that guard.
function installedDocker(){
const file=process.env.MOSAIC_GATE_DOCKER_RECORD;
if(!file)return 'docker';
const binary=JSON.parse(readFileSync(file,'utf8')).docker;
assert.ok(typeof binary==='string'&&isAbsolute(binary),'lane record must name the real absolute Docker binary');
return binary;
}
function composeFixture(t) {
const tempRoot=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(tempRoot,{recursive:true,force:true}));
const checkout=join(tempRoot,'candidate');mkdirSync(checkout);cpSync(new URL('../../../compose.yaml',import.meta.url),join(checkout,'compose.yaml'));
const config=join(tempRoot,'docker-config');mkdirSync(config,{mode:0o700});
const auth=join(tempRoot,'fixture-auth.json');writeFileSync(auth,'{}',{mode:0o600});
return {tempRoot,checkout,project:'mg197-a-abcdef123456',tag:'mosaic-poc-agent:fixture',imageId:'sha256:'+'a'.repeat(64),
env:{PATH:process.env.PATH,HOME:process.env.HOME,DOCKER_CONFIG:config,COMPOSE_PROJECT_NAME:'mg197-a-abcdef123456',
MOSAIC_IMAGE_TAG:'mosaic-poc-agent:fixture',MOSAIC_PROVIDER:'fixture',MOSAIC_MODEL:'fixture',MOSAIC_DATA_ROOT:tempRoot,PI_AUTH_FILE:auth}};
}
test('installed Compose accepts rewritten run argv without starting a container',t=>{
const run=composeFixture(t);
if(docker.writeComposeOverride)docker.writeComposeOverride(run);
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run);
const command=argv.indexOf('run');assert.ok(command>0);
// --help is before SERVICE, so parsing exits before any service operation.
const parsed=spawnSync(installedDocker(),[...argv.slice(0,command+1),'--help',...argv.slice(command+1)],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
assert.equal(parsed.status,0,parsed.stdout+parsed.stderr);
assert.match(parsed.stdout,/Usage:/);
});
test('installed Compose resolves the private override with no build and the pinned image',t=>{
const run=composeFixture(t);assert.equal(typeof docker.writeComposeOverride,'function');
const override=docker.writeComposeOverride(run);
assert.equal(override,join(run.tempRoot,'compose-no-build.yaml'));
assert.equal(statSync(override).mode&0o777,0o600);
assert.equal(docker.writeComposeOverride(run),override,'parallel callers reuse identical private content');
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run),command=argv.indexOf('run');
const resolved=spawnSync(installedDocker(),[...argv.slice(0,command),'config','--format','json'],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
assert.equal(resolved.status,0,resolved.stdout+resolved.stderr);
const service=JSON.parse(resolved.stdout).services['mosaic-agent'];
assert.equal(Object.hasOwn(service,'build'),false);assert.equal(service.image,run.imageId);
t.diagnostic(JSON.stringify({installedComposeConfig:{buildPresent:Object.hasOwn(service,'build'),image:service.image}}));
assert.ok(argv.includes('--no-deps'));assert.equal(argv[argv.indexOf('--pull')+1],'never');
writeFileSync(override,'changed fixture');assert.throws(()=>docker.writeComposeOverride(run),/override/);
unlinkSync(override);const foreign=join(run.tempRoot,'foreign');writeFileSync(foreign,'fixture');symlinkSync(foreign,override);
assert.throws(()=>docker.writeComposeOverride(run),/override/);assert.equal(readFileSync(foreign,'utf8'),'fixture');
});
test('guard owns every Compose file argument and refuses caller configuration overrides',t=>{
const run=composeFixture(t);
for(const args of [['-f','foreign.yaml'],['--file','foreign.yaml'],['--file=foreign.yaml'],['--project-directory','/tmp'],['--build'],['--pull','always'],['--no-build']])
assert.throws(()=>docker.guardDocker(['compose','run',...args,'mosaic-agent'],run),/options not allowed/);
for(const args of [['compose','-f','foreign.yaml','run','mosaic-agent'],['compose','--project-directory','/tmp','run','mosaic-agent']])
assert.throws(()=>docker.guardDocker(args,run),/unowned command/);
});
+124
View File
@@ -0,0 +1,124 @@
import * as fsExtra from 'node:fs';
import * as childModule from 'node:child_process';
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const api=await import('../../../scripts/testing/lane-state.mjs');
function fixture(t){const root=mkdtempSync('/mnt/storage/scratch/gs-');t.after(()=>rmSync(root,{recursive:true,force:true}));return root;}
test('unfinished dead-owner runs quarantine their lane and a recovery record clears only that run',t=>{
assert.equal(typeof api.appendRecord,'function','appendRecord is required');
const root=fixture(t),dir=join(root,'runs','one');
api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:123456789,start:'1',boot:'fixture'}});
assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
api.appendRecord(dir,{kind:'recovered',clean:true});
assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');
});
test('a legacy landing gate beside a lane stays legacy even with the same parent',t=>{
assert.equal(typeof api.appendRecord,'function');
const root=fixture(t);const owner={pid:123,start:'5',boot:'b',state:'S',ppid:1,argv:['node','gate-lane.mjs']};
api.appendRecord(join(root,'runs','one'),{kind:'start',lane:'a',owner});
const procs=[owner,{pid:124,start:'6',boot:'b',state:'S',ppid:1,argv:['node','/fixture/landing.mjs','gate','/fixture/wt','/fixture/logs']}];
const result=api.status(root,{processes:procs});assert.equal(result.a.state,'BUSY');assert.equal(result.b.state,'CLEAR');assert.deepEqual(result.legacy,[{pid:124,start:'6'}]);
});
test('the shell status entry reads scratch receipts and reports legacy processes without mutating old slots',t=>{
const root=fixture(t);const {spawnSync}=requireChild();
const r=spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'status','--state-root',root],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);const state=JSON.parse(r.stdout);assert.equal(state.a.state,'CLEAR');assert.equal(state.b.state,'CLEAR');assert.ok(Array.isArray(state.legacy));
});
function requireChild(){return childModule;}
test('recovery supersedes an unclean terminal without rewriting either receipt',t=>{
const root=fixture(t),dir=join(root,'runs','one');api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:99999999,start:'1',boot:'fixture'}});api.appendRecord(dir,{kind:'terminal',clean:false});api.appendRecord(dir,{kind:'recovered',clean:true});assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.records(dir).length,3);
});
test('the wrapper refuses a held lane lock and preserves its inode',async t=>{
const root=fixture(t),file=join(root,'a.lock');const {spawn,spawnSync}=childModule;
const holder=spawn('flock',['-x',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));
const {statSync}=await import('node:fs');const ino=statSync(file).ino;
t.after(async()=>{holder.stdin.end('\n');await exited;});
const r=spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
assert.equal(r.status,2);assert.match(r.stderr,/lock busy/);assert.equal(statSync(file).ino,ino);
});
test('a held kernel lock without a start receipt is BUSY rather than CLEAR',async t=>{
const root=fixture(t),file=join(root,'a.lock');const holder=childModule.spawn('flock',['-x',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));t.after(async()=>{holder.stdin.end('\n');await exited;});
assert.equal(api.status(root,{processes:[]}).a.state,'BUSY');
});
test('SIGKILL leaves a quarantined run and locked recovery publishes a new clean receipt',async t=>{
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
const child=childModule.spawn(process.execPath,[new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname,'wait',join(temp,'started')],{stdio:['ignore','pipe','pipe']});
const exited=new Promise(r=>child.once('exit',r));await new Promise(r=>child.stdout.once('data',r));
const id='abcdef123456',dir=join(root,'runs',id),docker=join(root,'docker-fixture');writeFileSync(docker,'#!/bin/sh\nexit 0\n',{mode:0o700});
api.appendRecord(dir,{kind:'start',lane:'a',owner:api.identity(child.pid),tempRoot:temp,project:`mg197-a-${id}`,docker});
child.kill('SIGKILL');await exited;assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'recover','--state-root',root,'--run-id',id,'--grant','fixture-recover'],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.records(dir).filter(e=>e.kind==='start').length,1);assert.equal(api.records(dir).filter(e=>e.kind==='recovered').length,1);
});
test('a malformed receipt refuses status and an identity mismatch cannot look live',t=>{
const root=fixture(t),dir=join(root,'runs','one');api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:123,start:'old',boot:'b'}});
assert.equal(api.status(root,{processes:[{pid:123,start:'new',boot:'b',state:'S',ppid:1,argv:['node','owned'] }]}).a.state,'QUARANTINED');
writeFileSync(join(dir,'bad.json'),'{');assert.throws(()=>api.status(root,{processes:[]}),/invalid run receipt/);
});
test('a symlink lock is refused before acquiring or replacing its target',t=>{
const root=fixture(t),target=join(root,'foreign');writeFileSync(target,'untouched');const {symlinkSync}=fsExtra;symlinkSync(target,join(root,'a.lock'));
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
assert.equal(r.status,2);assert.match(r.stderr,/ordinary file/);assert.equal(readFileSync(target,'utf8'),'untouched');
});
test('failed recovery keeps quarantine and never publishes a clean recovery receipt',async t=>{
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
const id='abcdef123456',dir=join(root,'runs',id),docker=join(root,'docker-fixture');writeFileSync(docker,'#!/bin/sh\nexit 1\n',{mode:0o700});
api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:99999999,start:'1',boot:'fixture'},tempRoot:temp,project:`mg197-a-${id}`,docker});
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'recover','--state-root',root,'--run-id',id,'--grant','fixture-recover'],{encoding:'utf8'});
assert.equal(r.status,2);assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');assert.equal(api.records(dir).some(e=>e.kind==='recovered'),false);
});
test('two shared image holders coexist, but exclusive preparation refuses their lock',async t=>{
const root=fixture(t),file=join(root,'image.lock');const holder=childModule.spawn('flock',['-s',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));t.after(async()=>{holder.stdin.end('\n');await exited;});
const entry=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
const shared=childModule.spawnSync('bash',[entry,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
assert.equal(shared.status,2);assert.doesNotMatch(shared.stderr,/lock busy|descriptor|not locked/);
const exclusive=childModule.spawnSync('bash',[entry,'prepare-image','--state-root',root,'--grant','fixture'],{encoding:'utf8'});
assert.equal(exclusive.status,2);assert.match(exclusive.stderr,/lock busy/);
});
const entry=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
const childFixture=new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function until(check){for(let n=0;n<100;n++){if(check())return;await sleep(20);}throw Error('fixture barrier did not become ready');}
test('two simultaneous shell claims have one winner; supervisor retains lock FDs and ordinary children do not',async t=>{
const root=fixture(t),bin=join(root,'bin');fsExtra.mkdirSync(bin);const shim=join(bin,'node');
writeFileSync(shim,`#!/bin/sh\nif [ "$2" = --locked ]; then exec '${process.execPath}' '${childFixture}' lock-probe '${root}'; fi\nexec '${process.execPath}' "$@"\n`,{mode:0o700});
const env={...process.env,PATH:bin+':'+process.env.PATH};
const start=()=>{const p=childModule.spawn('bash',[entry,'run','--state-root',root,'--lane','a','--grant','fixture'],{env,stdio:['ignore','pipe','pipe']});p.done=new Promise(r=>p.once('exit',code=>r(code)));return p;};
const first=start(),second=start();t.after(()=>{first.kill('SIGKILL');second.kill('SIGKILL');});
await until(()=>fsExtra.existsSync(join(root,'ready')));
const all=api.runRecords(root);assert.equal(all.length,1);assert.equal(readFileSync(join(root,'out/fds.log'),'utf8'),'');
const winner=all[0].records.find(r=>r.kind==='start').owner.pid;assert.ok([first.pid,second.pid].includes(winner));
const loser=first.pid===winner?second:first;assert.equal(await loser.done,2);
const ino=fsExtra.statSync(join(root,'a.lock')).ino;
assert.equal(childModule.spawnSync('flock',['-n',join(root,'a.lock'),'true']).status,1);
const won=first.pid===winner?first:second;won.kill('SIGTERM');await won.done;
assert.equal(fsExtra.statSync(join(root,'a.lock')).ino,ino);
});
test('same checkout and a legacy land gate each refuse a claim on an otherwise free lane',async t=>{
const {activeConflicts}=await import('../../../scripts/testing/gate-lane.mjs');
const root=fixture(t),owner={pid:321,start:'1',boot:'test',state:'S',ppid:1,argv:['node','owned']};
api.appendRecord(join(root,'runs','one'),{kind:'start',lane:'a',checkout:'/fixture/candidate',owner});
const o={stateRoot:root,lane:'b',checkout:'/fixture/candidate'};
assert.throws(()=>activeConflicts(o,{processes:[owner]}),/checkout already owned/);
assert.throws(()=>activeConflicts({...o,checkout:'/fixture/other'},{processes:[owner,{pid:322,start:'2',boot:'test',state:'S',ppid:1,argv:['node','/fixture/landing.mjs','gate']}]}),/LEGACY BUSY/);
});
test('recovery kills a proven surviving child, preserves a foreign child and keeps quarantine if interrupted',async t=>{
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
const owned=childModule.spawn(process.execPath,[childFixture,'wait',join(temp,'child')],{cwd:temp,stdio:'ignore'});
const foreign=childModule.spawn(process.execPath,[childFixture,'wait',join(root,'foreign')],{stdio:'ignore'});
const ownedDone=new Promise(r=>owned.once('exit',r)),foreignDone=new Promise(r=>foreign.once('exit',r));t.after(async()=>{owned.kill('SIGKILL');foreign.kill('SIGKILL');await Promise.all([ownedDone,foreignDone]);});
await until(()=>fsExtra.existsSync(join(temp,'child'))&&fsExtra.existsSync(join(root,'foreign')));
const docker=join(root,'docker'),pidFile=join(root,'docker-pid'),id='abcdef123456',dir=join(root,'runs',id);
writeFileSync(docker,`#!/bin/sh\necho $$ > '${pidFile}'\nexec sleep 60\n`,{mode:0o700});
api.appendRecord(dir,{kind:'start',id,stateRoot:root,lane:'a',owner:{pid:99999999,start:'1',boot:'test'},tempRoot:temp,project:`mg197-a-${id}`,docker});
const recovery=childModule.spawn('bash',[entry,'recover','--state-root',root,'--run-id',id,'--grant','fixture'],{stdio:'ignore'});const done=new Promise(r=>recovery.once('exit',r));
await until(()=>fsExtra.existsSync(pidFile));const dockerOwner=api.identity(Number(readFileSync(pidFile,'utf8')));recovery.kill('SIGKILL');await done;
if(api.sameProcess(dockerOwner))process.kill(dockerOwner.pid,'SIGKILL');
await ownedDone;assert.equal(api.sameProcess(api.identity(foreign.pid)),true);
assert.equal(api.records(dir).some(r=>r.kind==='recovered'),false);assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
writeFileSync(docker,'#!/bin/sh\nexit 0\n',{mode:0o700});
const retried=childModule.spawnSync('bash',[entry,'recover','--state-root',root,'--run-id',id,'--grant','fixture-resume'],{encoding:'utf8'});
assert.equal(retried.status,0,retried.stderr);assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.sameProcess(api.identity(foreign.pid)),true);
});
+125
View File
@@ -0,0 +1,125 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { join } from 'node:path';
const api = await import('../../../scripts/testing/gate-lane.mjs').catch(e => {
if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e;
return {};
});
function fixture(t) {
const root = mkdtempSync('/mnt/storage/scratch/gt-');
// Node appends six characters, keeping this root at 30 bytes.
t.after(() => rmSync(root, { recursive: true, force: true }));
const checkout = join(root, 'checkout'); mkdirSync(checkout);
for (const [f,text] of [['package.json','{}'],['RELEASE','0.0.1\n']]) writeFileSync(join(checkout,f),text);
execFileSync('git',['init','-q',checkout]);
execFileSync('git',['-C',checkout,'add','.']);
execFileSync('git',['-C',checkout,'-c','user.name=Fixture','-c','user.email=f@invalid','commit','-qm','fixture']);
symlinkSync('/mnt/storage/src/mosaic-stack/node_modules',join(checkout,'node_modules'));
return {lane:'a',candidate:execFileSync('git',['-C',checkout,'rev-parse','HEAD'],{encoding:'utf8'}).trim(),checkout,out:join(root,'out'),tempRoot:root,stateRoot:join(root,'state'),grant:'fixture-grant',hostFacts:{unprivilegedPortStart:1024,ephemeral:[32768,60999],listeners:[]}};
}
test('preflight refuses unsafe lane roots, missing grants and live port 9 before side effects', t => {
assert.equal(typeof api.validateRun,'function','validateRun is required');
const f=fixture(t);
assert.equal(api.validateRun(f).lane,'a');
for(const [patch,reason] of [[{lane:'c'},/lane/],[{grant:''},/grant/],[{tempRoot:'/tmp'},/tmp/],[{tempRoot:join(f.tempRoot,'deeper')},/30|missing/],[{candidate:'a'.repeat(40)},/candidate/],[{hostFacts:{...f.hostFacts,listeners:[{address:'0.0.0.0',port:9}]}},/port.9/],[{hostFacts:{...f.hostFacts,unprivilegedPortStart:0}},/privileged/]]) assert.throws(()=>api.validateRun({...f,...patch}),reason);
const link=join(f.tempRoot,'link');symlinkSync('/tmp',link);assert.throws(()=>api.validateRun({...f,tempRoot:link}),/tmp/);
writeFileSync(join(f.checkout,'RELEASE'),'changed');assert.throws(()=>api.validateRun(f),/dirty/);
});
test('discovery preserves all package and script steps while ignoring non-file shell names', t => {
const f=fixture(t);mkdirSync(join(f.checkout,'packages'));
for(const p of ['z','queue','a']){mkdirSync(join(f.checkout,'packages',p,'tests'),{recursive:true});writeFileSync(join(f.checkout,'packages',p,'tests','one.test.mjs'),'');}
mkdirSync(join(f.checkout,'scripts'));writeFileSync(join(f.checkout,'scripts','test-z.sh'),'');writeFileSync(join(f.checkout,'scripts','test-a.sh'),'');mkdirSync(join(f.checkout,'scripts','test-directory.sh'));
const steps=api.discoverSteps(f.checkout);
assert.deepEqual(steps.map(s=>s.name),['pkg-a','pkg-z','script-a','script-z']);
assert.deepEqual(steps[0].argv,['node','--test','--test-concurrency=2','tests/one.test.mjs']);
});
test('a failed step stops the gate and records its nonzero exit without running the next step',async t=>{
assert.equal(typeof api.executeSteps,'function','executeSteps is required');
const f=fixture(t),events=[],out=f.out;mkdirSync(out);const marker=join(f.tempRoot,'should-not-exist');
const result=await api.executeSteps([{name:'first',cwd:f.checkout,argv:['bash','-c','exit 7']},{name:'second',cwd:f.checkout,argv:['touch',marker]}],{out,env:process.env,record:r=>events.push(r)});
assert.equal(result.rc,7);assert.equal(events.filter(e=>e.kind==='step').length,1);assert.ok(events.find(e=>e.kind==='step').wallMs>=0);
const {existsSync}=await import('node:fs');assert.equal(existsSync(marker),false);
});
test('a cancelled step stops its process group and records failure',async t=>{
const f=fixture(t);mkdirSync(f.out);const controller=new AbortController(),events=[];
const child=new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname;
const result=await api.executeSteps([{name:'child',cwd:f.checkout,argv:[process.execPath,child,'wait',join(f.tempRoot,'started')]}],{out:f.out,env:process.env,record:e=>{events.push(e);if(e.kind==='child')setTimeout(()=>controller.abort(),100);},signal:controller.signal});
assert.notEqual(result.rc,0);const owned=events.find(e=>e.kind==='child').owner;const {sameProcess}=await import('../../../scripts/testing/lane-state.mjs');assert.equal(sameProcess(owned),false);
});
test('step receipts retain pass fail skipped counts and hashes of their actual logs',async t=>{
const f=fixture(t);mkdirSync(f.out);const events=[];
await api.executeSteps([{name:'counts',cwd:f.checkout,argv:[process.execPath,'-e',"console.log('ℹ pass 2\\nℹ fail 0\\nℹ skipped 1')"]}],{out:f.out,env:process.env,record:r=>events.push(r)});
const step=events.find(e=>e.kind==='step');assert.deepEqual(step.counts,{pass:2,fail:0,skipped:1});assert.match(step.logSha256,/^[a-f0-9]{64}$/);
});
test('preflight rejects a real 31-byte root, missing dependency tree, occupied band and missing inventory',t=>{
const f=fixture(t),long=f.tempRoot+'x';mkdirSync(long,{mode:0o700});t.after(()=>rmSync(long,{recursive:true,force:true}));assert.equal(Buffer.byteLength(long),31);
assert.throws(()=>api.validateRun({...f,tempRoot:long}),/30 bytes/);
assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,ephemeral:[60000,62000]}}),/overlaps/);
assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,listeners:[{address:'127.0.0.1',port:61005}]}}),/live listener/);
assert.throws(()=>api.validateRun({...f,hostFacts:{}}),/inventory/);
rmSync(join(f.checkout,'node_modules'));assert.throws(()=>api.validateRun(f),/node_modules/);
});
test('preflight refuses canonical and builder checkouts and an existing output',t=>{
const f=fixture(t);
for(const checkout of ['/mnt/storage/src/mosaic-stack',new URL('../../../',import.meta.url).pathname])assert.throws(()=>api.validateRun({...f,checkout}),/canonical or builder/);
mkdirSync(f.out);assert.throws(()=>api.validateRun(f),/output already exists/);
});
test('both candidates must carry every approved launcher file',t=>{
const f=fixture(t),other=join(f.tempRoot,'second');mkdirSync(other);
for(const checkout of [f.checkout,other]){
const target=join(checkout,'scripts','testing');fsExtra.cpSync(new URL('../../../scripts/testing',import.meta.url),target,{recursive:true});
api.validateTooling(checkout);
}
fsExtra.appendFileSync(join(other,'scripts/testing/lane-state.mjs'),'\n// changed\n');
assert.throws(()=>api.validateTooling(other),/tooling differs/);
fsExtra.rmSync(join(f.checkout,'scripts/testing/lane-fixtures.mjs'));
assert.throws(()=>api.validateTooling(f.checkout),/tooling differs/);
});
test('the 92-byte socket example passes and an over-107-byte pathname refuses',()=>{
const root='/mnt/storage/scratch/gt-ABCDEF';assert.equal(Buffer.byteLength(root),30);
const socket=root+'/mosaic-cli-ABCDEF/data/launches/acme/r0123456789ab/tools.sock';
assert.equal(Buffer.byteLength(socket),92);assert.equal(stateApi.socketPath(socket),socket);
assert.throws(()=>stateApi.socketPath(socket+'x'.repeat(16)),/107 bytes/);
});
test('package/script duplicates stay and every supervised step clears NODE_TEST_CONTEXT',async t=>{
const f=fixture(t);mkdirSync(join(f.checkout,'packages/cli/tests'),{recursive:true});mkdirSync(join(f.checkout,'scripts'));
writeFileSync(join(f.checkout,'packages/cli/tests/repeated.test.mjs'),'');writeFileSync(join(f.checkout,'scripts/test-cli.sh'),'node --test packages/cli/tests/repeated.test.mjs\n');
const steps=api.discoverSteps(f.checkout);assert.deepEqual(steps.map(s=>s.name),['pkg-cli','script-cli']);
const original=process.env.NODE_TEST_CONTEXT;process.env.NODE_TEST_CONTEXT='parent-context';
let env;try{env=api.cleanEnv(f,{project:'fixture'},'/fake/docker');}finally{if(original===undefined)delete process.env.NODE_TEST_CONTEXT;else process.env.NODE_TEST_CONTEXT=original;}
assert.equal(env.NODE_TEST_CONTEXT,undefined);assert.equal(env.HOME,process.env.HOME);
mkdirSync(f.out);const events=[];
for(const s of steps)s.argv=[process.execPath,'-e',"if(process.env.NODE_TEST_CONTEXT)process.exit(9);console.log('cleared')"];
const result=await api.executeSteps(steps,{out:f.out,env,record:e=>events.push(e)});
assert.equal(result.rc,0);assert.equal(events.filter(e=>e.kind==='step').length,2);
});
test('scoped ss loopback and wildcard port 9 listeners refuse',t=>{
const f=fixture(t);
for(const address of ['127.0.0.1%lo','0.0.0.0%lo','::%lo','::ffff:127.0.0.1%lo'])assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,listeners:[{address,port:9}]}}),/port 9/);
});
import * as fsExtra from 'node:fs';
import * as stateApi from '../../../scripts/testing/lane-state.mjs';
test('step children use legacy umask 022 while the supervisor retains 077', async t => {
const f=fixture(t);mkdirSync(f.out,{mode:0o700});const events=[],made=join(f.tempRoot,'step-file');
const original=process.umask(0o077);
try {
const result=await api.executeSteps([{name:'umask',cwd:f.checkout,argv:[process.execPath,'-e',`const fs=require('node:fs');console.log(process.umask().toString(8));fs.writeFileSync(${JSON.stringify(made)},'fixture',{mode:0o666});`]}],{out:f.out,env:process.env,record:r=>events.push(r)});
assert.equal(result.rc,0);assert.equal(process.umask(),0o077);
assert.equal(fsExtra.readFileSync(join(f.out,'umask.log'),'utf8').trim(),'22');
assert.equal(fsExtra.statSync(made).mode&0o777,0o644);
assert.equal(fsExtra.statSync(join(f.out,'umask.log')).mode&0o777,0o600);
} finally {process.umask(original);}
});
test('lane wrapper keeps its state directory and lock files private', t => {
const f=fixture(t),script=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
const result=spawnSync('bash',[script,'recover','--state-root',f.stateRoot,'--run-id','000000000000','--grant','fixture-private-modes'],{encoding:'utf8'});
assert.equal(result.status,2,result.stdout+result.stderr);assert.match(result.stderr,/run not found/);
assert.equal(fsExtra.statSync(f.stateRoot).mode&0o777,0o700);
for(const name of ['a.lock','b.lock','image.lock'])assert.equal(fsExtra.statSync(join(f.stateRoot,name)).mode&0o777,0o600);
});
+15 -1
View File
@@ -15,7 +15,7 @@ export function setup(t, realPin=false){
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
mkdirSync(repo);mkdirSync(bin);
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
const pkg={name:'copy-fixture',version:'1.0.0'};
@@ -32,6 +32,20 @@ export function setup(t, realPin=false){
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
}
// Row 66: a session launched from a pinned copy reads each harness module's files from the copy,
// so every path a module names under its REPO (adapter, mounts, bundle sources) is in COPY_PATHS.
test('a copy carries every file the harness modules read from it',async t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const copy=join(s.copies,s.id),paths=[];
for(const kind of ['pi','claude-code']){
const m=await import(pathToFileURL(join(copy,'packages','plugins','harness',kind,'index.mjs')).href);
// pi's mounts() resolves node_modules, which this fixture copy lacks; its unslop mounts are its
// bundle sources, so the sources stand in for them.
paths.push(m.adapter(),...(kind==='pi'?Object.values(m.bundle().sources):m.mounts({env:{}}).map(x=>x.src)));
}
assert.equal(paths.filter(p=>p.includes('/tools/unslop-hook/')).length,6,paths.join('\n'));
for(const p of paths){assert.ok(p.startsWith(copy+'/'),p);assert.ok(existsSync(p),p);}
});
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
+3 -1
View File
@@ -1,3 +1,5 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
// scripts/stack-release.sh (D1, #1659). Design, the T table and the mutants:
// agents/darkwing/work/queue-162/BUILD.md. Every run uses a temp data root, temp units and stand-in
// systemctl, journalctl and systemd-analyze on PATH; nothing here reads ~/.mosaic-dev or a live unit.
@@ -1784,7 +1786,7 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
}
const TESTER='7499';
const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
const rh=(e,...p)=>join(e.env.HOME,...p);
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
+3 -10
View File
@@ -1,3 +1,5 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
import { test, after } from "node:test";
import assert from "node:assert/strict";
import {
@@ -73,16 +75,7 @@ function closeServer(server) {
// Grab an ephemeral free port, then hand it back immediately so a caller can
// try to bind it themselves (used to prove startServer never opened a socket).
function getFreePort() {
return new Promise((resolvePromise, reject) => {
const probe = createNetServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const port = probe.address().port;
probe.close(() => resolvePromise(port));
});
});
}
function getFreePort() { return leasePort({after:laneAfter}, {closed:true}); }
function runCli(args) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
+28 -2
View File
@@ -68,9 +68,13 @@ so an existing bundle is never overwritten):
(`harnessVersion`), built-in and
typed tools, the actions without a typed tool, the S0 lines, the gate
timeout, and the sha256 of every bundle file and of the gate, extension,
MCP server and tools sources.
MCP server and tools sources, and of the unslop check's files (row 66:
`lists.json`, the checker, and pi's extension or Claude Code's hook
script).
- Claude Code only: `settings.json` (the wrapped PreToolUse hook, matcher
`*`) and `mcp.json` (one stdio server, `mosaic`, serving the typed tools).
`*`, and the two unslop hooks, Stop and UserPromptSubmit, each ending
`; exit 0` so it never blocks) and `mcp.json` (one stdio server, `mosaic`,
serving the typed tools).
`harness` is the route's module from `packages/plugins/harness/`, which the
launcher loaded; the bundle calls it for its own files and names and never
@@ -258,6 +262,28 @@ it never touches the disk and isn't in the environment or argv.
header after it names when the message was sent. A failed read fails the
turn without running the adapter: the sender gets `The turn failed: could
not read the current state: <code>`, and the message is read.
Row 66 (#1559): the unslop check. The runner sets `MOSAIC_UNSLOP_LOG` to
`<run>/unslop.jsonl`, after the harness module's variables (in the run
directory itself, since a thread run has no `<run>/session/`). Pi's second extension and
Claude Code's Stop and UserPromptSubmit hooks (`tools/unslop-hook`) check
each reply against `lists.json` and log `checked` or `flagged` (each
finding names its pattern id, the entry's `source`), `notice_injected`
when the next turn gets the correction notice, and `broken_lists`,
`skipped_broken` or `hook_failed` when there is no verdict. After each
turn the runner reads what the log gained, and the next RESULT it sends
ends with a note naming the flagged replies; a broken check is told
once a run. The whole note, both parts together, is at most 600
characters, and the answer before it is clipped at 32000, so the RESULT
stays under the bus's 32768. A turn that sends no reply (an
incoming RESULT, a landing turn) keeps its note for the next reply, and
so does a send that failed. That RESULT is what `mosaic talk` and
`mosaic follow` print, so the operator sees the note there.
`unslopCounts(path)` reads a log after the session ended: replies
checked, flagged, findings per pattern id, notices, and the first reason
the check broke. The check never blocks a reply (Q84 option B is a later
decision), and the session can write its run directory, so the log is
the session's own report, not the host's evidence.
5. SIGTERM or SIGINT kills the turn's process group, releases the role and
exits 0. The handlers go in before anything else, because pid 1 of a
PID namespace never sees a signal it has no handler for; a stop before
+120 -1
View File
@@ -21,6 +21,12 @@
// no reply and no read. A malformed entry exits 2 before the claim.
// 5. SIGTERM ends the turn, releases the role and exits 0; before the
// claim it exits 0 without claiming.
// 6. The unslop check (row 66): the harness logs each reply's check to
// <run>/unslop.jsonl (MOSAIC_UNSLOP_LOG). After each turn the
// runner reads what the log gained, and the next reply it sends ends
// with a note naming the flagged replies, or, once a run, the broken
// check. That reply is the RESULT `mosaic talk` and `mosaic follow`
// print, so the operator sees it there.
//
// Exit codes: 0 stopped, 20 founder credentials, 21 claim refused, 22 the
// capability or the claim stopped working (ended, revoked), 23 the broker
@@ -120,6 +126,113 @@ function clip(text) {
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
}
// Row 66 (#1559): the unslop check's log, which the harness writes and the runner only reads. Pi's
// second extension and Claude Code's Stop and UserPromptSubmit hooks (tools/unslop-hook) write the
// same events: checked, flagged (with findings, each naming its lists.json source, the pattern id),
// notice_injected, and broken_lists, skipped_broken or hook_failed when there is no verdict. The
// session can write the run directory, so these are its own report, not the host's evidence.
// The log sits in the run directory itself: a thread run has no <run>/session/ (its sessionDir is
// the thread's store), and one log a run keeps each run's counts apart.
export const unslopLog = (runDir) => join(runDir, "unslop.jsonl");
const UNSLOP_BROKEN = new Set(["broken_lists", "skipped_broken", "hook_failed"]);
const UNSLOP_NOTE_MAX = 600;
const cut = (text, n) => (text.length > n ? text.slice(0, n - 3) + "..." : text);
function unslopEvents(text) {
const out = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
try {
const e = JSON.parse(line);
if (e && typeof e === "object" && !Array.isArray(e) && typeof e.ev === "string") out.push(e);
} catch {}
}
return out;
}
const unslopReason = (e) => cut(typeof e.reason === "string" ? e.reason : "no reason given", 300);
// A session's counts from its log, readable after it ended: replies checked (clean or flagged),
// replies flagged, findings per pattern id, correction notices, and the first reason the check
// broke, or null. They are for the later blocking decision (Q84 option B).
export function unslopCounts(path) {
let text = "";
try {
text = readFileSync(path, "utf8");
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
const out = { checked: 0, flagged: 0, patterns: {}, notices: 0, broken: null };
for (const e of unslopEvents(text)) {
if (e.ev === "checked" || e.ev === "flagged") out.checked++;
if (e.ev === "flagged") {
out.flagged++;
for (const f of Array.isArray(e.findings) ? e.findings : []) {
if (typeof f?.source === "string") out.patterns[f.source] = (out.patterns[f.source] ?? 0) + 1;
}
}
if (e.ev === "notice_injected") out.notices++;
if (UNSLOP_BROKEN.has(e.ev) && out.broken === null) out.broken = unslopReason(e);
}
return out;
}
// What the log gained since the last read, held until a reply carries it. read() runs after every
// turn; note() is the text for the next reply, or "", and sent() drops it once that reply went out,
// so a failed send keeps it for the next one. A broken check is told once a run.
export function unslopNotes(path, log = () => {}) {
let offset = 0;
let toldBroken = false;
let flagged = [];
let broken = null;
const fresh = (reason) => {
if (toldBroken || broken !== null) return;
broken = cut(reason, 300);
log(`runner: unslop: the check is broken: ${broken}`);
};
return {
read() {
let text;
try {
text = readFileSync(path, "utf8");
} catch (e) {
if (e.code !== "ENOENT") fresh(`the runner can't read ${path}: ${e.code ?? e.message}`);
return;
}
// Only whole lines: a line the harness is still writing waits for the next read.
const end = text.lastIndexOf("\n") + 1;
if (end <= offset) return;
const events = unslopEvents(text.slice(offset, end));
offset = end;
for (const e of events) {
if (e.ev === "flagged") {
const details = (Array.isArray(e.findings) ? e.findings : []).map((f) => (typeof f?.detail === "string" ? f.detail : "")).filter(Boolean);
flagged.push(details.join("; ") || "no findings given");
log(`runner: unslop: a reply flagged: ${cut(details.join("; "), 300)}`);
}
if (UNSLOP_BROKEN.has(e.ev)) fresh(unslopReason(e));
}
},
// The whole note, both parts and the blank line, is at most UNSLOP_NOTE_MAX, so a clipped body
// plus its note stays under the bus's body limit. The broken part is at most 371 characters
// (the reason is cut at 300), and the flagged part gets what is left.
note() {
const parts = [];
if (broken !== null && !toldBroken) parts.push(`[unslop: the check is broken, so this session's replies go unchecked: ${broken}]`);
if (flagged.length) {
const n = flagged.length;
const tail = ". The session gets a correction notice on its next turn.]";
const room = UNSLOP_NOTE_MAX - 2 - parts.reduce((sum, p) => sum + p.length + 1, 0) - tail.length;
parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, room) + tail);
}
return parts.length ? `\n\n${parts.join("\n")}` : "";
},
sent() {
if (broken !== null) toldBroken = true;
flagged = [];
},
};
}
const readLine = (stream) => new Promise((resolve, reject) => {
let input = "";
stream.setEncoding("utf8");
@@ -274,6 +387,8 @@ export function adapterEnv(session, env) {
MOSAIC_PROVIDER: session.provider ?? "",
MOSAIC_MODEL: session.model,
...session.harnessEnv,
// Row 66: after the module's variables, so the log is always the run's own.
...(session.runDir ? { MOSAIC_UNSLOP_LOG: unslopLog(session.runDir) } : {}),
};
}
@@ -358,6 +473,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
},
});
const turnEnv = adapterEnv(session, env);
const unslop = unslopNotes(unslopLog(runDir), log);
let code = EXIT.stopped;
let failures = 0;
@@ -392,6 +508,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
try { await client.call("usage.begin", { turn: e.decision }); }
catch (err) { log(`runner: usage baseline unavailable: ${err.code ?? "failed"}`); }
const turn = await runTurn({ session, request: `${block}\n${landingRequest(e)}`, n, env: turnEnv, onChild: (c) => (child = c) });
unslop.read();
try { await client.call("usage.end", { turn: e.decision }); }
catch (err) { log(`runner: usage outcome unavailable: ${err.code ?? "failed"}`); }
log(`runner: landing turn ${n} for decision ${e.decision} (${e.task_ref}): ${turn.ok ? "ok" : `failed: ${turn.reason}`}`);
@@ -439,6 +556,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
const turn = unread
? { ok: false, reason: unread }
: await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
unslop.read();
// A failed/stopped harness can still have used tokens. Missing data remains unknown.
try { await client.call("usage.end", { turn: m.id }); }
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
@@ -447,7 +565,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
try {
if (m.class !== "RESULT") {
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
await client.call("message.send", { to: m.from_role, body: clip(body), class: "RESULT", in_reply_to: m.id });
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
unslop.sent();
}
await client.call("message.read", { id: m.id });
} catch (e) {
+15 -2
View File
@@ -7,6 +7,8 @@ import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.m
import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
import { decide } from "../src/gate.mjs";
const UNSLOP_DIR = new URL("../../../tools/unslop-hook/", import.meta.url).pathname;
const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
const mode = (p) => statSync(p).mode & 0o777;
@@ -80,8 +82,11 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
assert.deepEqual(m.skills.map((x) => x.name), ["ms-unslop", "ms-goal", "ms-communications"]);
assert.equal(m.reliesOn.length, 5);
assert.ok(!m.typed.includes("launch"), "row 119: the PM no longer launches");
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools"]);
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools", "unslopCheck", "unslopExtension", "unslopLists"]);
assert.equal(m.sources.piExtension.sha256, sha(SOURCES.piExtension));
// Row 66 (#1559): the unslop check's files, as the session loads them.
for (const [name, file] of [["unslopExtension", "extension.ts"], ["unslopCheck", "unslop-check.js"], ["unslopLists", "lists.json"]])
assert.equal(m.sources[name].sha256, sha(join(UNSLOP_DIR, file)), name);
for (const [name, digest] of Object.entries(m.files)) assert.equal(digest, sha(join(b.dir, "bundle", name)), name);
assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")), JSON.parse(JSON.stringify(m)));
});
@@ -144,7 +149,15 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) =
assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 });
assert.ok(m.builtins.includes("Bash"));
assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__")));
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]);
// Row 66 (#1559): the unslop hooks run after a reply and before the next prompt, and never block.
for (const [event, which] of [["Stop", "stop"], ["UserPromptSubmit", "prompt"]]) {
assert.equal(settings.hooks[event].length, 1, event);
assert.equal(settings.hooks[event][0].matcher, undefined, event);
assert.deepEqual(settings.hooks[event][0].hooks, [{ type: "command", command: `timeout -k 2 10 '${process.execPath}' '${join(UNSLOP_DIR, "claude-hook.js")}' ${which}; exit 0`, timeout: 20 }], event);
}
for (const [name, file] of [["unslopHook", "claude-hook.js"], ["unslopCheck", "unslop-check.js"], ["unslopLists", "lists.json"]])
assert.equal(m.sources[name].sha256, sha(join(UNSLOP_DIR, file)), name);
});
// Row 89 (#1583): the prompt names the built-ins as the session's harness names them.
+60 -4
View File
@@ -1,3 +1,4 @@
import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs';
// The host's claude CLI through adapters/claude with the bundle's hook and
// MCP server, against the scripted Messages API. Scratch CLAUDE_CONFIG_DIR
// and HOME, a dummy key, nonessential traffic off: nothing reaches a real
@@ -10,7 +11,7 @@ import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { buildBundle } from "../src/bundle.mjs";
import { adapterEnv } from "../src/runner.mjs";
import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
const ADAPTER = join(REPO, "adapters", "claude", "adapter.sh");
@@ -18,14 +19,14 @@ const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" })
const CLAUDE = which.status === 0 ? which.stdout.trim() : null;
const skip = CLAUDE ? false : "claude is not on PATH";
async function session(t, script, tools = ["read", "bash"]) {
async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}`) {
const dir = scratch(t);
const workspace = join(dir, "ws");
mkdirSync(workspace);
writeFileSync(join(workspace, "notes.txt"), "inside\n");
writeFileSync(join(dir, "secret.txt"), "outside\n");
if (typeof script === "function") script = script(workspace);
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}` });
const api = await mockAnthropic(t, { script, done });
const resolved = resolvedFor(t, "coder", (d) => {
d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5", "limits.tools": tools };
});
@@ -41,6 +42,9 @@ async function session(t, script, tools = ["read", "bash"]) {
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
// Row 66: the run directory, so the unslop hooks log to <dir>/unslop.jsonl, which the
// runner makes before the first turn.
runDir: dir,
};
const { files, manifest, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", harness: HARNESS["claude-code"], skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "pm" } });
s.bundle = files;
@@ -61,7 +65,7 @@ async function session(t, script, tools = ["read", "bash"]) {
function turn(env, request, cwd, adapter = ADAPTER) {
return new Promise((resolve) => {
const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true });
const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, ...fixtureCacheEnv(env.CLAUDE_CONFIG_DIR), MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true });
let stdout = "";
let stderr = "";
child.stdout.on("data", (b) => (stdout += b));
@@ -303,3 +307,55 @@ test("claude: a missing hook or MCP file refuses before claude starts", { skip }
}
assert.equal(api.requests.length, 0);
});
// Row 66 (#1559): the unslop check's Stop and UserPromptSubmit hooks. The scripted model's answers
// are the session's replies; the requests show what the model was sent on the next turn.
const TELL = "Let me delve into the build. It is a pivotal step.";
const replying = (answers) => {
let n = 0;
return () => answers[n++] ?? "Done.";
};
const notices = (api) => api.requests.filter((r) => r.tools.length).map((r) => r.raw.split("UNSLOP NOTICE").length - 1);
test("claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after", { skip }, async (t) => {
const { dir, api, s, env } = await session(t, [], undefined, replying([TELL, "The build is green.", TELL, "Done."]));
assert.equal(env.MOSAIC_UNSLOP_LOG, unslopLog(dir));
const notes = unslopNotes(unslopLog(dir));
for (const [i, request] of ["status?", "and now?", "and the tests?", "thanks"].entries()) {
const r = await turn(env, `Message ${i + 1} from pm, class REQUEST:\n\n${request}`, s.workspace);
assert.equal(r.code, 0, r.stderr);
notes.read();
// The runner's note for the operator: on each turn whose reply had the tell, and on no other.
assert.equal(/1 reply of this session flagged since the last note: banned word "delve" x1; banned word "pivotal" x1\./.test(notes.note()), i % 2 === 0, `turn ${i + 1}: ${notes.note()}\n${readFileSync(unslopLog(dir), "utf8")}`);
notes.sent();
}
// The request after each tell carries a notice; the one after a clean reply has no new one, and
// a tell after a clean reply is still caught.
assert.deepEqual(notices(api), [0, 1, 1, 2]);
const second = api.requests.filter((r) => r.tools.length)[1].raw;
assert.match(second, /UNSLOP NOTICE \(mechanical style check, not the user speaking\)[^"]*banned word \\"delve\\" x1/);
const counts = unslopCounts(unslopLog(dir));
assert.deepEqual({ ...counts, patterns: Object.keys(counts.patterns).length > 0 }, { checked: 4, flagged: 2, patterns: true, notices: 2, broken: null });
});
test("claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers", { skip }, async (t) => {
const { dir, api, s, env } = await session(t, [], undefined, replying(["The build is green.", TELL]));
writeFileSync(join(dir, "empty-lists.json"), "");
const notes = unslopNotes(unslopLog(dir));
const told = [];
for (const [i, request] of ["status?", "and now?"].entries()) {
const r = await turn({ ...env, UNSLOP_LISTS: join(dir, "empty-lists.json") }, `Message ${i + 1} from pm, class REQUEST:\n\n${request}`, s.workspace);
assert.equal(r.code, 0, r.stderr);
assert.ok(r.stdout.trim().length > 0, "the session answered");
notes.read();
told.push(notes.note());
notes.sent();
}
assert.match(told[0], /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: lists\.json invalid: empty file/);
assert.equal(told[1], "");
assert.deepEqual(notices(api), [0, 0]);
const counts = unslopCounts(unslopLog(dir));
assert.equal(counts.checked, 0);
assert.equal(counts.flagged, 0);
assert.match(counts.broken, /^lists\.json invalid: empty file/);
});
+9
View File
@@ -5,6 +5,9 @@
// FAIL exit 3
// NOMARKER answer without writing the turn marker
// ENV <name> answer "env: <value of name>"
// UNSLOP <json> append each event of the JSON array to $MOSAIC_UNSLOP_LOG, as the harness's
// unslop check does (row 66), and answer "unslop: <count>"
// UNSLOPLONG <json> the same, with an answer of 40000 more characters, over the runner's clip
// anything else answer "echo: <request>"
// Every turn appends its pid to $FAKE_PIDS when set.
@@ -33,6 +36,12 @@ if (body.startsWith("TOOL ")) {
} else if (body.startsWith("ENV ")) {
process.stdout.write(`env: ${process.env[body.slice(4)]}\n`);
done();
} else if (body.startsWith("UNSLOP ") || body.startsWith("UNSLOPLONG ")) {
const long = body.startsWith("UNSLOPLONG ");
const events = JSON.parse(body.slice(long ? 11 : 7));
for (const e of events) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
process.stdout.write(`unslop: ${events.length}${long ? " " + "x".repeat(40000) : ""}\n`);
done();
} else if (body === "NOMARKER") {
process.stdout.write("half an answer");
} else {
+61 -5
View File
@@ -1,20 +1,21 @@
import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs';
// Real pi (the pinned CLI) through adapters/pi with the extension, against
// the scripted Messages API. No real model, no network beyond 127.0.0.1.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildBundle } from "../src/bundle.mjs";
import { adapterEnv } from "../src/runner.mjs";
import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
import { REPLY_CAP } from "../src/tools.mjs";
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
// The chief of staff (row 119) has no write tool; tests that write use the PM.
async function session(t, script, place = (dir) => join(dir, "ws"), instance = "chief") {
async function session(t, script, place = (dir) => join(dir, "ws"), instance = "chief", done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}`) {
const dir = scratch(t);
const workspace = place(dir);
const agentDir = join(dir, "pi-agent");
@@ -22,7 +23,7 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
mkdirSync(agentDir);
writeFileSync(join(workspace, "notes.txt"), "inside\n");
writeFileSync(join(dir, "secret.txt"), "outside\n");
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` });
const api = await mockAnthropic(t, { script, done });
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { probe: { baseUrl: api.url, api: "anthropic-messages", apiKey: "probe-dummy", models: [{ id: "probe-model" }] } } }));
const resolved = resolvedFor(t, instance, (d) => {
d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "probe-model" };
@@ -42,6 +43,9 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
// Row 66: the run directory, so the unslop check logs to <dir>/unslop.jsonl, which the
// runner makes before the first turn.
runDir: dir,
};
const models = { "probe-model": { provider: "probe", model: "probe-model" } };
const { files, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, harness: HARNESS.pi, models, skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "jason" } });
@@ -57,7 +61,7 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
function turn(env, request, cwd) {
return new Promise((resolve) => {
const child = spawn("/bin/sh", [ADAPTER], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"] });
const child = spawn("/bin/sh", [ADAPTER], { cwd, env: { ...env, ...fixtureCacheEnv(env.PI_CODING_AGENT_DIR), MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (b) => (stdout += b));
@@ -263,3 +267,55 @@ test("pi: an extension without its configuration fails pi's start", async (t) =>
assert.equal(api.requests.length, 0);
assert.ok(MOSAIC_POLICY_FILE);
});
// Row 66 (#1559): the unslop check as a second extension. The scripted model's answers are the
// session's replies; the requests show what the model was sent on the next turn.
const TELL = "Let me delve into the build. It is a pivotal step.";
const replying = (answers) => {
let n = 0;
return () => answers[n++] ?? "Done.";
};
const notices = (api) => api.requests.filter((r) => r.tools.length).map((r) => r.raw.split("UNSLOP NOTICE").length - 1);
test("pi: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after", async (t) => {
const { dir, api, s, env } = await session(t, [], undefined, undefined, replying([TELL, "The build is green.", TELL, "Done."]));
assert.equal(env.MOSAIC_UNSLOP_LOG, unslopLog(dir));
const notes = unslopNotes(unslopLog(dir));
for (const [i, request] of ["status?", "and now?", "and the tests?", "thanks"].entries()) {
const r = await turn(env, `Message ${i + 1} from jason, class REQUEST:\n\n${request}`, s.workspace);
assert.equal(r.code, 0, r.stderr);
notes.read();
// The runner's note for the operator: on each turn whose reply had the tell, and on no other.
assert.equal(/1 reply of this session flagged since the last note: banned word "delve" x1; banned word "pivotal" x1\./.test(notes.note()), i % 2 === 0, `turn ${i + 1}: ${notes.note()}\n${readFileSync(unslopLog(dir), "utf8")}`);
notes.sent();
}
// The request after each tell carries a notice; the one after a clean reply has no new one, and
// a tell after a clean reply is still caught.
assert.deepEqual(notices(api), [0, 1, 1, 2]);
const second = api.requests.filter((r) => r.tools.length)[1].raw;
assert.match(second, /UNSLOP NOTICE \(mechanical style check, not the user speaking\)[^"]*banned word \\"delve\\" x1/);
const counts = unslopCounts(unslopLog(dir));
assert.deepEqual({ ...counts, patterns: Object.keys(counts.patterns).length > 0 }, { checked: 4, flagged: 2, patterns: true, notices: 2, broken: null });
});
test("pi: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers", async (t) => {
const { dir, api, s, env } = await session(t, [], undefined, undefined, replying(["The build is green.", TELL]));
writeFileSync(join(dir, "empty-lists.json"), "");
const notes = unslopNotes(unslopLog(dir));
const told = [];
for (const [i, request] of ["status?", "and now?"].entries()) {
const r = await turn({ ...env, UNSLOP_LISTS: join(dir, "empty-lists.json") }, `Message ${i + 1} from jason, class REQUEST:\n\n${request}`, s.workspace);
assert.equal(r.code, 0, r.stderr);
assert.ok(r.stdout.trim().length > 0, "the session answered");
notes.read();
told.push(notes.note());
notes.sent();
}
assert.match(told[0], /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: lists\.json invalid: empty file/);
assert.equal(told[1], "");
assert.deepEqual(notices(api), [0, 0]);
const counts = unslopCounts(unslopLog(dir));
assert.equal(counts.checked, 0);
assert.equal(counts.flagged, 0);
assert.match(counts.broken, /^lists\.json invalid: empty file/);
});
+129 -1
View File
@@ -13,7 +13,7 @@ import { join } from "node:path";
import { Store } from "../../bus/src/store.mjs";
import { Broker, BusError } from "../../bus/src/broker.mjs";
import { serve } from "../../bus/src/server.mjs";
import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest } from "../src/runner.mjs";
import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
import { typedTools } from "../src/tools.mjs";
import { resolvedFor, scratch } from "./helpers.mjs";
@@ -836,3 +836,131 @@ test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn m
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.deepEqual(ctx.inbox.map((m) => m.body.trim()), ["env: xhigh", "half an answer"]);
});
// Row 66 (#1559): the harness's unslop check logs to <run>/unslop.jsonl. The fake adapter's
// UNSLOP body writes the events a real check would; pi-session and claude-session tests run the real
// extension and hooks.
const tell = (detail, source = "ms-unslop:7") => ({ ev: "flagged", clean: false, charsChecked: 40, findings: [{ rule: "word", source, detail, count: 1 }] });
const unslopAsk = (ctx, events, cls = "REQUEST") => ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOP ${JSON.stringify(events)}`, class: cls });
test("row 66 the adapter gets the run's unslop log, and a flagged reply's note rides that reply's RESULT, once", async (t) => {
const ctx = await setup(t, { session: { harnessEnv: { MOSAIC_UNSLOP_LOG: "/elsewhere.jsonl" } } });
ctx.inbox = [];
// A thread run's sessionDir is elsewhere and the run has no session/; the log needs none.
rmSync(join(ctx.runDir, "session"), { recursive: true, force: true });
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
const env = await ask(ctx, "ENV MOSAIC_UNSLOP_LOG");
assert.equal(env.body, `env: ${join(ctx.runDir, "unslop.jsonl")}`);
assert.equal(existsSync(join(ctx.runDir, "session")), false);
unslopAsk(ctx, [{ ev: "checked", clean: true, charsChecked: 10 }, tell('banned word "delve" x1')]);
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.equal(ctx.inbox[1].body, 'unslop: 2\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
const clean = await ask(ctx, "ENV MOSAIC_MODEL");
assert.equal(clean.body, "env: fake-model");
assert.match(r.stderr(), /runner: unslop: a reply flagged: banned word "delve" x1\n/);
});
test("row 66 a turn with no reply holds its note for the next reply; a broken check is told once", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
// A RESULT takes a turn and gets no reply, so its flagged reply waits.
const res = unslopAsk(ctx, [tell('banned word "tapestry" x1', "ms-unslop:7")], "RESULT");
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", res.id));
assert.equal(replies(ctx)(), null);
unslopAsk(ctx, [{ ev: "broken_lists", reason: "lists.json invalid: empty file" }, tell('filler phrase "in order to" x1', "ms-unslop:23")]);
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
assert.equal(ctx.inbox[0].body, "unslop: 2\n\n[unslop: the check is broken, so this session's replies go unchecked: lists.json invalid: empty file]\n" +
'[unslop: 2 replies of this session flagged since the last note: banned word "tapestry" x1; filler phrase "in order to" x1. The session gets a correction notice on its next turn.]');
// The next broken event isn't told again.
unslopAsk(ctx, [{ ev: "skipped_broken", reason: "lists.json invalid: empty file" }]);
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.equal(ctx.inbox[1].body, "unslop: 1");
assert.equal(r.stderr().match(/runner: unslop: the check is broken/g).length, 1);
const counts = unslopCounts(unslopLog(ctx.runDir));
assert.deepEqual(counts, { checked: 2, flagged: 2, patterns: { "ms-unslop:7": 1, "ms-unslop:23": 1 }, notices: 0, broken: "lists.json invalid: empty file" });
});
test("row 66 unslopNotes keeps a note until sent(), reads only whole lines, and unslopCounts reads a log after the session", (t) => {
const dir = scratch(t);
const path = join(dir, "unslop.jsonl");
const notes = unslopNotes(path);
notes.read();
assert.equal(notes.note(), "");
writeFileSync(path, JSON.stringify(tell('banned word "pivotal" x1')) + "\n" + '{"ev":"flagged","findings":[{"detail":"half');
notes.read();
const first = notes.note();
assert.match(first, /1 reply of this session flagged since the last note: banned word "pivotal" x1\./);
// A send that failed calls no sent(), so the note is still there.
assert.equal(notes.note(), first);
notes.sent();
assert.equal(notes.note(), "");
// The half line completes; it is read once, from where the last read stopped.
writeFileSync(path, readFileSync(path, "utf8") + ' x","source":"ms-unslop:9"}]}\nnot json\n[1]\n' + JSON.stringify({ ev: "notice_injected" }) + "\n");
notes.read();
assert.match(notes.note(), /1 reply of this session flagged since the last note: half x\./);
assert.deepEqual(unslopCounts(path), { checked: 2, flagged: 2, patterns: { "ms-unslop:7": 1, "ms-unslop:9": 1 }, notices: 1, broken: null });
assert.deepEqual(unslopCounts(join(dir, "none.jsonl")), { checked: 0, flagged: 0, patterns: {}, notices: 0, broken: null });
// A note is bounded however many findings a reply had.
const many = unslopNotes(join(dir, "many.jsonl"));
writeFileSync(join(dir, "many.jsonl"), Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x")))).join("\n") + "\n");
many.read();
assert.ok(many.note().length <= 600, many.note().length);
assert.match(many.note(), /^\n\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
// Both parts together are bounded too, and a reason the runner makes itself is cut like one the
// log gives: here the log path is a directory with a long name, so the read fails with EISDIR.
const deep = join(dir, "d".repeat(250));
mkdirSync(deep);
const both = unslopNotes(deep);
both.read();
assert.match(both.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: the runner can't read .*\.\.\.\]$/);
assert.equal(both.note().length, 2 + 71 + 300);
const both2 = unslopNotes(join(dir, "both.jsonl"));
writeFileSync(join(dir, "both.jsonl"), [JSON.stringify({ ev: "broken_lists", reason: "b".repeat(400) }), ...Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x"))))].join("\n") + "\n");
both2.read();
assert.equal(both2.note().length, 600);
assert.match(both2.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: b{297}\.\.\.\]\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
});
test("row 66 a clipped answer with both note parts stays under the bus's body limit, and the note under 600", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const events = [{ ev: "hook_failed", reason: "r".repeat(400) }, ...Array.from({ length: 30 }, (_, i) => tell(`banned word "w${i}" x1`.padEnd(60, "x")))];
ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOPLONG ${JSON.stringify(events)}`, class: "REQUEST" });
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
const body = ctx.inbox[0].body;
const at = body.indexOf("\n[answer cut at 32000 characters]");
assert.equal(at, 32000);
const note = body.slice(at + "\n[answer cut at 32000 characters]".length);
assert.ok(note.length <= 600, note.length);
assert.ok(body.length <= 32768, body.length);
assert.match(note, /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: r+\.\.\.\]\n\[unslop: 30 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
});
test("row 66 a refused send keeps the note: it rides the next RESULT the session sends", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
// The broker refuses the session's first RESULT once, as it would a body it can't take.
const request = ctx.broker.request.bind(ctx.broker);
let refused = 0;
ctx.broker.request = (cap, r) => {
if (r.verb === "message.send" && r.args?.class === "RESULT" && refused++ === 0) throw new BusError("invalid-request");
return request(cap, r);
};
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const sent = unslopAsk(ctx, [tell('banned word "delve" x1')]);
await until(() => r.stderr().includes(`runner: reply to ${sent.id} failed: invalid-request\n`));
// The refused message was delivered and isn't read; its reply never reached the bus.
assert.ok(!ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
const next = await ask(ctx, "ENV MOSAIC_MODEL");
assert.equal(next.body, 'env: fake-model\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
assert.equal(ctx.inbox.length, 1);
});
+5
View File
@@ -153,6 +153,11 @@ export returns:
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
how a turn ends and where its transcripts land.
- Row 66: both modules wire the unslop check from `tools/unslop-hook`.
`mounts` adds its three files read-only and `bundle` names them in the
manifest's sources. Pi's `env` loads `extension.ts` as the second entry of
`MOSAIC_EXTENSIONS`; Claude Code's `bundle` adds Stop and UserPromptSubmit
hooks that run `claude-hook.js` and never block.
`plugins.harness` switches harnesses on, and an instance's `harness` and
`fallback.harness` vars each name one. A launch on a harness that isn't
+23 -8
View File
@@ -16,6 +16,13 @@ export const interfaceVersion = 1;
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
const GATE = fileURLToPath(new URL("../../../harness/src/claude-gate.mjs", import.meta.url));
const MCP_SERVER = fileURLToPath(new URL("../../../harness/src/mcp-server.mjs", import.meta.url));
// Row 66 (#1559): the unslop check's hook script, which the Stop and UserPromptSubmit hooks run. It
// logs to MOSAIC_UNSLOP_LOG, which the runner sets, and the runner tells the operator.
const UNSLOP = Object.freeze({
unslopHook: join(REPO, "tools", "unslop-hook", "claude-hook.js"),
unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"),
unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"),
});
// `claude --effort <level>` on 2.1.302 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
// It has no off or minimal.
const LEVELS = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
@@ -69,11 +76,13 @@ export function version({ env }) {
// Its launches spend the business's launch.account (row 126).
export const countsAccount = () => true;
// The pinned binary, when it lives outside /usr (which the sandbox already binds).
// The pinned binary, when it lives outside /usr (which the sandbox already binds), and the unslop
// hook's files.
export function mounts({ env }) {
if (!env.MOSAIC_CLAUDE_BIN) return [];
const unslop = Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's Stop and UserPromptSubmit hooks run" }));
if (!env.MOSAIC_CLAUDE_BIN) return unslop;
const real = realpathSync(env.MOSAIC_CLAUDE_BIN);
return real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }];
return [...(real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }]), ...unslop];
}
// WebSearch and WebFetch are its own, so it serves harness-builtin web.
@@ -83,22 +92,28 @@ export const typedName = (name) => MCP_PREFIX + name;
export const reliesOn = () => RELIES_ON;
export const gateTimeout = () => ({ ...GATE_TIMEOUT });
// settings.json holds the gate hook and mcp.json the typed tools' server. files: the bundle's own
// (policy, tools). write(name, text) puts a file in the bundle directory and returns
// [name, sha256]; quote(path) refuses a single quote.
// settings.json holds the gate hook and the two unslop hooks (row 66), and mcp.json the typed
// tools' server. An unslop hook ends with `; exit 0`, so it never blocks a reply or a prompt.
// files: the bundle's own (policy, tools). write(name, text) puts a file in the bundle directory
// and returns [name, sha256]; quote(path) refuses a single quote.
export function bundle({ dir, files: core, session, write, quote }) {
const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") };
const node = process.execPath;
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(GATE)} ${quote(core.policy)} || exit 2`;
const unslop = (which) => [{ hooks: [{ type: "command", command: `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(UNSLOP.unslopHook)} ${which}; exit 0`, timeout: GATE_TIMEOUT.hook }] }];
const digests = [
write("settings.json", JSON.stringify({
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
hooks: {
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }],
Stop: unslop("stop"),
UserPromptSubmit: unslop("prompt"),
},
}, null, 2) + "\n"),
write("mcp.json", JSON.stringify({
mcpServers: { mosaic: { type: "stdio", command: node, args: [MCP_SERVER, core.tools, session.toolSocket] } },
}, null, 2) + "\n"),
];
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER } };
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER, ...UNSLOP } };
}
export function env({ files, policy, session }) {
+13 -3
View File
@@ -13,6 +13,13 @@ const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
const PACKAGE = "@earendil-works/pi-coding-agent";
const BIN = join(REPO, "node_modules", ".bin", "pi");
const EXTENSION = fileURLToPath(new URL("../../../harness/src/pi-extension.mjs", import.meta.url));
// Row 66 (#1559): the unslop check, loaded as a second extension. It logs to MOSAIC_UNSLOP_LOG,
// which the runner sets, and the runner tells the operator.
const UNSLOP = Object.freeze({
unslopExtension: join(REPO, "tools", "unslop-hook", "extension.ts"),
unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"),
unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"),
});
// `pi --thinking <level>` on 0.85.1 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
const LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
// docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5.
@@ -40,20 +47,23 @@ export function version() {
// Pi sessions run on a provider key, never the Claude account limit.
export const countsAccount = () => false;
export const mounts = () => [{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" }];
export const mounts = () => [
{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" },
...Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's second extension runs" })),
];
// Pi has no web tool of its own, so a granted role under harness-builtin is refused.
export const builtinWeb = () => false;
export const builtins = ({ tools }) => [...tools];
export const typedName = (name) => name;
export const reliesOn = () => RELIES_ON;
export const gateTimeout = () => null;
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION } });
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION, ...UNSLOP } });
// The tool limit is the built-ins plus the typed tools, which the extension serves.
export function env({ policy, tools, session }) {
return {
MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","),
MOSAIC_EXTENSIONS: EXTENSION,
MOSAIC_EXTENSIONS: [EXTENSION, UNSLOP.unslopExtension].join(","),
PI_PROVIDER: session.provider ?? "",
PI_MODEL: session.model,
MOSAIC_THINKING: session.thinking,
+12 -4
View File
@@ -71,9 +71,11 @@ test("row 65 claude-code preflight: a missing credential, then a missing or rela
assert.equal(claude.preflight({ env: { ...token, MOSAIC_CLAUDE_BIN: bin } }), null);
assert.equal(claude.preflight({ env: { ...token, PATH: root } }), null);
assert.equal(claude.version({ env: { MOSAIC_CLAUDE_BIN: bin, PATH: process.env.PATH } }), "2.1.302");
// The pinned binary outside /usr is mounted where the adapter looks for it.
assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin]]);
assert.deepEqual(claude.mounts({ env: {} }), []);
// The pinned binary outside /usr is mounted where the adapter looks for it, and the unslop hook's
// files where the hook commands name them (row 66).
const unslop = ["claude-hook.js", "unslop-check.js", "lists.json"].map(f => [join(REPO, "tools", "unslop-hook", f), undefined]);
assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin], ...unslop]);
assert.deepEqual(claude.mounts({ env: {} }).map(m => [m.src, m.dest]), unslop);
});
// Row 68's claudeVersion precedence, moved here with the version (row 65): the manifest records
@@ -100,7 +102,8 @@ test("row 65 pi preflight passes with the repository's pi, and its version is th
assert.equal(pi.preflight({ env: {} }), null);
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
assert.equal(pi.version({ env: {} }), pkg.dependencies?.["@earendil-works/pi-coding-agent"] ?? pkg.devDependencies?.["@earendil-works/pi-coding-agent"]);
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest), [join(REPO, "node_modules")]);
// Row 66: the unslop extension's files, bound read-only at their own paths.
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest ?? m.src), [join(REPO, "node_modules"), ...["extension.ts", "unslop-check.js", "lists.json"].map(f => join(REPO, "tools", "unslop-hook", f))]);
});
test("row 65 env passes the thinking level to both adapters, and claude-code's bundle writes its hook and MCP files", async t => {
@@ -108,6 +111,8 @@ test("row 65 env passes the thinking level to both adapters, and claude-code's b
const session = { provider: "fixture", model: "m", thinking: "high", toolSocket: "/run/tools.sock" };
const piEnv = pi.env({ policy: { tools: ["read"] }, tools: [{ name: "list_tasks" }], session });
assert.deepEqual([piEnv.MOSAIC_THINKING, piEnv.PI_MODEL, piEnv.MOSAIC_TOOLS], ["high", "m", "read,list_tasks"]);
// Row 66: pi loads the unslop check as its second extension.
assert.deepEqual(piEnv.MOSAIC_EXTENSIONS.split(","), [join(REPO, "packages", "harness", "src", "pi-extension.mjs"), join(REPO, "tools", "unslop-hook", "extension.ts")]);
const dir = scratch(t);
const written = [];
const write = (name, text) => { writeFileSync(join(dir, name), text); written.push(name); return [name, "0".repeat(64)]; };
@@ -117,6 +122,9 @@ test("row 65 env passes the thinking level to both adapters, and claude-code's b
const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
assert.match(settings.hooks.PreToolUse[0].hooks[0].command, /^timeout -k 2 10 .* \|\| exit 2$/);
assert.equal(settings.hooks.PreToolUse[0].hooks[0].timeout, 20);
// Row 66: the unslop hooks can't block: each command ends with `; exit 0`.
assert.match(settings.hooks.Stop[0].hooks[0].command, /^timeout -k 2 10 .*claude-hook\.js' stop; exit 0$/);
assert.match(settings.hooks.UserPromptSubmit[0].hooks[0].command, /^timeout -k 2 10 .*claude-hook\.js' prompt; exit 0$/);
assert.equal(JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8")).mcpServers.mosaic.args.at(-1), "/run/tools.sock");
const claudeEnv = claude.env({ files: out.files, policy: { tools: ["read"], typed: [] }, session });
assert.deepEqual([claudeEnv.MOSAIC_THINKING, claudeEnv.MOSAIC_CLAUDE_SETTINGS, claudeEnv.MOSAIC_CLAUDE_MCP_CONFIG], ["high", out.files.settings, out.files.mcp]);
+6 -1
View File
@@ -93,7 +93,12 @@ test("row 65 a harness module's mounts bind read-only after the adapter, each wi
assert.ok(at(join(root, "a.sh")) < at(modules.src) && at(modules.src) < at(join(root, "bus.sock")));
const fixture = binds({ ...args, harnessMounts: [{ src: join(root, "tool"), why: "a fixture harness's tool" }] });
assert.deepEqual(fixture.find((b) => b.src === join(root, "tool")), { kind: "ro", src: join(root, "tool"), why: "a fixture harness's tool" });
assert.equal(binds(args).length, list.length - 1);
// Row 66 (#1559): pi also mounts the unslop extension's three files, each read-only at its own path.
for (const f of ["extension.ts", "unslop-check.js", "lists.json"]) {
const src = join(REPO, "tools", "unslop-hook", f);
assert.deepEqual(list.find((b) => b.src === src), { kind: "ro", src, why: "the unslop check the session's second extension runs" }, f);
}
assert.equal(binds(args).length, list.length - 4);
});
// Row 121 (#1615): a thread run gets only its own thread's two directories,
+2 -2
View File
@@ -33,7 +33,7 @@ function tmp(t, prefix) {
function fixtureRepo(t) {
const dir = tmp(t, 'webui-copy-');
const repo = join(dir, 'repo');
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
@@ -72,7 +72,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
+3 -2
View File
@@ -1,3 +1,5 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
@@ -205,8 +207,7 @@ test('in preview mode every write answers 403 read-only, from the public name an
});
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
const dead = `http://127.0.0.1:${probe.address().port}`; await close(probe);
const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
const loopback = { host: `127.0.0.1:${port}` };
const inbox = await send(port, { headers: loopback });
+3 -2
View File
@@ -1,3 +1,5 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
@@ -62,8 +64,7 @@ test('serve argv carries only fixture proof path and header; startup explicitly
test('CLI applies proof on the main listener and rejects that header on its tester listener', async t => {
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
writeFileSync(file, VALUE, { mode: 0o600 });
const free = createServer(); free.listen(0, '127.0.0.1'); await once(free, 'listening');
const testerPort = free.address().port; await new Promise(r => free.close(r));
const testerPort = await leasePort({after:laneAfter}, {closed:true});
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-port', String(testerPort), '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
+3 -2
View File
@@ -1,3 +1,5 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, get } from 'node:http';
@@ -97,8 +99,7 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
});
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${probe.address().port}`; await close(probe);
const url = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
const web = await startServer({ port: 0, board: url, timeout: 100 });
try {
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
+3 -9
View File
@@ -1,3 +1,5 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
@@ -45,15 +47,7 @@ function filled(flags, root = REPO) {
// A loopback port with nothing listening, so status's /healthz request
// reaches no real Console.
async function closedPort() {
const server = createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address();
server.close();
await once(server, 'close');
return port;
}
async function closedPort() { return leasePort({after:laneAfter}, {closed:true}); }
function tmp(t) {
const dir = mkdtempSync(join(tmpdir(), 'webui-service-'));
+3 -4
View File
@@ -1,3 +1,5 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
@@ -15,10 +17,7 @@ const get = (port, path) => new Promise((resolve, reject) => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
}); q.on('error', reject); q.end();
});
async function freePort() {
const s = createServer(); s.listen(0, '127.0.0.1'); await once(s, 'listening');
const port = s.address().port; await new Promise(r => s.close(r)); return port;
}
async function freePort() { return leasePort({after:laneAfter}, {closed:true}); }
test('tester-port validates range tunnel main port and requires configured bus before listening', t => {
const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root);
+26 -4
View File
@@ -7,9 +7,13 @@
# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the
# user, home, copy, node, claude and both ports from the args file, which must pass stack-upgrade.sh's
# rules and be a factory file for --business. Every command that runs the stack's code runs as
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself only reads
# systemd, ss, /proc, the account database and file metadata. It changes nothing on the host but the
# message F3 sends and its reply, and it never opens claude-session.env.
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself reads ss,
# /proc, the account database and file metadata, and runs these: systemctl show and is-enabled (F1,
# P11), curl on the Console's loopback healthz (F2), sudo -l (P1, P3), sudo -u jarvis bwrap --version
# and hostcheck.sh --check (P9), and node to parse managed-settings.json (F9). F9 runs node only when
# --node is the pinned path and it and every directory above it are root's with no group or other
# write. The script changes nothing on the host but the message F3 sends and its reply, and it never
# opens claude-session.env.
#
# One line per check, `<id> <path> <mark> <evidence>`, then `count <mark> N` for each mark and a
# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted <ruling>` or
@@ -259,6 +263,7 @@ check_f4() {
check_f6() {
local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v
[[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; }
[[ "$CLAUDE/" != */../* ]] || { ffail F6 claude-pin "--claude $CLAUDE has a .. segment"; return; }
v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)"
if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'"
else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi
@@ -291,10 +296,27 @@ check_f8() {
else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi
}
# Empty when $1 and every directory above it, up to /, are the running user's (root's on the host) with
# no group or other write: case2-unit.sh's root_only rule for --node, checked again before root runs it.
# Otherwise the first path that breaks the rule. A symlink breaks it, because its mode is 777.
not_root_only() {
local p="$1" st
while :; do
st="$(stat -c '%u %a' -- "$ROOT$p" 2>/dev/null)" || { echo "$p (cannot stat)"; return; }
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || { echo "$p (uid ${st% *}, mode ${st#* })"; return; }
[ "$p" = / ] && return
p="$(dirname -- "$p")"
done
}
check_f9() {
local s v
local s v bad
s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing"
[ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; }
# Root runs node here, so it runs only the pinned path, and only when nothing but root can replace it.
[ "$NODE" = "/opt/mosaic-stack/node-$NODE_PIN/bin/node" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: it is not the --node-pin path"; return; }
bad="$(not_root_only "$NODE")"
[ -z "$bad" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: $bad is not root's alone"; return; }
v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable"
if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\""
else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi
+16 -5
View File
@@ -18,9 +18,11 @@
// staged file against the manifest and C2-08's fence, and writes
// <configDir>/personas/<business>/<instance>.md at 0600. It never overwrites.
//
// Output is names, byte counts and sha256, never persona or prompt text. --dry-run writes nothing
// and prints counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool
// or channel from HERMES_WORDS. A fence fault there is counted, not refused.
// Output is names, byte counts and sha256, never persona or prompt text. A key from config.yaml
// prints as a JSON string, so it can't start a line of its own. --dry-run writes nothing and prints
// counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool or channel
// from HERMES_WORDS. It applies C2-08's fence as stage does, prints each persona's fault by its
// reason and then refuses. A SOUL.md over four times the fence in bytes refuses before any count.
//
// Exit codes: 0 ok, 1 refused, 2 usage.
@@ -184,7 +186,12 @@ function privateDir(path, { create }) {
const s = lstatSync(path, { throwIfNoEntry: false });
if (!s) {
if (!create) throw new Refusal(`${path} doesn't exist`);
mkdirSync(path, { mode: 0o700 });
try {
mkdirSync(path, { mode: 0o700 });
} catch (e) {
if (e.code === "EEXIST") throw new Refusal(`${path} was made by something else between the check and mkdir`);
throw e;
}
chmodSync(path, 0o700);
return;
}
@@ -221,10 +228,14 @@ function stage(o, say) {
if (o.dryRun) {
say(`files ${personas.length}`);
const faulted = [];
for (const p of personas) {
const c = counts(p.text);
say(`profile ${p.profile}: lines ${c.lines}, lines starting "# " or "## " ${c.headings}, lines naming a Hermes tool or channel ${c.words}`);
const fault = personaTextFault(p.text);
if (fault) { say(`profile ${p.profile}: persona ${fault}, so C2-08's fence would refuse it`); faulted.push(p.profile); }
}
if (faulted.length) throw new Refusal(`C2-08's fence would refuse the persona of profile ${faulted.join(" and ")}, so stage would write nothing`);
return;
}
@@ -234,7 +245,7 @@ function stage(o, say) {
for (const p of pairs) {
const prompts = config(p);
if (!prompts.length) say(`profile ${p.profile}: no per-channel prompts`);
for (const c of prompts) say(`profile ${p.profile}: per-channel prompt ${c.where} ${c.channel}, ${c.bytes === null ? "not text" : `${c.bytes} bytes`}`);
for (const c of prompts) say(`profile ${p.profile}: per-channel prompt ${JSON.stringify(c.where)} ${JSON.stringify(c.channel)}, ${c.bytes === null ? "not text" : `${c.bytes} bytes`}`);
}
for (const p of personas) {
const fault = personaTextFault(p.text);
+2
View File
@@ -36,6 +36,8 @@ COPY_PATHS=(
packages/queue/src packages/library/src
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
packages/plugins
# The unslop check both harness modules mount and list in the bundle (row 66).
tools/unslop-hook
)
# stack-release.sh sources this file and calls reachable before an activate (D1).
reachable() {
+10 -1
View File
@@ -71,6 +71,8 @@ EOF_PROBE
mkdir -p "$SANDBOX/toolroot/plans"
echo "row one" >"$SANDBOX/toolroot/plans/QUEUE.md"
TOOLS_JSON="{\"roots\":[{\"name\":\"docs\",\"path\":\"$SANDBOX/toolroot\"}],\"maxFileBytes\":4096,\"maxCallsPerTurn\":8}"
export PI_CODING_AGENT_DIR="$SANDBOX/pi-agent"
mkdir -p "$PI_CODING_AGENT_DIR"
PI_COMMON="--mode rpc --no-extensions --no-context-files --no-skills --no-prompt-templates --no-themes --offline --no-session --provider zai --model glm-5.3"
if [ -x "$PI_BIN" ]; then
printf '{"type":"get_state","id":"a"}\n' | MOSAIC_DISCORD_TOOLS="$TOOLS_JSON" timeout 60 "$PI_BIN" $PI_COMMON --no-builtin-tools \
@@ -142,7 +144,14 @@ fi
# A nested `node --test` inherits a parent runner's NODE_TEST_CONTEXT, reports
# to that runner and exits 0 whatever its tests do, so the suite clears it
# (#1508 N13). The planted failing test proves a failure still fails here.
node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
node_tests() {
local lane_concurrency=()
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
lane_concurrency=(--test-concurrency=2)
fi
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
}
mkdir -p "$SANDBOX/planted"
printf '%s\n' 'import { test } from "node:test";' 'import assert from "node:assert/strict";' 'test("planted failure", () => assert.equal(1, 2));' >"$SANDBOX/planted/planted.test.mjs"
NODE_TEST_CONTEXT=child-v8 node_tests "$SANDBOX/planted/" >"$SANDBOX/planted.log" 2>&1
+8 -1
View File
@@ -76,7 +76,14 @@ check "checked-in demo bundles equal a fresh generation" $DEMO_OK
# A nested `node --test` inherits a parent runner's NODE_TEST_CONTEXT, reports
# to that runner and exits 0 whatever its tests do, so the suite clears it
# (#1508 N13). The planted failing test proves a failure still fails here.
node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
node_tests() {
local lane_concurrency=()
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
lane_concurrency=(--test-concurrency=2)
fi
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
}
mkdir -p "$SANDBOX/planted"
printf '%s\n' 'import { test } from "node:test";' 'import assert from "node:assert/strict";' 'test("planted failure", () => assert.equal(1, 2));' >"$SANDBOX/planted/planted.test.mjs"
NODE_TEST_CONTEXT=child-v8 node_tests "$SANDBOX/planted/" >"$SANDBOX/planted.log" 2>&1
+6 -1
View File
@@ -47,7 +47,12 @@ node -e 'const p=require("./packages/queue/package.json"); process.exit(p.depend
check "packages/queue declares no dependencies" $?
# --- the package tests ---
env -u NODE_TEST_CONTEXT node --test --test-reporter=spec packages/queue/tests/ > "$SANDBOX/tests.log" 2>&1
lane_concurrency=()
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || exit 2
lane_concurrency=(--test-concurrency=2)
fi
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" --test-reporter=spec packages/queue/tests/ > "$SANDBOX/tests.log" 2>&1
rc=$?
grep -E '^ℹ (tests|pass|fail) ' "$SANDBOX/tests.log"
if [ "$rc" != 0 ]; then grep -E '^✖|Error' "$SANDBOX/tests.log" | head -40; fi
+97
View File
@@ -0,0 +1,97 @@
# Gate lanes
This tooling is for Sage's granted test gates on dragon-lin. It does not grant
itself a slot. A grant reference is an audit field, not an authentication token.
Until Sage adopts these lanes, the existing shared-slot procedure still applies.
Ask Sage with the lane, candidate SHA and exact command. Get a separate grant
for image preparation, suites, and the whole-slot paired proof. New state is
`/mnt/storage/scratch/mosaic-gate-lanes`. Old slot files are untouched.
Invoke the entry from the builder or canonical repository. The --checkout target
must be a separate clean scratch clone containing identical tooling source:
```bash
scripts/testing/gate-lane.sh run --lane a --candidate FULL_SHA \
--checkout /mnt/storage/scratch/ROW-RUN/wt \
--out /mnt/storage/scratch/ROW-RUN/out --grant SAGE_MESSAGE_ID \
--image-receipt /mnt/storage/scratch/PREP/image-receipt.json
```
Each run creates a private root like `/mnt/storage/scratch/ga-ABCDEF` (30 bytes).
An explicit `--temp-root` must already be private, outside `/tmp`, and no longer
than 30 UTF-8 bytes after resolving symlinks. The sandbox's own `/tmp` and HOME
are unchanged. Host HOME stays real. A listener covering loopback port 9, an
unsafe privileged-port threshold or unreadable host inventory refuses startup.
Lane a leases ports 61000..61127, lane b 61128..61255. Refuse any band overlapping
the host's current ephemeral range or a live listener. Port-0 fixtures with a
held server keep using port 0. Never replace the literal dead Board :9 URLs.
Two Node test files run per lane, including nested shell-suite Node runners.
```bash
scripts/testing/gate-lane.sh status
scripts/testing/gate-lane.sh recover --run-id RUN_NONCE --grant SAGE_MESSAGE_ID
```
Status reports each lane, the image lock, unfinished receipts and unmanaged
test/landing/build processes as LEGACY BUSY. It prints no command lines or
environment values. A dead owner with an unfinished receipt is quarantined even
when flock is free. Recovery takes both lanes and the exclusive image lock.
Unknown process-group, scope or Docker ownership refuses recovery. Do not remove
lock files, old receipts, or another run's resources to clear a refusal.
An image receipt records exact COPY input hashes/modes, image ID, release tag,
base digest and platform. Both concurrent candidates must match those files and
that same image ID. Rebuilding identical files is not equivalent. Warm gates
refuse implicit Docker builds/pulls and recheck the ID at each step and the end.
External same-uid commands can ignore these locks. Sage must exclude other
builders; this is test coordination, not a security boundary against that uid.
Only under an explicit image-build grant:
```bash
scripts/testing/gate-lane.sh prepare-image --lane a --candidate FULL_SHA \
--checkout /mnt/storage/scratch/ROW-PREP/wt \
--out /mnt/storage/scratch/ROW-PREP/out --grant SAGE_MESSAGE_ID
```
Preparation holds both lanes and the exclusive image lock. Its build log,
receipt, count and wall time are separate from warm gate timings. The current
Containerfile uses a mutable FROM tag and apt indexes. A missing base digest
refuses provenance publication. No credential file is copied or printed.
Run records are immutable JSON files published by link after fsync. Every step
has a log, UTC/monotonic times, exit code, pass/fail/skip counts and log hash.
The first failed step stops the gate. Cleanup touches only exact project labels,
recorded process identities and fixture paths. A process leak fails the gate
even when cleanup succeeds. Failed logs and temp roots remain for diagnosis.
A cleanup failure leaves the lane quarantined. GATE_PASSED requires all steps
and cleanup. Required prerequisite skips fail the run; optional skips remain
visible and require acceptance review.
Home metadata is evidence only. Assert only on entries attributable to this
run's nonce or fixture paths. Other sessions normally change ~/.claude, ~/.pi
and ~/.cache, so there is no global unchanged-home assertion.
The row 197 acceptance packet is `agents/herb/work/queue-197/`: two candidate
solo baselines followed by three overlapping pairs, lane assignment swapped in
pair two. Keep later paired results after adoption. Six green gates establish
the initial acceptance bar, not a reliable estimate of rare flakes.
The preflight unit fixture uses the canonical checkout's existing node_modules
at `/mnt/storage/src/mosaic-stack/node_modules` through the required plain
symlink. It does not install dependencies. Unit run roots use the `gt-` prefix;
real lane roots use `ga-` or `gb-`. Resource helper tests inside a lane lease only
that lane's band through MOSAIC_GATE_STATE, including their child test files.
The exhaustion probe temporarily narrows its process-local band to a port it
already leased, so it never reserves the whole shared band.
`closed` call-site options are accepted for compatibility; every dynamic lease
reserves an unbound endpoint. Listener fixtures decide when to bind and close.
Fixture child caches put TMPDIR under `tmp/` next to `jiti/` and `cache/`.
Outside a lane, the resource helper file uses lane a with the shared default
state `/mnt/storage/scratch/mosaic-gate-lanes`. It leaves stable lock files in
place. A single-file run therefore respects a live lane-a gate's closed leases.
Its unrelated-session probe bounds both polling children at five seconds.
+227
View File
@@ -0,0 +1,227 @@
import { createHash } from 'node:crypto';
import { realpathSync, existsSync, statSync, readdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { shortRoot, checkHost, hostFacts, refuse, within, SCRATCH, identity } from './lane-state.mjs';
const BUILDER=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
const git=(root,...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
export function validateRun(o){
if(!['a','b'].includes(o.lane))refuse('invalid lane');
if(typeof o.grant!=='string'||!o.grant.trim())refuse('missing grant reference');
for(const key of ['checkout','out','stateRoot'])if(typeof o[key]!=='string'||!o[key])refuse(`missing ${key}`);
const tempRoot=shortRoot(o.tempRoot);
const checkout=realpathSync(o.checkout);
if(checkout===BUILDER||checkout==='/mnt/storage/src/mosaic-stack'||!within(SCRATCH,checkout))refuse('canonical or builder checkout');
if(!/^[a-f0-9]{40}$/.test(o.candidate)||git(checkout,'rev-parse','HEAD')!==o.candidate)refuse('wrong candidate SHA');
const changed=git(checkout,'status','--porcelain','--untracked-files=normal').split('\n').filter(x=>x&&x!=='?? node_modules');
if(changed.length)refuse('dirty checkout');
if(!existsSync(join(checkout,'node_modules'))||!statSync(join(checkout,'node_modules')).isDirectory())refuse('missing node_modules');
const out=resolve(o.out);if(existsSync(out))refuse('output already exists');
if(!within(dirname(checkout),out)||!within(SCRATCH,realpathSync(dirname(out))))refuse('output escapes run scratch');
const stateRoot=resolve(o.stateRoot);if(!within(SCRATCH,stateRoot))refuse('state outside scratch');
checkHost(o.lane,o.hostFacts??hostFacts());
return {...o,tempRoot,checkout,out,stateRoot};
}
export function validateTooling(checkout){
for(const name of readdirSync(join(BUILDER,'scripts/testing')).filter(n=>/\.(mjs|sh)$/.test(n))){
const candidate=join(checkout,'scripts/testing',name),launcher=join(BUILDER,'scripts/testing',name);
if(!existsSync(candidate)||!readFileSync(candidate).equals(readFileSync(launcher)))refuse('candidate tooling differs from launcher');
}
}
export function discoverSteps(checkout,concurrency=2){
if(concurrency!==2)refuse('test concurrency must be 2');
const steps=[];
for(const p of readdirSync(join(checkout,'packages')).sort())if(p!=='queue'&&existsSync(join(checkout,'packages',p,'tests'))&&statSync(join(checkout,'packages',p,'tests')).isDirectory())steps.push({name:`pkg-${p}`,cwd:join(checkout,'packages',p),argv:['node','--test','--test-concurrency=2',...readdirSync(join(checkout,'packages',p,'tests')).filter(n=>n.endsWith('.test.mjs')).sort().map(n=>`tests/${n}`)]});
for(const f of readdirSync(join(checkout,'scripts')).sort())if(/^test-.+\.sh$/.test(f)&&statSync(join(checkout,'scripts',f)).isFile())steps.push({name:`script-${f.slice(5,-3)}`,cwd:checkout,argv:['bash',`scripts/${f}`]});
return steps;
}
export async function executeSteps(steps,{out,env,record,onChild=()=>{},signal}){
const {openSync,closeSync}=await import('node:fs');const {spawn}=await import('node:child_process');
for(const step of steps){
if(signal?.aborted)return {rc:130};
const begin=performance.now(),startMs=Date.now();const fd=openSync(join(out,`${step.name}.log`),'wx',0o600);
// Keep the supervisor private while matching the legacy test environment.
const child=spawn('/bin/bash',['-c','umask 022; exec "$@"','gate-step',...step.argv],{cwd:step.cwd,env,detached:true,stdio:['ignore',fd,fd]});closeSync(fd);
onChild(child);record({kind:'child',group:child.pid,owner:identity(child.pid)});
let escalation;
const abort=()=>{try{process.kill(-child.pid,'SIGTERM');}catch{};escalation=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL');}catch{}},3000);escalation.unref();};
signal?.addEventListener('abort',abort,{once:true});
const rc=await new Promise(resolve=>{child.once('error',()=>resolve(127));child.once('close',(code,sig)=>resolve(signal?.aborted?130:(code??(sig?128:1))));});
clearTimeout(escalation);signal?.removeEventListener('abort',abort);onChild(null);
const bytes=readFileSync(join(out,`${step.name}.log`)),text=bytes.toString('utf8');
const counts={};for(const name of ['pass','fail','skipped']){const m=text.match(new RegExp('^(?:ℹ |# )'+name+' (\\d+)','m'));if(m)counts[name]=Number(m[1]);}
if(counts.pass===undefined){const summaries=[...text.matchAll(/(\d+) passed, (\d+) failed/g)];const last=summaries.at(-1);if(last){counts.pass=Number(last[1]);counts.fail=Number(last[2]);}}
const requiredSkips=text.split('\n').filter(line=>/^skip\b/i.test(line)&&/(docker|daemon|sandbox|systemd|credential)/i.test(line));
record({kind:'step',name:step.name,startMs,endMs:Date.now(),wallMs:performance.now()-begin,rc,counts,requiredSkips,logSha256:createHash('sha256').update(bytes).digest('hex')});
if(rc!==0||requiredSkips.length)return {rc:rc||2};
}
return {rc:0};
}
export async function executePinnedSteps(steps,options,{receipt,checkout,inspect,afterStep=()=>{}}){
for(const step of steps){
verifyImage(receipt,checkout,inspect);
const result=await executeSteps([step],options);afterStep(step);
if(result.rc){verifyImage(receipt,checkout,inspect);return {...result,imageBuilds:0};}
}
verifyImage(receipt,checkout,inspect);return {rc:0,imageBuilds:0};
}
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { status, appendRecord, runRecords, processInventory, sameProcess, nonce } from './lane-state.mjs';
import { verifyImage, imageFingerprint, inspectImage, prepareImage } from './lane-image.mjs';
import { cleanupProject } from './lane-docker.mjs';
import { homeMetadata, attributeEntries, socketInventory } from './lane-evidence.mjs';
function options(argv){
const result={verb:'run',stateRoot:'/mnt/storage/scratch/mosaic-gate-lanes',lane:'a',exclusive:false};
const names={'--state-root':'stateRoot','--lane':'lane','--candidate':'candidate','--checkout':'checkout','--out':'out','--temp-root':'tempRoot','--grant':'grant','--image-receipt':'imageReceipt','--run-id':'runId'};
const args=[...argv];if(args[0]&&!args[0].startsWith('--'))result.verb=args.shift();
if(!['run','status','recover','prepare-image','lock-dir'].includes(result.verb))refuse('unknown verb');
while(args.length){const key=args.shift();if(key==='--exclusive'){result.exclusive=true;continue;}if(!names[key]||!args.length)refuse('invalid or missing option');result[names[key]]=args.shift();}
return result;
}
function stateDirectory(path,create=false){
const root=resolve(path);if(!within(SCRATCH,root)||root===SCRATCH)refuse('state must be private scratch');
if(create){const parent=realpathSync(dirname(root));if(!within(SCRATCH,parent))refuse('state parent escapes scratch');mkdirSync(root,{recursive:true,mode:0o700});}
if(existsSync(root)){const st=lstatSync(root);if(st.isSymbolicLink()||!st.isDirectory()||st.uid!==process.getuid()||(st.mode&0o077))refuse('state is not a private owned directory');
for(const n of ['a.lock','b.lock','image.lock'])if(existsSync(join(root,n))&&!lstatSync(join(root,n)).isFile())refuse('lock is not an ordinary file');
}
return root;
}
export function verifyLocks(o){
for(const [fd,name] of o.exclusive?[[3,'a.lock'],[4,'b.lock'],[5,'image.lock']]:[[3,`${o.lane}.lock`],[5,'image.lock']]){
let opened,path;try{opened=fstatSync(fd);path=lstatSync(join(o.stateRoot,name));}catch{refuse('lock descriptors missing');}
if(!opened.isFile()||!path.isFile()||opened.ino!==path.ino||opened.dev!==path.dev||path.uid!==process.getuid())refuse('lock file replaced');
// A separate open must fail while our shared/exclusive lock is held.
const r=spawnSync('flock',['-n','-x',join(o.stateRoot,name),'true']);if(r.status!==1)refuse('lock descriptor is not locked');
}
}
export function activeConflicts(o,observations={}){
const current=status(o.stateRoot,{...observations,ignoreLocks:o.exclusive?['a','b','image']:[o.lane,'image']});
if(current.legacy.length)refuse('LEGACY BUSY: unmanaged gate, test or Docker process');
for(const lane of o.exclusive?['a','b']:[o.lane])if(current[lane].state!=='CLEAR')refuse(`${lane} ${current[lane].state}`);
for(const r of runRecords(o.stateRoot)){const start=r.records.find(e=>e.kind==='start');if(!r.records.some(e=>['terminal','recovered'].includes(e.kind)&&e.clean)&&start?.checkout===o.checkout)refuse('checkout already owned');}
}
export function cleanEnv(o,run,docker){
const kept=['PATH','HOME','USER','LOGNAME','SHELL','LANG','LC_ALL','LC_CTYPE','TZ','TERM','XDG_RUNTIME_DIR','DBUS_SESSION_BUS_ADDRESS','DOCKER_HOST','CHROMIUM'];
const env=Object.fromEntries(kept.filter(k=>typeof process.env[k]==='string').map(k=>[k,process.env[k]]));
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json')});
env.PATH=`${join(o.out,'bin')}:${env.PATH??'/usr/bin:/bin'}`;
return env;
}
function fixtureProcesses(run){
const all=processInventory(),ancestors=new Set([process.pid]);let parent=process.pid;
while(parent){const p=all.find(p=>p.pid===parent);if(!p||ancestors.has(p.ppid))break;ancestors.add(p.ppid);parent=p.ppid;}
return all.filter(p=>{
if(ancestors.has(p.pid)||p.state==='Z')return false;
let cwd;try{cwd=readlinkSync(`/proc/${p.pid}/cwd`);}catch{}
return p.argv.some(a=>typeof a==='string'&&(a===run.tempRoot||a.startsWith(run.tempRoot+'/')))||cwd&&(within(run.tempRoot,cwd)||run.checkout&&within(run.checkout,cwd));
});
}
async function cleanProcesses(run,record){
const before=fixtureProcesses(run);
if(run.stateRoot&&run.id){
const procs=processInventory(),known=new Set(before.map(p=>p.pid));
const {records}=await import('./lane-state.mjs');
for(const child of records(join(run.stateRoot,'runs',run.id)).filter(e=>e.kind==='child')){
const members=procs.filter(p=>p.group===child.group&&p.state!=='Z');
if(members.length&&sameProcess(child.owner,procs.find(p=>p.pid===child.owner?.pid))){
try{process.kill(-child.group,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}
}else if(members.some(p=>!known.has(p.pid)))refuse('unproven orphan process-group ownership');
}
}
record({kind:'leftover-processes',objects:before.map(({pid,start,boot})=>({pid,start,boot}))});
for(const p of before){if(!sameProcess(p))continue;let raw;try{raw=readFileSync(`/proc/${p.pid}/cgroup`,'utf8');}catch(e){if(e.code==='ENOENT')continue;throw e;}const scope=raw.trim().split('/').find(x=>/^mosaic-chat-[a-z0-9]+\.scope$/.test(x));
if(scope){const shown=execFileSync('systemctl',['--user','show',scope,'-p','InvocationID','--value'],{encoding:'utf8'}).trim();if(!/^[a-f0-9]{32}$/.test(shown))refuse('scope identity unknown');record({kind:'scope-cleanup',unit:scope,invocation:shown});execFileSync('systemctl',['--user','kill','--signal=SIGKILL',scope],{stdio:'pipe'});}
if(sameProcess(p))try{process.kill(p.pid,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}
}
for(let n=0;n<30;n++){if(!fixtureProcesses(run).some(p=>p.state!=='Z'))return before.length===0;await new Promise(r=>setTimeout(r,100));}
refuse('owned fixture process remains');
}
async function recoverRun(o){
if(!o.grant)refuse('missing recovery grant');
if(!/^[a-f0-9]{12}$/.test(o.runId??''))refuse('invalid run ID');
const r=runRecords(o.stateRoot).find(r=>r.id===o.runId);if(!r)refuse('run not found');
const start=r.records.find(x=>x.kind==='start');if(start?.mode==='prepare-image'&&status(o.stateRoot).legacy.length)refuse('image builder still active or unknown');if(!start||sameProcess(start.owner))refuse('run owner live or unknown');
if(r.records.some(x=>['terminal','recovered'].includes(x.kind)&&x.clean))refuse('run already clean');
shortRoot(start.tempRoot);const record=e=>appendRecord(r.dir,e);
const procs=processInventory(),known=new Set(fixtureProcesses(start).map(p=>p.pid));
for(const event of r.records.filter(e=>e.kind==='child')){
const members=procs.filter(p=>p.group===event.group&&p.state!=='Z');
if(members.length&&!sameProcess(event.owner,procs.find(p=>p.pid===event.owner?.pid))&&members.some(p=>!known.has(p.pid)))refuse('orphan process-group ownership unknown');
if(members.length&&sameProcess(event.owner)){process.kill(-event.group,'SIGKILL');}
}
await cleanProcesses(start,record);cleanupProject(start,start.docker);
appendRecord(r.dir,{kind:'recovered',clean:true,grant:o.grant,owner:identity()});return {recovered:r.id};
}
async function runGate(input){
for(const key of ['checkout','out','candidate','grant','imageReceipt'])if(!input[key])refuse(`missing ${key}`);
const automatic=!input.tempRoot,tempRoot=input.tempRoot??mkdtempSync(`/mnt/storage/scratch/g${input.lane}-`);
let o,receipt,docker;
try{
o=validateRun({...input,tempRoot});
validateTooling(o.checkout);
activeConflicts(o);
receipt=JSON.parse(readFileSync(o.imageReceipt,'utf8'));verifyImage(receipt,o.checkout);
docker=execFileSync('which',['docker'],{encoding:'utf8'}).trim();
}catch(e){if(automatic)rmdirSync(tempRoot);throw e;}
const id=nonce(),run={kind:'start',id,stateRoot:o.stateRoot,lane:o.lane,exclusive:o.exclusive,owner:identity(),grant:o.grant,candidate:o.candidate,checkout:o.checkout,tempRoot:o.tempRoot,out:o.out,project:`mg197-${o.lane}-${id}`,tag:receipt.tag,imageId:receipt.imageId,docker};
const dir=join(o.stateRoot,'runs',id);appendRecord(dir,run);const record=e=>{appendRecord(dir,e);if(e.kind==='step')process.stdout.write(`${e.name} rc=${e.rc} wall_ms=${Math.round(e.wallMs)} pass=${e.counts.pass??'-'} fail=${e.counts.fail??'-'} skipped=${e.counts.skipped??'-'}\n`);};
const begin=performance.now(),startMs=Date.now();let rc=1,clean=false,current=null;
const aborter=new AbortController();const stop=()=>aborter.abort();for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.on(signal,stop);
try{
mkdirSync(o.out,{mode:0o700});mkdirSync(join(o.out,'bin'),{mode:0o700});
writeFileSync(join(o.out,'docker.json'),JSON.stringify({...run,docker}),{mode:0o600,flag:'wx'});
const guard=join(BUILDER,'scripts/testing/lane-docker.mjs');
// Launch the guard directly so it validates every real Docker invocation.
writeFileSync(join(o.out,'bin','docker'),`#!/bin/sh\nexec '${process.execPath.replaceAll("'","'\\''")}' '${guard.replaceAll("'","'\\''")}' "$@"\n`,{mode:0o700,flag:'wx'});
const steps=discoverSteps(o.checkout);record({kind:'plan',steps});
const before=homeMetadata(process.env.HOME);record({kind:'home-before',entries:before});
const env=cleanEnv(o,run,docker);
const result=await executePinnedSteps(steps,{out:o.out,env,record,onChild:c=>current=c,signal:aborter.signal},{receipt,checkout:o.checkout,afterStep:()=>record({kind:'sockets',entries:socketInventory(o.tempRoot)})});
rc=result.rc;
const after=homeMetadata(process.env.HOME);record({kind:'home-after',entries:after});
const prior=new Map(before.map(e=>[e.path,JSON.stringify(e)]));const changed=after.filter(e=>prior.get(e.path)!==JSON.stringify(e));
if(attributeEntries(changed,{...run,nonce:id,fixturePaths:[]}).owned.length)refuse('lane-attributed real HOME write');
if(git(o.checkout,'diff','--name-only'))refuse('tracked checkout changed during gate');
const noLeaks=await cleanProcesses(run,record);cleanupProject(run,docker);clean=true;if(!noLeaks&&rc===0)rc=1;
}catch(e){record({kind:'failure',message:e.message});process.stderr.write(`${e.message}\n`);rc=rc||1;
try{await cleanProcesses(run,record);cleanupProject(run,docker);clean=true;}catch(error){record({kind:'cleanup-failed',message:error.message});}
}finally{
if(current){try{process.kill(-current.pid,'SIGKILL');}catch{}}
for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.off(signal,stop);
record({kind:'terminal',clean,rc,startMs,endMs:Date.now(),wallMs:performance.now()-begin,imageBuilds:0});
}
if(rc===0&&clean)process.stdout.write('GATE_PASSED\n');return rc;
}
async function main(argv){
const locked=argv[0]==='--locked';if(locked)argv=argv.slice(1);
if(argv[0]==='lock-dir'){
const o=options(argv.slice(1));if(!o.grant)refuse('missing grant reference');if(!['a','b'].includes(o.lane))refuse('invalid lane');process.stdout.write(stateDirectory(o.stateRoot,true)+'\n');return 0;
}
const o=options(argv);o.stateRoot=stateDirectory(o.stateRoot);
if(o.verb==='status'){process.stdout.write(JSON.stringify(status(o.stateRoot))+'\n');return 0;}
if(!locked)refuse('use gate-lane.sh for locked operations');
if(['recover','prepare-image'].includes(o.verb))o.exclusive=true;
verifyLocks(o);
if(o.verb==='recover'){process.stdout.write(JSON.stringify(await recoverRun(o))+'\n');return 0;}
if(o.verb==='prepare-image'){
for(const key of ['checkout','out','candidate','grant'])if(!o[key])refuse(`missing ${key}`);
const checked=validateRun({...o,tempRoot:o.tempRoot??mkdtempSync('/mnt/storage/scratch/ga-')});activeConflicts(checked);
const id=nonce(),dir=join(checked.stateRoot,'runs',id),docker=execFileSync('which',['docker'],{encoding:'utf8'}).trim();
appendRecord(dir,{kind:'start',mode:'prepare-image',lane:checked.lane,exclusive:true,owner:identity(),grant:checked.grant,candidate:checked.candidate,checkout:checked.checkout,tempRoot:checked.tempRoot,out:checked.out,project:`mg197-${checked.lane}-${id}`,docker});
mkdirSync(checked.out,{mode:0o700});
try{
const receipt=prepareImage(checked.checkout,checked);
writeFileSync(join(checked.out,'image-receipt.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600,flag:'wx'});
appendRecord(dir,{kind:'terminal',clean:true,rc:0,imageId:receipt.imageId,builds:1,wallMs:receipt.buildWallMs});
process.stdout.write(JSON.stringify({imageReceipt:join(checked.out,'image-receipt.json'),imageId:receipt.imageId,buildWallMs:receipt.buildWallMs})+'\n');return 0;
}catch(e){appendRecord(dir,{kind:'terminal',clean:false,rc:1,message:e.message});throw e;}
}
return runGate(o);
}
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main(process.argv.slice(2)).then(rc=>process.exitCode=rc).catch(e=>{process.stderr.write(`${e.message}\n`);process.exitCode=2;});
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
entry="$(cd "$(dirname "$0")" && pwd -P)/gate-lane.mjs"
verb="${1:-run}"
if [ "$verb" = status ]; then exec node "$entry" "$@"; fi
state="$(node "$entry" lock-dir "$@")"
lane=a
exclusive=0
args=("$@")
for ((i=0;i<${#args[@]};i++)); do
case "${args[i]}" in
--lane) lane="${args[i+1]:-}" ;;
--exclusive) exclusive=1 ;;
esac
done
case "$verb" in recover|prepare-image) exclusive=1;; esac
case "$lane" in a|b) ;; *) echo 'gate-lane: refused: invalid lane' >&2; exit 2;; esac
if [ "$exclusive" = 1 ]; then
exec 3>>"$state/a.lock" 4>>"$state/b.lock" 5>>"$state/image.lock"
flock -n 3 && flock -n 4 && flock -n -x 5 || { echo 'gate-lane: refused: lane or image lock busy' >&2; exit 2; }
else
exec 3>>"$state/$lane.lock" 5>>"$state/image.lock"
flock -n 3 && flock -n -s 5 || { echo 'gate-lane: refused: lane or image lock busy' >&2; exit 2; }
fi
exec node "$entry" --locked "$@"
+60
View File
@@ -0,0 +1,60 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, linkSync, unlinkSync, openSync, closeSync, fstatSync, constants } from 'node:fs';
import { join, isAbsolute } from 'node:path';
import { pathToFileURL } from 'node:url';
import { refuse, shortRoot, nonce } from './lane-state.mjs';
function composeFiles(run){
if(!/^sha256:[a-f0-9]{64}$/.test(run.imageId??''))refuse('invalid pinned image ID');
if(typeof run.checkout!=='string'||!isAbsolute(run.checkout))refuse('invalid candidate checkout');
return [join(run.checkout,'compose.yaml'),join(shortRoot(run.tempRoot),'compose-no-build.yaml')];
}
export function writeComposeOverride(run){
const [,file]=composeFiles(run);
const text=`services:\n mosaic-agent:\n build: !reset null\n image: ${JSON.stringify(run.imageId)}\n`;
// Publish a complete file without replacing another guard's override.
const pending=file+'.'+nonce();
try{
writeFileSync(pending,text,{mode:0o600,flag:'wx'});
try{linkSync(pending,file);}catch(e){if(e.code!=='EEXIST')throw e;}
}finally{try{unlinkSync(pending);}catch(e){if(e.code!=='ENOENT')throw e;}}
let fd;
try{
fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
const st=fstatSync(fd);
if(!st.isFile()||st.uid!==process.getuid()||(st.mode&0o777)!==0o600||st.size!==Buffer.byteLength(text)||readFileSync(fd,'utf8')!==text)
refuse('unsafe or changed Compose override');
}catch{refuse('unsafe or changed Compose override');}
finally{if(fd!==undefined)closeSync(fd);}
return file;
}
export function guardDocker(argv,run){
if(argv[0]==='info'&&argv.length===1)return argv;
if(argv[0]==='image'&&argv[1]==='inspect'&&argv[2]===run.tag&&argv.length===3)return argv;
if(argv[0]==='compose'&&argv[1]==='run'){
const service=argv.indexOf('mosaic-agent',2);
if(service<0||argv.slice(2,service).some(a=>!['--rm','-T','--no-deps','--interactive','-i'].includes(a)))refuse('Docker Compose run options not allowed');
const [base,override]=composeFiles(run);
return ['compose','-f',base,'-f',override,'run','--pull','never','--no-deps',...argv.slice(2)];
}
refuse('Docker mutation or unowned command');
}
export function cleanupProject(run,docker='docker'){
if(!/^mg197-[ab]-[a-f0-9]{12}$/.test(run.project))refuse('invalid Docker ownership project');
const invoke=typeof docker==='function'?docker:(args)=>execFileSync(docker,args,{encoding:'utf8',stdio:['ignore','pipe','pipe']});
const removed=[];
for(const [kind,list,filter] of [['container',['ps','-aq'],'com.docker.compose.project'],['network',['network','ls','-q'],'com.docker.compose.project']]){
const ids=invoke([...list,'--filter',`label=${filter}=${run.project}`]).trim().split(/\s+/).filter(Boolean);
for(const id of ids){if(!/^[a-f0-9]{12,64}$/.test(id))refuse('invalid Docker resource ID');
const label=invoke([kind,'inspect',id,'--format',`{{index .${kind==='container'?'Config.':''}Labels "${filter}"}}`]).trim();
if(label!==run.project)refuse('foreign Docker resource');
invoke(kind==='container'?['container','rm','-f',id]:['network','rm',id]);removed.push({kind,id});
}
const left=invoke([...list,'--filter',`label=${filter}=${run.project}`]).trim();if(left)refuse('Docker cleanup incomplete');
}
return {clean:true,removed};
}
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){
try{const run=JSON.parse(readFileSync(process.env.MOSAIC_GATE_DOCKER_RECORD,'utf8'));if(process.env.COMPOSE_PROJECT_NAME!==run.project)refuse('Compose project mismatch');
const argv=guardDocker(process.argv.slice(2),run);if(argv[0]==='compose')writeComposeOverride(run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
}catch(e){process.stderr.write(`${e.message}\n`);process.exit(2);}
}
+37
View File
@@ -0,0 +1,37 @@
import { readdirSync, lstatSync, readlinkSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { within, refuse } from './lane-state.mjs';
export function attributeEntries(metadata,run){
const result={owned:[],unrelated:[],unknown:[]};
for(const e of metadata){const owns=e.path.includes(run.nonce)||e.link&&within(run.tempRoot,e.link)||(run.fixturePaths??[]).some(path=>within(path,e.path)||e.link&&within(path,e.link));result[owns?'owned':'unrelated'].push(e);}
return result;
}
export function homeMetadata(home){
const out=[];
for(const name of ['.claude','.pi','.cache']){
const root=join(home,name);if(!existsSync(root))continue;
// Two levels are evidence only. Never open or hash file contents.
const walk=(dir,depth)=>{for(const d of readdirSync(dir,{withFileTypes:true})){const path=join(dir,d.name);try{const st=lstatSync(path);out.push({path,size:st.size,mtime:st.mtimeMs,mode:st.mode,link:st.isSymbolicLink()?readlinkSync(path):null});if(d.isDirectory()&&depth)walk(path,depth-1);}catch{}}};
walk(root,1);
}
return out;
}
export function socketInventory(root){const out=[];const walk=dir=>{for(const d of readdirSync(dir,{withFileTypes:true})){const p=join(dir,d.name);if(d.isDirectory())walk(p);else if(d.isSocket()){const bytes=Buffer.byteLength(p);out.push({path:p,bytes});if(bytes>107)refuse('socket path exceeds 107 bytes');}}};walk(root);return out;}
export function summarizePairs(solos,pairs){
const byCandidate=new Map(solos.map(r=>[r.candidate,r]));
if(solos.length!==2||byCandidate.size!==2||solos.some(r=>!Number.isFinite(r.wallMs)||r.wallMs<=0))refuse('solo timing invalid');
if(pairs.length!==3)refuse('proof needs three pairs');
let first;
return pairs.map((pair,index)=>{
if(pair.length!==2||pair[0].candidate===pair[1].candidate||new Set(pair.map(r=>r.lane)).size!==2||pair.some(r=>!['a','b'].includes(r.lane)))refuse('pair needs different candidates and lanes');
for(const run of pair){
if(!run.clean||run.rc!==0||!byCandidate.has(run.candidate))refuse('proof gate failed');
if(!Number.isFinite(run.startMs)||!Number.isFinite(run.endMs)||run.endMs<=run.startMs)refuse('paired timing invalid');
}
if(index===0)first=new Map(pair.map(r=>[r.candidate,r.lane]));
if(index===1&&pair.some(r=>first.get(r.candidate)===r.lane))refuse('second pair must swap lanes');
if(Math.max(...pair.map(r=>r.startMs))>=Math.min(...pair.map(r=>r.endMs)))refuse('paired gates did not overlap');
const makespan=Math.max(...pair.map(r=>r.endMs))-Math.min(...pair.map(r=>r.startMs));
return {makespanMs:makespan,runs:pair.map(r=>({candidate:r.candidate,wallMs:r.endMs-r.startMs,slowdown:(r.endMs-r.startMs)/byCandidate.get(r.candidate).wallMs})),throughput:pair.reduce((n,r)=>n+byCandidate.get(r.candidate).wallMs,0)/makespan};
});
}
+33
View File
@@ -0,0 +1,33 @@
// Imported only by tests. The port holder lives until the fixture closes.
import { spawn } from 'node:child_process';
import { mkdirSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { createServer } from 'node:net';
import { once } from 'node:events';
import { BANDS, within, SCRATCH, refuse } from './lane-state.mjs';
async function hold(file){
const child=spawn('flock',['-n','-E','73',file,'bash','-c','printf "held\\n"; IFS= read -r release'],{stdio:['pipe','pipe','pipe']});
let err='';child.stderr.on('data',c=>err+=c);const exited=once(child,'exit');
const ok=await new Promise((resolve,reject)=>{child.stdout.once('data',()=>resolve(true));child.once('exit',code=>code===73?resolve(false):reject(Error(`port lock holder exited ${code}: ${err}`)));child.once('error',reject);});
if(!ok)return null;
return async()=>{child.stdin.end('\n');await exited;};
}
async function probe(port){const s=createServer();try{await new Promise((resolve,reject)=>{s.once('error',reject);s.listen(port,'127.0.0.1',resolve);});const found=s.address().port;await new Promise(r=>s.close(r));return found;}catch(e){if(s.listening)await new Promise(r=>s.close(r));if(e.code==='EADDRINUSE')return null;throw e;}}
export async function leasePort(t,{lane=process.env.MOSAIC_GATE_LANE,stateRoot=process.env.MOSAIC_GATE_STATE}={}){
if(!lane){const port=await probe(0);return port;}
if(!BANDS[lane]||!stateRoot)refuse('invalid port lane state');
const state=realpathSync(stateRoot);if(!within(SCRATCH,state))refuse('port state outside scratch');
const dir=join(state,'ports',lane);mkdirSync(dir,{recursive:true,mode:0o700});
const [lo,hi]=BANDS[lane];
for(let port=lo;port<=hi;port++){
const release=await hold(join(dir,`${port}.lock`));if(!release)continue;
let actual;try{actual=await probe(port);}catch(e){await release();throw e;}
if(!actual){await release();continue;}
t.after(release);return port;
}
refuse('lane port band exhausted');
}
export function fixtureCacheEnv(root){
mkdirSync(root,{recursive:true,mode:0o700});const dir=realpathSync(root);for(const n of ['tmp','jiti','cache'])mkdirSync(join(dir,n),{recursive:true,mode:0o700});
return {TMPDIR:join(dir,'tmp'),JITI_FS_CACHE:join(dir,'jiti'),XDG_CACHE_HOME:join(dir,'cache')};
}
+44
View File
@@ -0,0 +1,44 @@
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, lstatSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { refuse } from './lane-state.mjs';
export function imageFingerprint(checkout){
const dockerfile=readFileSync(join(checkout,'Containerfile'),'utf8');
const copies=dockerfile.split('\n').filter(l=>/^\s*(COPY|ADD)\s/i.test(l)).map(l=>l.trim());
const expected=['COPY package.json package-lock.json ./','COPY contracts /opt/mosaic/contracts','COPY src /opt/mosaic/src','COPY adapters /opt/mosaic/adapters'];
if(JSON.stringify(copies)!==JSON.stringify(expected))refuse('image COPY inputs changed; manifest review required');
const hash=createHash('sha256');
const visit=rel=>{const path=join(checkout,rel),st=lstatSync(path);if(st.isSymbolicLink())refuse('image input is a symlink');hash.update(JSON.stringify([rel,st.mode&0o777,st.isDirectory()?'d':'f'])+'\n');if(st.isDirectory())for(const n of readdirSync(path).sort())visit(`${rel}/${n}`);else if(st.isFile())hash.update(readFileSync(path));else refuse('unsupported image input');};
for(const rel of ['Containerfile','package.json','package-lock.json','contracts','src','adapters'])visit(rel);
return hash.digest('hex');
}
export function inspectImage(tag,docker=process.env.MOSAIC_GATE_REAL_DOCKER??'docker'){
return JSON.parse(execFileSync(docker,['image','inspect',tag,'--format','{{json .}}'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}));
}
export function verifyImage(receipt,checkout,inspect=inspectImage){
if(!receipt||!/^sha256:[a-f0-9]{64}$/.test(receipt.imageId??'')||!/^.+@sha256:[a-f0-9]{64}$/.test(receipt.baseDigest??'')||!/^linux\/[a-z0-9_]+$/.test(receipt.platform??'')||!/^mosaic-poc-agent:[A-Za-z0-9_.-]+$/.test(receipt.tag??''))refuse('image provenance receipt invalid');
if(receipt.inputDigest!==imageFingerprint(checkout))refuse('image inputs differ');
const actual=inspect(receipt.tag);
if(actual.Id!==receipt.imageId)refuse('pinned image ID differs');
if(`${actual.Os}/${actual.Architecture}`!==receipt.platform)refuse('image platform differs');
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
if(pkg.dependencies?.['@earendil-works/pi-coding-agent']&&receipt.tag!==`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`)refuse('image tag differs from release identity');
return receipt;
}
// Called by the CLI only while both lanes and the image lock are exclusive.
export function prepareImage(checkout,{grant,tempRoot,out},invoke){
if(!grant)refuse('missing image preparation grant');
const inputDigest=imageFingerprint(checkout);
const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();
if(!/^\d+\.\d+\.\d+$/.test(release))refuse('invalid image release');
const tag=`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`;
const run=invoke??((args)=>execFileSync('docker',args,{cwd:checkout,encoding:'utf8',stdio:['ignore','pipe','pipe'],env:{PATH:process.env.PATH,HOME:process.env.HOME,...(process.env.DOCKER_HOST?{DOCKER_HOST:process.env.DOCKER_HOST}:{}),COMPOSE_PROJECT_NAME:'mg197-image-prepare',MOSAIC_IMAGE_TAG:tag,MOSAIC_DATA_ROOT:join(tempRoot,'data'),MOSAIC_PROVIDER:'fixture',MOSAIC_MODEL:'fixture',PI_AUTH_FILE:'/dev/null'}}));
const start=performance.now();let buildOutput;try{buildOutput=run(['compose','build']);}catch(e){if(out)writeFileSync(join(out,'image-build.log'),String(e.stdout??'')+String(e.stderr??''),{mode:0o600,flag:'wx'});throw e;}if(out)writeFileSync(join(out,'image-build.log'),buildOutput,{mode:0o600,flag:'wx'});const buildWallMs=performance.now()-start;
if(inputDigest!==imageFingerprint(checkout))refuse('image inputs changed while building');
const actual=JSON.parse(run(['image','inspect',tag,'--format','{{json .}}']));
const base=JSON.parse(run(['image','inspect','node:24-bookworm-slim','--format','{{json .}}']));
const receipt={inputDigest,imageId:actual.Id,tag,baseDigest:base.RepoDigests?.find(x=>/@sha256:[a-f0-9]{64}$/.test(x)),platform:`${actual.Os}/${actual.Architecture}`,grant,builds:1,buildWallMs,at:new Date().toISOString()};
verifyImage(receipt,checkout,()=>actual);return receipt;
}
+77
View File
@@ -0,0 +1,77 @@
// Scratch-only lane ownership. No live Mosaic state is read or written.
import { realpathSync, statSync, lstatSync, existsSync, readdirSync, readFileSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync, linkSync, unlinkSync, renameSync } from 'node:fs';
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
export const SCRATCH='/mnt/storage/scratch';
export const BANDS={a:[61000,61127],b:[61128,61255]};
export function refuse(message){throw new Error(`gate-lane: refused: ${message}`);}
export function within(root,path){const r=relative(root,path);return r===''||(!r.startsWith('..')&&!isAbsolute(r));}
export function shortRoot(path){
let real;try{real=realpathSync(path);}catch{refuse('temporary root missing');}
if(real==='/tmp'||real.startsWith('/tmp/'))refuse('temporary root under /tmp');
if(Buffer.byteLength(real)>30)refuse('temporary root exceeds 30 bytes');
if(!within(SCRATCH,real)||!statSync(real).isDirectory()||statSync(real).uid!==process.getuid()||(statSync(real).mode&0o077))refuse('temporary root must be private owned scratch');
return real;
}
export function socketPath(path){if(Buffer.byteLength(path)>107)refuse('socket path exceeds 107 bytes');return path;}
export function hostFacts(){
const range=readFileSync('/proc/sys/net/ipv4/ip_local_port_range','utf8').trim().split(/\s+/).map(Number);
const unprivilegedPortStart=Number(readFileSync('/proc/sys/net/ipv4/ip_unprivileged_port_start','utf8').trim());
const text=execFileSync('ss',['-Hltn'],{encoding:'utf8'});
const listeners=text.trim().split('\n').filter(Boolean).map(line=>{const field=line.trim().split(/\s+/)[3];const m=field?.match(/^(.*):(\d+)$/);if(!m)refuse('unreadable listener inventory');return {address:m[1].replace(/^\[|\]$/g,''),port:Number(m[2])};});
return {ephemeral:range,unprivilegedPortStart,listeners};
}
export function checkHost(lane,facts){
if(!facts||!Array.isArray(facts.listeners)||facts.ephemeral?.length!==2||!facts.ephemeral.every(Number.isInteger))refuse('host inventory unavailable');
if(!Number.isInteger(facts.unprivilegedPortStart)||facts.unprivilegedPortStart<=9)refuse('unprivileged port range includes port 9');
if(facts.listeners.some(x=>x.port===9&&['127.0.0.1','0.0.0.0','*','::','::ffff:127.0.0.1'].includes(x.address.replace(/%[^%]+$/, ''))))refuse('live port 9 listener');
const [lo,hi]=BANDS[lane];const [first,last]=facts.ephemeral;
if(lo<=last&&hi>=first)refuse('lane port band overlaps ephemeral range');
if(facts.listeners.some(x=>x.port>=lo&&x.port<=hi))refuse('lane port band has a live listener');
}
export function identity(pid=process.pid){try{const s=readFileSync(`/proc/${pid}/stat`,'utf8');const f=s.slice(s.lastIndexOf(')')+2).split(' ');return {pid:Number(pid),start:f[19],boot:readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim(),state:f[0]};}catch{return null;}}
export function sameProcess(a,b=identity(a?.pid)){return !!a&&!!b&&a.pid===b.pid&&a.start===b.start&&a.boot===b.boot&&b.state!=='Z';}
export function nonce(){return randomBytes(6).toString('hex');}
export function appendRecord(dir,record){
const publishedDir=dir;if(record.kind==='start'){if(existsSync(dir))refuse('run already exists');dir=dir+'.starting';}
mkdirSync(dir,{recursive:true,mode:0o700});
const file=join(dir,`${Date.now()}-${nonce()}-${record.kind}.json`);
const pending=file+'.pending',fd=openSync(pending,'wx',0o600);try{writeSync(fd,JSON.stringify({...record,at:new Date().toISOString()})+'\n');fsyncSync(fd);}finally{closeSync(fd);}
linkSync(pending,file);unlinkSync(pending);if(record.kind==='start')renameSync(dir,publishedDir);return join(publishedDir,file.split('/').at(-1));
}
export function records(dir){if(!existsSync(dir))return [];return readdirSync(dir).filter(n=>n.endsWith('.json')).sort().map(n=>{try{return JSON.parse(readFileSync(join(dir,n),'utf8'));}catch{refuse(`invalid run receipt ${n}`);}});}
export function runRecords(stateRoot){
const runs=join(stateRoot,'runs');if(!existsSync(runs))return [];
return readdirSync(runs).filter(id=>!id.endsWith('.starting')).map(id=>({id,dir:join(runs,id),records:records(join(runs,id))}));
}
export function processInventory(){
const out=[];
for(const pid of readdirSync('/proc').filter(n=>/^\d+$/.test(n))){try{const argv=readFileSync(`/proc/${pid}/cmdline`,'utf8').split('\0').filter(Boolean);const raw=readFileSync(`/proc/${pid}/stat`,'utf8');const f=raw.slice(raw.lastIndexOf(')')+2).split(' ');out.push({...identity(Number(pid)),ppid:Number(f[1]),group:Number(f[2]),argv});}catch{}}
return out;
}
export function status(stateRoot,observations={}){
const procs=observations.processes??processInventory();const runs=runRecords(stateRoot);
const answer={a:{state:'CLEAR'},b:{state:'CLEAR'},image:'free',legacy:[],at:new Date().toISOString()};
const owned=new Set();
for(const run of runs){const start=run.records.find(x=>x.kind==='start');if(!start)refuse('run receipt has no start');const clean=run.records.some(x=>['terminal','recovered'].includes(x.kind)&&x.clean===true);
if(clean)continue;
const live=procs.find(p=>sameProcess(start.owner,p));const state=live?'BUSY':'QUARANTINED';
for(const lane of start.exclusive?['a','b']:[start.lane]){if(!BANDS[lane])refuse('invalid lane receipt');answer[lane]={state,run:run.id};}
answer.image=start.exclusive?'exclusive':'shared';
if(live){owned.add(live.pid);let changed=true;while(changed){changed=false;for(const p of procs)if(owned.has(p.ppid)&&!owned.has(p.pid)){owned.add(p.pid);changed=true;}}}
}
for(const p of procs){if(p.state==='Z'||p.pid===process.pid||owned.has(p.pid))continue;
const args=p.argv??[];const executable=args[0]?.split('/').at(-1);
const test=(executable==='node'&&args.includes('--test'))||args.some(a=>/^(?:.*\/)?(?:gate\.sh|test-[a-z-]+\.sh)$/.test(a))||(executable==='docker'&&(args.includes('compose')||args.includes('build')))||args.some(a=>a.endsWith('/landing.mjs')&&args.includes('gate'))||args.some(a=>/(?:^|\/)[^/]*mutant[^/]*\.(?:mjs|py|sh)$/.test(a));
if(test)answer.legacy.push({pid:p.pid,start:p.start});
}
for(const lane of ['a','b','image']){
if(observations.ignoreLocks?.includes(lane)||!existsSync(join(stateRoot,`${lane}.lock`)))continue;
const lock=lstatSync(join(stateRoot,`${lane}.lock`));if(!lock.isFile())refuse('lock is not an ordinary file');
const probe=spawnSync('flock',['-n','-x',join(stateRoot,`${lane}.lock`),'true']);
if(probe.status!==0&&probe.status!==1)refuse('cannot inspect lane lock');
if(probe.status===1){if(lane==='image')answer.image=answer.image==='free'?'held':answer.image;else if(answer[lane].state==='CLEAR')answer[lane]={state:'BUSY',run:null};}
}
return answer;
}
+49 -4
View File
@@ -1,6 +1,7 @@
# unslop-hook
Mechanical AI-tell enforcement for pi seats. Anti-drift gate for the writing
Mechanical AI-tell enforcement for pi seats and for managed sessions on
both harnesses (row 66, #1559). Anti-drift gate for the writing
standard in SYSTEM.md / ms-unslop: prose distribution alone decays over long
sessions; this check cannot forget.
@@ -18,7 +19,9 @@ sessions; this check cannot forget.
never a clean verdict).
- `extension.ts`: pi extension. `message_end` checks finalized assistant text
and notifies the operator (TUI/RPC). `before_agent_start` reads the most
recent assistant reply from the session file and, if it carries tells,
recent assistant reply from the session file (walking `getBranch()` from
its end: pi returns the branch root first, and before row 66 the walk
read the session's first reply every turn) and, if it carries tells,
injects a correction notice the model sees on its next turn. `/unslop`
reports session stats. Violation state lives in the session file, so the
injection path survives restart, resume, fork, and reload (an in-memory
@@ -26,6 +29,17 @@ sessions; this check cannot forget.
broken lists.json fails closed: checks stop, `broken_lists` /
`skipped_broken` events log the reason, operator notified once, seat keeps
running.
- `claude-hook.js`: the same check for Claude Code (row 66). A managed
session's `settings.json` runs it twice: `stop` checks each assistant
message of the turn from the transcript and logs `checked` or `flagged`,
and `prompt` (UserPromptSubmit) prints the extension's correction notice
as `additionalContext` when the last reply had tells, once per reply.
Claude Code writes one transcript line per content block, so adjacent
lines with one message id are one reply, keyed by the first line's uuid.
A broken lists.json logs `broken_lists` and checks nothing; any other
fault logs `hook_failed`. It always exits 0 and never blocks.
- Each finding carries `source`, its entry's pattern id in lists.json
(`ms-unslop:7`), so the counts can be read per pattern.
- `test-unslop-check.js`: unit tests with red and green controls.
## Use
@@ -44,6 +58,15 @@ events (loaded / flagged / notice_injected / checked / broken_lists /
skipped_broken) for headless evidence. `UNSLOP_LISTS=<path>` overrides the
lists.json location for both CLI and extension.
In a managed session the runner sets `MOSAIC_UNSLOP_LOG` to
`<run>/unslop.jsonl`, reads it after each turn and adds a short note
to the next reply the session sends, which `mosaic talk` and `mosaic follow`
print. A broken check is told there once a run. `unslopCounts()` in
`packages/harness/src/runner.mjs` reads the counts after the session ends.
The session environment's allowlist doesn't pass `MOSAIC_UNSLOP_HOOK` or
`UNSLOP_LISTS`, so a managed session can't turn the check off or swap its
lists.
## Verified here (2026-08-19)
- Unit suite 24/24 (8 behavioral, 11 loader/CLI, 5 review follow-up), red and
@@ -60,6 +83,18 @@ lists.json location for both CLI and extension.
full TUI trial (notify line, injection, /unslop stats) on session vision-unslop.
- Log evidence in session scratchpad.
## Verified here (2026-10-20, row 66)
- Unit suite 28/28: the `source` field, `replies()` grouping, the Claude
Code hook's stop and prompt paths, its off switch, a broken lists.json at
stop and at prompt, and bad hook input (`hook_failed`, exit 0).
- Managed sessions on both harnesses against the test API
(`packages/harness/tests/pi-session.test.mjs`, `claude-session.test.mjs`):
a reply with a tell gets the notice on the next turn, a clean reply gets
none, the operator note rides the next reply, a broken lists.json fails
closed with the session still answering, and the counts read back after
the session ends.
## Limits
- `/unslop` command not tested headless (print mode has no command surface);
@@ -72,7 +107,15 @@ lists.json location for both CLI and extension.
does not revive checks until the seat restarts. Acceptable for an advisory
gate (review S1).
- The fail-closed operator notification requires a UI. Print-mode sessions
log `skipped_broken` but notify nobody (review S2).
log `skipped_broken` but notify nobody (review S2). Managed sessions are
the exception: the runner tells the operator in the next reply (row 66).
- In a managed session the log sits in the run directory, which the session
can write. The counts are the session's own report, not host evidence.
- The extension's per-reply dedupe is in memory. After a restart the same
reply can get a second notice.
- On the first turn of a new Claude Code session the transcript may not
hold the reply when Stop runs; the hook then checks
`last_assistant_message` and logs an empty `entryId`.
- The word/phrase lists are the mechanical subset of ms-unslop only, keyed to
pattern ids in lists.json. Style judgments (voice, rhythm, structure) stay in
the skill, not the gate.
@@ -80,4 +123,6 @@ lists.json location for both CLI and extension.
## Promotion path
Stack issue (A4): checker shared as the single source for a matching Claude
Code Stop-hook script; lists versioned beside SYSTEM.md contract text.
Code Stop-hook script; lists versioned beside SYSTEM.md contract text. Row 66
did the first part: `claude-hook.js` runs the same checker and lists.
Blocking a reply (Q84 option B) is a later decision.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
"use strict";
// unslop-hook for Claude Code: the managed-session counterpart of extension.ts (row 66, #1559).
// Two hooks in a managed session's settings.json run it (packages/plugins/harness/claude-code),
// and neither blocks: the script always exits 0 and prints nothing on Stop.
//
// node claude-hook.js stop Stop hook. Checks each assistant message with text since the
// turn's prompt and logs checked or flagged for each.
// node claude-hook.js prompt UserPromptSubmit hook. Checks the last assistant reply with text
// and, when it has tells, prints the correction notice as
// additionalContext, once per reply.
//
// Both read the hook input on stdin and the transcript it names. Events go to MOSAIC_UNSLOP_LOG in
// the extension's shapes (checked, flagged, notice_injected, broken_lists), so the runner reads one
// log for either harness and tells the operator. A broken lists.json logs broken_lists and checks
// nothing; there is never a clean verdict without the lists.
// Off: MOSAIC_UNSLOP_HOOK=0. Lists: UNSLOP_LISTS=<path>, as for the checker.
//
// The notice text is the extension's, word for word.
const fs = require("node:fs");
const { checkText } = require("./unslop-check.js");
const LOG = process.env.MOSAIC_UNSLOP_LOG;
const log = (ev) => {
if (!LOG) return;
try { fs.appendFileSync(LOG, JSON.stringify({ ts: Date.now(), ...ev }) + "\n"); } catch {}
};
function entries(path) {
let raw;
try { raw = fs.readFileSync(path, "utf8"); } catch { return []; }
const out = [];
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try { out.push(JSON.parse(line)); } catch {}
}
return out.filter((e) => e && typeof e === "object" && !e.isSidechain);
}
const textOf = (content) => {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
};
// A prompt is a user entry that isn't only tool results.
const isPrompt = (e) => e.type === "user" && e.message?.role === "user"
&& !(Array.isArray(e.message.content) && e.message.content.length > 0 && e.message.content.every((b) => b?.type === "tool_result"));
// The assistant messages with text, in order. Claude Code writes one transcript line per content
// block, so adjacent lines that share a message id are one message. Its id is its first line's
// uuid: a message id alone can repeat across messages (a test API answers one id for all).
function replies(list) {
const out = [];
let last = null;
for (const e of list) {
if (e.type !== "assistant" || e.message?.role !== "assistant") {
last = null;
continue;
}
const msg = e.message.id ?? null;
if (!last || msg === null || last.msg !== msg) out.push((last = { msg, id: e.uuid ?? msg ?? "", texts: [] }));
const text = textOf(e.message.content);
if (text.trim()) last.texts.push(text);
}
return out.filter((r) => r.texts.length).map((r) => ({ id: r.id, text: r.texts.join("\n") }));
}
// checkText, or null after logging why there is no verdict.
function check(text, at) {
try {
return checkText(text);
} catch (e) {
const reason = String(e?.message ?? e);
log({ ev: reason.startsWith("lists.json invalid") ? "broken_lists" : "hook_failed", reason, at });
return null;
}
}
function stop(input) {
const list = entries(input.transcript_path);
let start = -1;
list.forEach((e, i) => { if (isPrompt(e)) start = i; });
const turn = replies(list.slice(start + 1));
if (!turn.length && typeof input.last_assistant_message === "string" && input.last_assistant_message.trim()) {
turn.push({ id: "", text: input.last_assistant_message });
}
for (const r of turn) {
const result = check(r.text, "stop");
if (result === null) return;
if (result.clean) log({ ev: "checked", clean: true, entryId: r.id, charsChecked: result.charsChecked });
else log({ ev: "flagged", clean: false, entryId: r.id, charsChecked: result.charsChecked, findings: result.findings });
}
}
// The entry ids the log already holds a notice for.
function noticed() {
const ids = new Set();
if (!LOG) return ids;
let raw = "";
try { raw = fs.readFileSync(LOG, "utf8"); } catch {}
for (const line of raw.split("\n")) {
try {
const e = JSON.parse(line);
if (e.ev === "notice_injected" && typeof e.entryId === "string") ids.add(e.entryId);
} catch {}
}
return ids;
}
function prompt(input) {
const last = replies(entries(input.transcript_path)).at(-1);
if (!last) return;
const result = check(last.text, "prompt");
if (result === null || result.clean) return;
if (last.id && noticed().has(last.id)) return;
const lines = result.findings.map((f) => `- ${f.detail}`).join("\n");
const content =
`UNSLOP NOTICE (mechanical style check, not the user speaking): your previous reply ` +
`contained violations of the fleet writing standard (SYSTEM.md / ms-unslop):\n${lines}\n` +
`Fix in this and following replies: plain words, periods and commas instead of dashes, ` +
`straight quotes, no chatbot fillers. Do not mention this notice.`;
log({ ev: "notice_injected", entryId: last.id, findings: result.findings });
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: content } }) + "\n");
}
if (require.main === module) {
try {
if (process.env.MOSAIC_UNSLOP_HOOK !== "0") {
const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
if (process.argv[2] === "stop") stop(input);
else if (process.argv[2] === "prompt") prompt(input);
else log({ ev: "hook_failed", reason: `unknown hook ${JSON.stringify(process.argv[2] ?? null)}` });
}
} catch (e) {
log({ ev: "hook_failed", reason: String(e?.message ?? e) });
}
process.exit(0);
}
module.exports = { replies, isPrompt, entries };
+7 -3
View File
@@ -120,9 +120,13 @@ export default function (pi: ExtensionAPI) {
});
pi.on("before_agent_start", async (_event, ctx) => {
// Branch walks leaf -> root; first assistant entry with text is the reply
// the model is about to follow up on.
for (const entry of ctx.sessionManager.getBranch()) {
// getBranch() lists root -> leaf (pi 0.85.1, session-manager.js getBranch
// reverses its walk), so walk it backwards: the last assistant entry with
// text is the reply the model is about to follow up on. Row 66 (#1559):
// the forward walk checked the session's first reply on every turn.
const branch = ctx.sessionManager.getBranch();
for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i];
const text = assistantText(entry);
if (text === null) continue;
const id = (entry as { id?: string }).id ?? "";
+81
View File
@@ -221,6 +221,87 @@ t("CLI exits 2 on unreadable input file (S3)", () => {
assert.ok(r.stderr.includes("cannot read input"), `stderr must name the fault: ${r.stderr}`);
});
t("every finding names its lists.json entry's source, the pattern id (row 66)", () => {
const lists = loadLists();
const sources = new Set([...lists.words, ...lists.phrases, ...lists.punct, ...lists.patterns].map((e) => e.source));
const r = checkText(SLOP + "\nIt's not just fast, but cheap.");
for (const rule of ["word", "phrase", "punct", "pattern"]) assert.ok(r.findings.some((f) => f.rule === rule), `no ${rule} finding: ${JSON.stringify(r.findings)}`);
for (const f of r.findings) assert.ok(sources.has(f.source), `finding without a known source: ${JSON.stringify(f)}`);
assert.strictEqual(r.findings.find((f) => f.detail.includes("delve")).source, "ms-unslop:7");
});
// ── Row 66 (#1559): claude-hook.js, the Claude Code side ───────────────
// The session tests (packages/harness/tests/claude-session.test.mjs) run it under real claude;
// these pin its parts without one.
const HOOK = path.join(__dirname, "claude-hook.js");
const { replies } = require("./claude-hook.js");
const line = (type, id, uuid, content) => ({ type, uuid, message: { id, role: type, content } });
t("claude-hook replies(): adjacent lines of one message join, a repeated message id later is a new reply", () => {
const r = replies([
line("assistant", "m1", "u1", [{ type: "text", text: "one" }]),
line("assistant", "m1", "u2", [{ type: "tool_use", id: "x", name: "Read", input: {} }]),
line("user", undefined, "u3", [{ type: "tool_result", tool_use_id: "x", content: "ok" }]),
line("assistant", "m1", "u4", [{ type: "text", text: "two" }]),
line("assistant", "m1", "u5", [{ type: "text", text: "three" }]),
line("assistant", "m2", "u6", [{ type: "tool_use", id: "y", name: "Read", input: {} }]),
]);
assert.deepStrictEqual(r, [{ id: "u1", text: "one" }, { id: "u4", text: "two\nthree" }]);
});
function hook(which, input, env = {}) {
return spawnSync(process.execPath, [HOOK, which], { input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", env: { PATH: process.env.PATH, ...env } });
}
const events = (f) => (fs.existsSync(f) ? fs.readFileSync(f, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
t("claude-hook: Stop logs the turn's reply flagged with pattern ids, the next prompt gets the notice once, and both exit 0", () => {
const transcript = writeTmp("transcript.jsonl", [
{ type: "user", uuid: "p1", message: { role: "user", content: "status?" } },
line("assistant", "m1", "a1", [{ type: "text", text: "Let me delve into it. A pivotal step." }]),
].map((x) => JSON.stringify(x)).join("\n") + "\n");
const log = tmp("hook-log.jsonl");
const stop = hook("stop", { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: log });
assert.strictEqual(stop.status, 0, stop.stderr);
assert.strictEqual(stop.stdout, "");
const [flagged] = events(log);
assert.strictEqual(flagged.ev, "flagged");
assert.strictEqual(flagged.entryId, "a1");
assert.deepStrictEqual(flagged.findings.map((f) => f.source), ["ms-unslop:7", "ms-unslop:7"]);
const first = hook("prompt", { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: log });
assert.strictEqual(first.status, 0, first.stderr);
const out = JSON.parse(first.stdout).hookSpecificOutput;
assert.strictEqual(out.hookEventName, "UserPromptSubmit");
assert.ok(out.additionalContext.startsWith("UNSLOP NOTICE (mechanical style check, not the user speaking)"), out.additionalContext);
assert.ok(out.additionalContext.includes('banned word "delve" x1'), out.additionalContext);
const again = hook("prompt", { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: log });
assert.strictEqual(again.status, 0);
assert.strictEqual(again.stdout, "", "one notice per reply");
assert.deepStrictEqual(events(log).map((e) => e.ev), ["flagged", "notice_injected"]);
// Off: nothing logged, nothing printed.
const off = tmp("hook-off.jsonl");
const quiet = hook("prompt", { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: off, MOSAIC_UNSLOP_HOOK: "0" });
assert.strictEqual(quiet.status, 0);
assert.strictEqual(quiet.stdout, "");
assert.ok(!fs.existsSync(off));
});
t("claude-hook: a broken lists.json logs broken_lists and no verdict; bad input logs hook_failed; both exit 0", () => {
const transcript = writeTmp("transcript-clean.jsonl", JSON.stringify(line("assistant", "m1", "a1", [{ type: "text", text: "The build is green." }])) + "\n");
const log = tmp("hook-broken.jsonl");
for (const which of ["stop", "prompt"]) {
const r = hook(which, { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: log, UNSLOP_LISTS: writeTmp("hook-empty.json", "") });
assert.strictEqual(r.status, 0, r.stderr);
assert.strictEqual(r.stdout, "");
}
assert.deepStrictEqual(events(log).map((e) => [e.ev, e.at]), [["broken_lists", "stop"], ["broken_lists", "prompt"]]);
assert.ok(events(log).every((e) => /^lists\.json invalid: empty file/.test(e.reason)));
const bad = tmp("hook-bad.jsonl");
const r = hook("stop", "{not json", { MOSAIC_UNSLOP_LOG: bad });
assert.strictEqual(r.status, 0);
assert.deepStrictEqual(events(bad).map((e) => e.ev), ["hook_failed"]);
});
let failed = 0;
for (const [name, ok] of results) { console.log(`${ok ? "PASS" : "FAIL"} ${name}`); if (!ok) failed++; }
console.log(`${results.length - failed}/${results.length} passed`);
+7 -4
View File
@@ -19,6 +19,9 @@
// Provenance note (2026-08-19): the inline lists this file carried before C1
// moved to lists.json unchanged — 16 words, 17 phrases, 3 punct rules, 1 pattern.
// The suite pins those counts; a list edit without a test edit is a drift signal.
//
// Row 66 (#1559): each finding carries its entry's source, the pattern id, so a managed
// session's unslop.jsonl can count the ids a reply hit.
const fs = require("node:fs");
const path = require("node:path");
@@ -125,12 +128,12 @@ function checkText(raw) {
for (const w of lists.words) {
const re = new RegExp("\\b" + escapeRegex(w.value) + "\\b", "gi");
const count = (text.match(re) || []).length;
if (count > 0) findings.push({ rule: "word", detail: `banned word "${w.value}" x${count}`, count });
if (count > 0) findings.push({ rule: "word", source: w.source, detail: `banned word "${w.value}" x${count}`, count });
}
for (const p of lists.phrases) {
const count = phraseHay.split(p.value).length - 1;
if (count > 0) findings.push({ rule: "phrase", detail: `phrase "${p.value}" x${count}`, count });
if (count > 0) findings.push({ rule: "phrase", source: p.source, detail: `phrase "${p.value}" x${count}`, count });
}
// Density-gated punctuation. The deliberate divergence from SYSTEM.md's
@@ -140,14 +143,14 @@ function checkText(raw) {
for (const ch of pc.chars) count += text.split(ch).length - 1;
if (count < lists.thresholds.punctMinCount) continue;
if (count / Math.max(text.length, 1) * 1000 < lists.thresholds.punctDensityPer1k) continue;
findings.push({ rule: "punct", detail: `${pc.label} x${count} (density-gated)`, count });
findings.push({ rule: "punct", source: pc.source, detail: `${pc.label} x${count} (density-gated)`, count });
}
for (const pt of lists.patterns) {
const flags = pt.flags.includes("g") ? pt.flags : pt.flags + "g";
const m = text.match(new RegExp(pt.regex, flags));
const count = m ? m.length : 0;
if (count > 0) findings.push({ rule: "pattern", detail: `"${pt.detail}" x${count}`, count });
if (count > 0) findings.push({ rule: "pattern", source: pt.source, detail: `"${pt.detail}" x${count}`, count });
}
return { clean: findings.length === 0, findings, charsChecked: text.length };