Files
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

14 KiB

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 21ae25a328. 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.