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).
unslop-hook
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.
lists.json: committed machine source for every list the checker enforces: words, phrases, punct rules, regex patterns, density thresholds. Each entry carries provenance (ms-unslop:<pattern id>orsystem-md), the mention convention, and the documented divergence of the density gate from SYSTEM.md's outright em-dash ban. Edit lists here, not in code.unslop-check.js: dependency-free checker (node CLI + module) driven by lists.json. Loads and schema-validates the lists on first use and hard-fails closed: empty, unparseable, or invalid lists throw. Detects banned vocabulary, chatbot/sycophancy phrases, filler phrases, em/en dashes, curly quotes,not just X but Y. Strips fenced and inline code first, so quoted code is never flagged. Exit 0 clean, 1 violations, 2 gate broken (lists unreadable, never a clean verdict).extension.ts: pi extension.message_endchecks finalized assistant text and notifies the operator (TUI/RPC).before_agent_startreads the most recent assistant reply from the session file (walkinggetBranch()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./unslopreports session stats. Violation state lives in the session file, so the injection path survives restart, resume, fork, and reload (an in-memory pending flag was measured dead across print-mode turns, 2026-08-19). A broken lists.json fails closed: checks stop,broken_lists/skipped_brokenevents log the reason, operator notified once, seat keeps running.claude-hook.js: the same check for Claude Code (row 66). A managed session'ssettings.jsonruns it twice:stopchecks each assistant message of the turn from the transcript and logscheckedorflagged, andprompt(UserPromptSubmit) prints the extension's correction notice asadditionalContextwhen 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 logsbroken_listsand checks nothing; any other fault logshook_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
node test-unslop-check.js # suite
node unslop-check.js <file> # CLI check
UNSLOP_LISTS=<path> node unslop-check.js <file> # alt lists location
pi -e ~/.mosaic/tools/unslop-hook/extension.ts # ad-hoc load
# deploy: copy dir to ~/.pi/agent/extensions/unslop-hook/ or seat .pi
# equivalent, or list it in settings.json "extensions"
Env: MOSAIC_UNSLOP_HOOK=0 disables. MOSAIC_UNSLOP_LOG=<path> appends JSONL
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 green controls both exercised, including exit-2 on broken lists and on an unreadable input file (S3).
- CLI: slop file exit 1, clean file exit 0, broken lists exit 2 with the fault named on stderr.
- Extension, healthy path (print mode, zai/glm-5.3:low): startup probe loads lists.json, reply checked clean.
- Extension, broken-lists path (print mode):
broken_listsat startup,skipped_brokenper turn, seat survives, reply still delivered. - Earlier live evidence (pre-C1, inline lists): forced-slop turn flagged; fresh-process follow-up injected the notice and the reply came back clean; 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
sourcefield,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
/unslopcommand not tested headless (print mode has no command surface); it is a thin stats wrapper.- En dash flag fires on typographic ranges too (2–3); acceptable for fleet prose, revisit if it noisifies technical writing.
- Notice injection is a nudger, not a blocker. Output already streamed to the user stays as-is; correction lands on the next turn.
- A broken lists.json latches for the session: repairing the file mid-session 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_brokenbut 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_messageand logs an emptyentryId. - 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.
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. 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.