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

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> or system-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_end checks finalized assistant text and notifies the operator (TUI/RPC). before_agent_start reads the most 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 pending flag was measured dead across print-mode turns, 2026-08-19). A 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

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_lists at startup, skipped_broken per 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 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); 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_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.

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.