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
33 changed files with 1328 additions and 196 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.
+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.
-128
View File
@@ -1,128 +0,0 @@
# Row 243: too-open mode refusal tests set their mode with chmod
Brief: `docs/plans/2026-10-21_gate-env-rows.md` § "The startup token refusal test sets its file mode"
(Sage, sage-1909). Reviewer Webby. Lands after row 197 (mos-1162). Design notes written before 197
lands, read at `origin/refactor` 01e1fe512. Recheck the line cites against 197's landing before code
starts.
## The defect
`writeFileSync(file, data, { mode })` and `mkdirSync(dir, { mode })` apply the process umask to the
mode. A test that creates a "too open" file this way and expects a refusal passes under `umask 022`
(0644 stays 0644) and fails under `umask 077` (0644 becomes 0600, and the source accepts it).
Row 197 r6 (df0c59608) runs each gate step under `umask 022` (`gate-lane.mjs:46`), so the lane no
longer hits this. Row 243 still makes the tests independent of the umask, as the brief asks.
## Sweep
Method: every source-side mode check in `packages/*/src` and `scripts/` (grep for `mode & 0o...`), then
the tests that reach each check's refusal branch, deciding whether the too-open object gets its mode at
creation or from an explicit chmod. Also a scan of every `writeFileSync`/`mkdirSync`/`openSync` in
`packages/*/tests` with a group or other bit in its mode (50 hits, 41 without a following chmod), and a
grep of the refusal messages for objects created with no mode or by a shell-out. Read at
`origin/refactor` with `git show` and `git grep`; nothing run.
Affected refusal tests, all three verified by reading the test (the fourth file, an accept test, follows the table):
| Creation | Test | Source check | Expected refusal |
|---|---|---|---|
| `packages/bus/tests/process.test.mjs:76` `writeFileSync(file, secret, { mode: 0o644 })` | startup token refusal returns safe code... | `bus/src/credentials.mjs:205` (must be 0600) | `credential-file`, exit 2 (:83-84) |
| `packages/webui/tests/proof-cli.test.mjs:28` `writeFileSync(file, value, { mode })`, mode 0o644 from the `mode` case at :27 | proof startup refuses partial flags... | `webui/src/serve.mjs:234` (0600 or 0400) | status 2 (:38) |
| `packages/webui/tests/sso.test.mjs:360` `writeFileSync(.../secrets/mosaic-stack/a, ..., { mode: 0o644 })` | 11: the CLI refuses --sso... | `webui/src/sso.mjs:45` (no group or other bits) | status 2 and the "no group or other permission bits" line (:361-363); the chmod to 0600 at :364 comes after |
Fix for each: keep the write, then `chmodSync(<path>, 0o644)` right after it. For `proof-cli.test.mjs`
the chmod goes inside the case map after the write, with the case's own mode, so the 0o600 cases are set
explicitly too.
Not affected (each sets the mode with a chmod or a chmodding helper before the code under test reads it):
bus credentials.test :52, credential-store.test :55-61; business access.test :225-226, business.test
:159/161/174, credentials.test :60, cli.test :151, helpers `writeJson`/`tokenFile`; cli cli.test :176,
notifier.test :318/:321, stack-release.test :1220/1239/1294/1313/1552, stack-upgrade.test :426-430,
case2-unit.test :340/:510/:635; control-board discord.test :79; discord binding.test :106/118/140/277,
notify.test :76, setspark.test :110, git.test :138/:360, offline-check.test :21; mosaic safety.test :80;
queue review.test :214/:228; ledger gitea-helper-raw.test :113; webui service.test :797, sso.test :310;
bus host-tools.test :182/:266. The 0o644/0o755 adapter and fake-binary writes (launcher, thread-launch,
session, sandbox, checkout, commit, etc.) aren't read by a mode check, or are only checked for the exec
bit, which 0700 keeps. `host-tools.test.mjs:205`, `:271` and the confDir at `:77` expect acceptance and
only need "not group-writable".
Mode checks with no refusal test at all (not this row; listed for the record): `bus/src/store.mjs:121`,
`bus/src/host-run.mjs:71`, `business/src/organization.mjs:28/45/109/141`,
`conversation/src/controller.mjs:299`, `mosaic/src/materialize-fixture.mjs:16`,
`mosaic/src/refresh-fixture.mjs:64`, `seat/src/session.mjs:72`, `scripts/git-credential-mosaic.mjs:49`,
and the mode branch of `scripts/stack-release.sh:1952`.
Fourth file, folded in by Sage (sage-1918): `packages/cli/tests/stack-instances.test.mjs:84` and `:89`
assert 0644 on the config and `instance.json`. `scripts/mosaic-config.mjs:337` creates them with
`openSync(file, "wx", 0o644)` and no chmod, so under `umask 077` they come out 0600 and the test fails.
This is an accept test of a source mode, not a refusal test.
| Creation | Test | Source | Ruling |
|---|---|---|---|
| `scripts/mosaic-config.mjs:337` via `bootstrap()` (`stack-instances.test.mjs:41`, a `spawnSync` of `scripts/bootstrap.sh`) | the `--instance` create test, :79-89 | `createExclusive` passes 0o644 and the umask narrows it | test-only (sage-1918) |
- No change to `mosaic-config.mjs`. Letting the operator's umask narrow the config is the source's
choice; a chmod to 0644 would make the config wider than that umask. No row asks for it.
- `bootstrap()` runs its child under an explicit `umask 022`, the pattern from `gate-lane.mjs:46`:
`run("/bin/bash", ["-c", 'umask 022; exec "$@"', "bash", BOOTSTRAP, ...args], homeEnv(home))`. Every
`bootstrap()` call goes through it (:79, :99, :102, :119, :127, :136, :140, :152, :165, :169), so the
0644 assertions still prove the mode the source asks for, whatever the gate's umask.
- New assertion: a `bootstrap --instance` run under `umask 077` makes the config and `instance.json`
0600. That pins that the source honors the umask, so a later chmod "fix" fails a test. The directories
stay 0700 in both runs (they're created with `mode: 0o700`, which 077 can't narrow).
- The `config()` helper (:42) has no mode assertion and stays as it is.
Tests outside `packages/*/tests`: the five `scripts/foundation/*.test.mjs` files have no open-mode
write. `cli.test.mjs:144/148` chmods to 0o000 and 0o600, which doesn't depend on the umask. The
`agents/*/work` probe tests are review evidence, not gate steps (`discoverSteps` doesn't run them), so
they're out of scope.
## Tests
- Each of the four files runs whole twice, once under `umask 022` and once under `umask 077`
(`bash -c 'umask 077; node --test <file>'`). Both pass. The brief requires this for process.test.mjs;
Sage extended it to all four (sage-1918).
- Proof that the change matters: before the change, the same four files under `umask 077` fail on the
named assertions. I record that run too, on the slot, so the 077 pass after the change is meaningful.
- Order (mos-1162): this lands after 197 r6, so r6's proof ran on the unchanged test.
## Files (expected)
`packages/bus/tests/process.test.mjs`, `packages/webui/tests/proof-cli.test.mjs`,
`packages/webui/tests/sso.test.mjs`, `packages/cli/tests/stack-instances.test.mjs`. Test-only. No
source change.
## Build (sage-2056: start on row 197's landing a36523713)
Branch `morgana/row243`, one commit on a36523713. Cites rechecked there: all hold except proof-cli's
write, which is now `proof-cli.test.mjs:30` (was :28).
- `bus/tests/process.test.mjs`: imports `chmodSync`; `chmodSync(file, 0o644)` after the write at :76.
- `webui/tests/proof-cli.test.mjs:30`: `chmodSync(file, mode)` after the write, with each case's own
mode, so the 0o600 cases are set explicitly too.
- `webui/tests/sso.test.mjs:360`: `chmodSync(..., 0o644)` after the write, before the `--sso` start
that must refuse.
- `cli/tests/stack-instances.test.mjs`: `underUmask(mask, home, args)` runs `scripts/bootstrap.sh`
through `/bin/bash -c 'umask <mask>; exec "$@"'`; `bootstrap()` is `underUmask("022", ...)`. New
subtest in T-I1: under umask 077 the config dir and root are 0700, and the config and `instance.json`
are 0600.
- Hash pins: no test or script names any of the four files (a grep for the basenames; positive control
`stack-release.test` found in two files). `stack-release.test.mjs:2122` mentions
`stack-instances.test.mjs` only in a comment.
Tests: on Sage's slot (sage-2062, plan approved in sage-2059), 18:55Z. My busy check read CLEAR before
and after. Each file ran whole with `node --test --test-concurrency=2` from its package, under
`bash -c 'umask <mask>; exec ...'`, with TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/g243XXXX`.
Script `/mnt/storage/scratch/morgana-r243/slot243.sh`, logs in `logs/` next to it.
| Tree | umask | process | proof-cli | sso | stack-instances |
|---|---|---|---|---|---|
| base a36523713 | 077 | 7/1 | 2/1 | 27/1 | 21/2 |
| candidate 137bda3af | 022 | 8/0 | 3/0 | 28/0 | 24/0 |
| candidate 137bda3af | 077 | 8/0 | 3/0 | 28/0 | 24/0 |
(pass/fail.) The base fails are the predicted ones: "startup token refusal returns safe code...",
"proof startup refuses partial flags...", "11: the CLI refuses --sso...", and in stack-instances the
0644 subtest "alpha and beta get a 0700 config dir..." plus its parent T-I1 (so 2). stack-instances has
one more test on the candidate (the umask 077 subtest). No unpredicted failure.
+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`.
+1
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.
@@ -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.
+11
View File
@@ -334,6 +334,17 @@ at every gate. Started 2026-09-12 during the control board MVP.
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
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
+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.
+1 -3
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
@@ -74,8 +74,6 @@ test('startup token refusal returns safe code without value or partial listening
const secret = 'fixture-token-never-in-db-948723';
const file = join(root, 'secret');
writeFileSync(file, secret, { mode: 0o644 });
// writeFileSync applies the umask; chmod sets the too-open mode under any umask (row 243).
chmodSync(file, 0o644);
const config = structuredClone(businesses);
config.demo.roles.cto.credentials = { gitea: { file, rotateBy: '2099-01-01' } };
const { child, logs } = launch(t);
+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);
+1 -14
View File
@@ -37,10 +37,7 @@ function run(cmd, args, env) {
return { code: r.status, out: r.stdout, err: r.stderr };
}
// The child runs under an explicit umask, so the mode assertions hold under any gate umask (row 243,
// sage-1918). mosaic-config.mjs creates with 0644 and lets the umask narrow it.
const underUmask = (mask, home, args) => run("/bin/bash", ["-c", `umask ${mask}; exec "$@"`, "bash", BOOTSTRAP, ...args], homeEnv(home));
const bootstrap = (home, ...args) => underUmask("022", home, args);
const bootstrap = (home, ...args) => run(BOOTSTRAP, args, homeEnv(home));
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
@@ -97,16 +94,6 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
});
await t.test("under umask 077 the config and marker are 0600: the source honors the operator's umask", (t) => {
const home = tmp(t, "si-");
const r = underUmask("077", home, ["--instance", "alpha"]);
assert.equal(r.code, 0, r.err);
assert.equal(mode(join(home, ".config", "mosaic-alpha")), 0o700);
assert.equal(mode(cfgOf(home, "alpha")), 0o600);
assert.equal(mode(join(home, ".mosaic-alpha")), 0o700);
assert.equal(mode(join(home, ".mosaic-alpha", "instance.json")), 0o600);
});
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
+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.
+58 -3
View File
@@ -11,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");
@@ -19,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 };
});
@@ -42,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;
@@ -304,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 {
+59 -4
View File
@@ -5,17 +5,17 @@ import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs';
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");
@@ -23,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" };
@@ -43,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" } });
@@ -264,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');
+1 -1
View File
@@ -27,7 +27,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
const cases = [
...sets.map(flags => ({ flags })),
...[['mode', VALUE, 0o644], ['short', 'short_fixture', 0o600], ['newline', VALUE.slice(0, 20) + '\n' + VALUE.slice(20), 0o600], ['bad', VALUE + '!', 0o600], ['two-final-lines', VALUE + '\n\n', 0o600]].map(([name, value, mode]) => {
const file = join(f.root, name); writeFileSync(file, value, { mode }); chmodSync(file, mode); // umask-proof (row 243)
const file = join(f.root, name); writeFileSync(file, value, { mode });
return { flags: [...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], value };
}),
];
-1
View File
@@ -358,7 +358,6 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, and
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o644); // writeFileSync applies the umask (row 243)
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(wide.status, 2);
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
+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() {
+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 };