Lands Sage's sage/row66-r5 at7ffb640ac, round 4 (r4d82c830779) rebased ontoa36523713after 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 on7b9737953,a36523713and 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).
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, andfresh()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-characterhook_failedreason 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_PATHSgainstools/unslop-hook. The copy fixtures instack-copy.test.mjsandpinned-copy.test.mjscopytools/unslop-hooktoo, and the pinned-copy top-level list gainstools. A newstack-copy.test.mjstest 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;
patternskeys 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
- extension.ts read the wrong reply. Pi 0.85.1
getBranch()returns entries root first (session-manager.js:958-967reverses the leaf-to-root walk).before_agent_starttook 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 inextension.ts; this also affects the fleet copy under~/.mosaic/tools, which I didn't touch. - claude-hook replies() merged replies. Grouping by
message.idacross the transcript merged two replies that shared an id (the test API answersmsg_mockfor 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]). - 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_messagewith an emptyentryId. On resumed turns the reply was in the transcript. The verdict is the same either way; only the entry id differs. - 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, andpackages/cli/tests/thread-launch.test.mjs:179pins that a thread run keeps no per-runsession/. My first build made<run>/sessionfor the log and failed that test in the gate (452/453). The brief's other line, "unslop.jsonlin the run directory", still holds, and each run keeps its own log. -
packages/seat/tests/sandbox.test.mjsisn't an owned file. Its pinbinds(args).length === list.length - 1broke 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.shand the two copy tests aren't owned files. The copy change is P1 above. PB3 of the Part B window (repin-partB-proof.md) holdsscripts/stack-copy.shstill until B7, so this row lands after the Part B window has run. -
MOSAIC_EXTENSIONSis set through the pi module'senv, not in the runner. -
unslop-check.jsgained thesourcefield 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_brokenandbroken_listsall 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 withcopy_tree, which copiesCOPY_PATHS, so a bundle copy also carriestools/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.