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).
129 lines
7.0 KiB
Markdown
129 lines
7.0 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
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.
|