# 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 '' '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 `/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:
. The session gets a correction notice on its next turn.]` - `[unslop: the check is broken, so this session's replies go unchecked: ]`, 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: {: 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/`. 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 `/unslop.jsonl`. - N2 (Filbert), doc-only follow-up: the brief's `/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 `/unslop.jsonl`, not the brief's `/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 `/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.