Compare commits

...
Author SHA1 Message Date
jason.woltje d4a3fc997b row 66 r4: BUILD.md names the rows that moved the three paths (#1559) 2026-10-21 02:34:05 -05:00
jason.woltje e6dce607e5 row 66 r4: BUILD.md round 4, rebased on 4ced5e63d (#1559) 2026-10-21 02:32:46 -05:00
jason.woltje 5d47935088 row 66 r2 (#1559): bound the unslop note, keep it across a refused send, carry tools/unslop-hook in stack copies
F1: the whole note is at most 600 characters; the hook failure reason is cut at 300 and the
flagged part gets what is left, so a clipped answer plus its note stays under the bus's
32768-character body limit. F2: a runner test for a refused RESULT send; the note rides the next
RESULT the session sends. P1: COPY_PATHS carries tools/unslop-hook, and a stack-copy test imports
both harness modules from a copy and checks every unslop path they read exists there. N1: stale
session-test comments. N2 is a doc-only follow-up in BUILD.md.
2026-10-21 02:30:54 -05:00
jason.woltje e2e2bf5bfc row 66 (#1559): managed sessions run the unslop check on both harnesses
Pi loads tools/unslop-hook/extension.ts as its second extension. Claude Code's
settings.json runs the new claude-hook.js on Stop and UserPromptSubmit, and
neither hook blocks. The modules mount the checker, lists.json and the entry
read-only, and the manifest records their digests. The runner sets
MOSAIC_UNSLOP_LOG to <run>/unslop.jsonl, reads it after each turn, and adds a
note for the operator to the next RESULT it sends (printed by mosaic talk and
follow). A broken check is told once a run. unslopCounts reads the counts after
the session ends. Findings carry their lists.json source.

The extension now walks getBranch() from its end, since pi returns it root
first, and claude-hook groups only adjacent transcript lines into one reply.
Deviations (agents/sage/work/queue-66/BUILD.md): the log is <run>/unslop.jsonl
because a thread run has no <run>/session/, and packages/seat/tests/sandbox.test.mjs
counts the three new read-only binds.
2026-10-21 02:30:54 -05:00
23 changed files with 965 additions and 44 deletions
+17 -1
View File
@@ -75,7 +75,8 @@ session's workspace and its own process group. Same contract, plus:
| `MOSAIC_POLICY_FILE`, `MOSAIC_TOOLS_FILE` | The bundle's gate policy and typed tools. |
| `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. |
| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. |
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. The pi module names two: the gate and typed tools extension, then the unslop check's `tools/unslop-hook/extension.ts` (row 66). |
| `MOSAIC_UNSLOP_LOG` | The unslop check's JSONL log, `<run>/unslop.jsonl`, which the runner sets for both adapters and reads after each turn (row 66). |
| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
| `MOSAIC_THINKING` | The route's thinking level (row 65), which the harness module's `levels()` already checked. `pi` passes `--thinking` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) and omits it when unset, as for `run-task.sh` workers. `claude` passes `--effort` (`low`, `medium`, `high`, `xhigh`, `max`) and requires it; unset exits 2. |
| `MOSAIC_CLAUDE_BIN` | `claude` only: absolute path of the CLI to run, from the host's environment (the bus unit pins it). Unset or empty runs `claude` from `PATH`; a relative path or a non-executable file exits 2. |
@@ -106,3 +107,18 @@ The cost is prompt size: a role's skill text is in every turn, where the
other routes load a skill when it's used. The bundle caps a role's skills at
64 KiB together, since `claude` passes `prompt.md` as one argument and Linux
caps one argument at 128 KiB.
### The unslop check (row 66)
Both adapters run the mechanical writing check from `tools/unslop-hook`,
driven by one `lists.json`. `pi` loads it as its second `-e` extension.
`claude` runs `claude-hook.js` from the bundle's `settings.json`: the Stop
hook checks the turn's replies, and the UserPromptSubmit hook adds the
correction notice as context on the next turn when the last reply had
tells. Each hook command ends `; exit 0`, so neither blocks. Both log to
`MOSAIC_UNSLOP_LOG`, and the runner adds a note for the operator to the
next RESULT it sends (`packages/harness/README.md`, the runner). The
harness modules mount the three files read-only at their own paths, and
the manifest records their digests. `MOSAIC_UNSLOP_HOOK` and `UNSLOP_LISTS`
aren't in the session environment's allowlist, so a managed session can't
switch the check off or swap its lists.
+166
View File
@@ -0,0 +1,166 @@
# 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 21ae25a328d4. 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.
+1
View File
@@ -418,6 +418,7 @@ One read-only tree supplies the bus, Console and human CLI (row 85,
`copy` requires a canonical checkout (.git directory); --allow-worktree
is a test override. COMMIT must be reachable from both local refactor and
origin/refactor. It archives the runtime/import closure, adapters, roles,
`tools/unslop-hook` (the unslop check both harness modules mount, row 66),
RELEASE, package.json and package-lock.json; refuses missing manifests;
runs npm ci inside the temporary copy; removes write permissions; and
renames beside the target. A published existing copy stays unchanged.
+15 -1
View File
@@ -15,7 +15,7 @@ export function setup(t, realPin=false){
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
mkdirSync(repo);mkdirSync(bin);
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
const pkg={name:'copy-fixture',version:'1.0.0'};
@@ -32,6 +32,20 @@ export function setup(t, realPin=false){
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
}
// Row 66: a session launched from a pinned copy reads each harness module's files from the copy,
// so every path a module names under its REPO (adapter, mounts, bundle sources) is in COPY_PATHS.
test('a copy carries every file the harness modules read from it',async t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const copy=join(s.copies,s.id),paths=[];
for(const kind of ['pi','claude-code']){
const m=await import(pathToFileURL(join(copy,'packages','plugins','harness',kind,'index.mjs')).href);
// pi's mounts() resolves node_modules, which this fixture copy lacks; its unslop mounts are its
// bundle sources, so the sources stand in for them.
paths.push(m.adapter(),...(kind==='pi'?Object.values(m.bundle().sources):m.mounts({env:{}}).map(x=>x.src)));
}
assert.equal(paths.filter(p=>p.includes('/tools/unslop-hook/')).length,6,paths.join('\n'));
for(const p of paths){assert.ok(p.startsWith(copy+'/'),p);assert.ok(existsSync(p),p);}
});
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
+28 -2
View File
@@ -68,9 +68,13 @@ so an existing bundle is never overwritten):
(`harnessVersion`), built-in and
typed tools, the actions without a typed tool, the S0 lines, the gate
timeout, and the sha256 of every bundle file and of the gate, extension,
MCP server and tools sources.
MCP server and tools sources, and of the unslop check's files (row 66:
`lists.json`, the checker, and pi's extension or Claude Code's hook
script).
- Claude Code only: `settings.json` (the wrapped PreToolUse hook, matcher
`*`) and `mcp.json` (one stdio server, `mosaic`, serving the typed tools).
`*`, and the two unslop hooks, Stop and UserPromptSubmit, each ending
`; exit 0` so it never blocks) and `mcp.json` (one stdio server, `mosaic`,
serving the typed tools).
`harness` is the route's module from `packages/plugins/harness/`, which the
launcher loaded; the bundle calls it for its own files and names and never
@@ -258,6 +262,28 @@ it never touches the disk and isn't in the environment or argv.
header after it names when the message was sent. A failed read fails the
turn without running the adapter: the sender gets `The turn failed: could
not read the current state: <code>`, and the message is read.
Row 66 (#1559): the unslop check. The runner sets `MOSAIC_UNSLOP_LOG` to
`<run>/unslop.jsonl`, after the harness module's variables (in the run
directory itself, since a thread run has no `<run>/session/`). Pi's second extension and
Claude Code's Stop and UserPromptSubmit hooks (`tools/unslop-hook`) check
each reply against `lists.json` and log `checked` or `flagged` (each
finding names its pattern id, the entry's `source`), `notice_injected`
when the next turn gets the correction notice, and `broken_lists`,
`skipped_broken` or `hook_failed` when there is no verdict. After each
turn the runner reads what the log gained, and the next RESULT it sends
ends with a note naming the flagged replies; a broken check is told
once a run. The whole note, both parts together, is at most 600
characters, and the answer before it is clipped at 32000, so the RESULT
stays under the bus's 32768. A turn that sends no reply (an
incoming RESULT, a landing turn) keeps its note for the next reply, and
so does a send that failed. That RESULT is what `mosaic talk` and
`mosaic follow` print, so the operator sees the note there.
`unslopCounts(path)` reads a log after the session ended: replies
checked, flagged, findings per pattern id, notices, and the first reason
the check broke. The check never blocks a reply (Q84 option B is a later
decision), and the session can write its run directory, so the log is
the session's own report, not the host's evidence.
5. SIGTERM or SIGINT kills the turn's process group, releases the role and
exits 0. The handlers go in before anything else, because pid 1 of a
PID namespace never sees a signal it has no handler for; a stop before
+120 -1
View File
@@ -21,6 +21,12 @@
// no reply and no read. A malformed entry exits 2 before the claim.
// 5. SIGTERM ends the turn, releases the role and exits 0; before the
// claim it exits 0 without claiming.
// 6. The unslop check (row 66): the harness logs each reply's check to
// <run>/unslop.jsonl (MOSAIC_UNSLOP_LOG). After each turn the
// runner reads what the log gained, and the next reply it sends ends
// with a note naming the flagged replies, or, once a run, the broken
// check. That reply is the RESULT `mosaic talk` and `mosaic follow`
// print, so the operator sees it there.
//
// Exit codes: 0 stopped, 20 founder credentials, 21 claim refused, 22 the
// capability or the claim stopped working (ended, revoked), 23 the broker
@@ -120,6 +126,113 @@ function clip(text) {
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
}
// Row 66 (#1559): the unslop check's log, which the harness writes and the runner only reads. Pi's
// second extension and Claude Code's Stop and UserPromptSubmit hooks (tools/unslop-hook) write the
// same events: checked, flagged (with findings, each naming its lists.json source, the pattern id),
// notice_injected, and broken_lists, skipped_broken or hook_failed when there is no verdict. The
// session can write the run directory, so these are its own report, not the host's evidence.
// The log sits in the run directory itself: a thread run has no <run>/session/ (its sessionDir is
// the thread's store), and one log a run keeps each run's counts apart.
export const unslopLog = (runDir) => join(runDir, "unslop.jsonl");
const UNSLOP_BROKEN = new Set(["broken_lists", "skipped_broken", "hook_failed"]);
const UNSLOP_NOTE_MAX = 600;
const cut = (text, n) => (text.length > n ? text.slice(0, n - 3) + "..." : text);
function unslopEvents(text) {
const out = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
try {
const e = JSON.parse(line);
if (e && typeof e === "object" && !Array.isArray(e) && typeof e.ev === "string") out.push(e);
} catch {}
}
return out;
}
const unslopReason = (e) => cut(typeof e.reason === "string" ? e.reason : "no reason given", 300);
// A session's counts from its log, readable after it ended: replies checked (clean or flagged),
// replies flagged, findings per pattern id, correction notices, and the first reason the check
// broke, or null. They are for the later blocking decision (Q84 option B).
export function unslopCounts(path) {
let text = "";
try {
text = readFileSync(path, "utf8");
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
const out = { checked: 0, flagged: 0, patterns: {}, notices: 0, broken: null };
for (const e of unslopEvents(text)) {
if (e.ev === "checked" || e.ev === "flagged") out.checked++;
if (e.ev === "flagged") {
out.flagged++;
for (const f of Array.isArray(e.findings) ? e.findings : []) {
if (typeof f?.source === "string") out.patterns[f.source] = (out.patterns[f.source] ?? 0) + 1;
}
}
if (e.ev === "notice_injected") out.notices++;
if (UNSLOP_BROKEN.has(e.ev) && out.broken === null) out.broken = unslopReason(e);
}
return out;
}
// What the log gained since the last read, held until a reply carries it. read() runs after every
// turn; note() is the text for the next reply, or "", and sent() drops it once that reply went out,
// so a failed send keeps it for the next one. A broken check is told once a run.
export function unslopNotes(path, log = () => {}) {
let offset = 0;
let toldBroken = false;
let flagged = [];
let broken = null;
const fresh = (reason) => {
if (toldBroken || broken !== null) return;
broken = cut(reason, 300);
log(`runner: unslop: the check is broken: ${broken}`);
};
return {
read() {
let text;
try {
text = readFileSync(path, "utf8");
} catch (e) {
if (e.code !== "ENOENT") fresh(`the runner can't read ${path}: ${e.code ?? e.message}`);
return;
}
// Only whole lines: a line the harness is still writing waits for the next read.
const end = text.lastIndexOf("\n") + 1;
if (end <= offset) return;
const events = unslopEvents(text.slice(offset, end));
offset = end;
for (const e of events) {
if (e.ev === "flagged") {
const details = (Array.isArray(e.findings) ? e.findings : []).map((f) => (typeof f?.detail === "string" ? f.detail : "")).filter(Boolean);
flagged.push(details.join("; ") || "no findings given");
log(`runner: unslop: a reply flagged: ${cut(details.join("; "), 300)}`);
}
if (UNSLOP_BROKEN.has(e.ev)) fresh(unslopReason(e));
}
},
// The whole note, both parts and the blank line, is at most UNSLOP_NOTE_MAX, so a clipped body
// plus its note stays under the bus's body limit. The broken part is at most 371 characters
// (the reason is cut at 300), and the flagged part gets what is left.
note() {
const parts = [];
if (broken !== null && !toldBroken) parts.push(`[unslop: the check is broken, so this session's replies go unchecked: ${broken}]`);
if (flagged.length) {
const n = flagged.length;
const tail = ". The session gets a correction notice on its next turn.]";
const room = UNSLOP_NOTE_MAX - 2 - parts.reduce((sum, p) => sum + p.length + 1, 0) - tail.length;
parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, room) + tail);
}
return parts.length ? `\n\n${parts.join("\n")}` : "";
},
sent() {
if (broken !== null) toldBroken = true;
flagged = [];
},
};
}
const readLine = (stream) => new Promise((resolve, reject) => {
let input = "";
stream.setEncoding("utf8");
@@ -274,6 +387,8 @@ export function adapterEnv(session, env) {
MOSAIC_PROVIDER: session.provider ?? "",
MOSAIC_MODEL: session.model,
...session.harnessEnv,
// Row 66: after the module's variables, so the log is always the run's own.
...(session.runDir ? { MOSAIC_UNSLOP_LOG: unslopLog(session.runDir) } : {}),
};
}
@@ -358,6 +473,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
},
});
const turnEnv = adapterEnv(session, env);
const unslop = unslopNotes(unslopLog(runDir), log);
let code = EXIT.stopped;
let failures = 0;
@@ -392,6 +508,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
try { await client.call("usage.begin", { turn: e.decision }); }
catch (err) { log(`runner: usage baseline unavailable: ${err.code ?? "failed"}`); }
const turn = await runTurn({ session, request: `${block}\n${landingRequest(e)}`, n, env: turnEnv, onChild: (c) => (child = c) });
unslop.read();
try { await client.call("usage.end", { turn: e.decision }); }
catch (err) { log(`runner: usage outcome unavailable: ${err.code ?? "failed"}`); }
log(`runner: landing turn ${n} for decision ${e.decision} (${e.task_ref}): ${turn.ok ? "ok" : `failed: ${turn.reason}`}`);
@@ -439,6 +556,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
const turn = unread
? { ok: false, reason: unread }
: await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
unslop.read();
// A failed/stopped harness can still have used tokens. Missing data remains unknown.
try { await client.call("usage.end", { turn: m.id }); }
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
@@ -447,7 +565,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
try {
if (m.class !== "RESULT") {
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
await client.call("message.send", { to: m.from_role, body: clip(body), class: "RESULT", in_reply_to: m.id });
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
unslop.sent();
}
await client.call("message.read", { id: m.id });
} catch (e) {
+15 -2
View File
@@ -7,6 +7,8 @@ import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.m
import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
import { decide } from "../src/gate.mjs";
const UNSLOP_DIR = new URL("../../../tools/unslop-hook/", import.meta.url).pathname;
const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
const mode = (p) => statSync(p).mode & 0o777;
@@ -80,8 +82,11 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
assert.deepEqual(m.skills.map((x) => x.name), ["ms-unslop", "ms-goal", "ms-communications"]);
assert.equal(m.reliesOn.length, 5);
assert.ok(!m.typed.includes("launch"), "row 119: the PM no longer launches");
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools"]);
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools", "unslopCheck", "unslopExtension", "unslopLists"]);
assert.equal(m.sources.piExtension.sha256, sha(SOURCES.piExtension));
// Row 66 (#1559): the unslop check's files, as the session loads them.
for (const [name, file] of [["unslopExtension", "extension.ts"], ["unslopCheck", "unslop-check.js"], ["unslopLists", "lists.json"]])
assert.equal(m.sources[name].sha256, sha(join(UNSLOP_DIR, file)), name);
for (const [name, digest] of Object.entries(m.files)) assert.equal(digest, sha(join(b.dir, "bundle", name)), name);
assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")), JSON.parse(JSON.stringify(m)));
});
@@ -144,7 +149,15 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) =
assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 });
assert.ok(m.builtins.includes("Bash"));
assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__")));
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]);
// Row 66 (#1559): the unslop hooks run after a reply and before the next prompt, and never block.
for (const [event, which] of [["Stop", "stop"], ["UserPromptSubmit", "prompt"]]) {
assert.equal(settings.hooks[event].length, 1, event);
assert.equal(settings.hooks[event][0].matcher, undefined, event);
assert.deepEqual(settings.hooks[event][0].hooks, [{ type: "command", command: `timeout -k 2 10 '${process.execPath}' '${join(UNSLOP_DIR, "claude-hook.js")}' ${which}; exit 0`, timeout: 20 }], event);
}
for (const [name, file] of [["unslopHook", "claude-hook.js"], ["unslopCheck", "unslop-check.js"], ["unslopLists", "lists.json"]])
assert.equal(m.sources[name].sha256, sha(join(UNSLOP_DIR, file)), name);
});
// Row 89 (#1583): the prompt names the built-ins as the session's harness names them.
+58 -3
View File
@@ -10,7 +10,7 @@ import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { buildBundle } from "../src/bundle.mjs";
import { adapterEnv } from "../src/runner.mjs";
import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
const ADAPTER = join(REPO, "adapters", "claude", "adapter.sh");
@@ -18,14 +18,14 @@ const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" })
const CLAUDE = which.status === 0 ? which.stdout.trim() : null;
const skip = CLAUDE ? false : "claude is not on PATH";
async function session(t, script, tools = ["read", "bash"]) {
async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}`) {
const dir = scratch(t);
const workspace = join(dir, "ws");
mkdirSync(workspace);
writeFileSync(join(workspace, "notes.txt"), "inside\n");
writeFileSync(join(dir, "secret.txt"), "outside\n");
if (typeof script === "function") script = script(workspace);
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}` });
const api = await mockAnthropic(t, { script, done });
const resolved = resolvedFor(t, "coder", (d) => {
d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5", "limits.tools": tools };
});
@@ -41,6 +41,9 @@ async function session(t, script, tools = ["read", "bash"]) {
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
// Row 66: the run directory, so the unslop hooks log to <dir>/unslop.jsonl, which the
// runner makes before the first turn.
runDir: dir,
};
const { files, manifest, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", harness: HARNESS["claude-code"], skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "pm" } });
s.bundle = files;
@@ -303,3 +306,55 @@ test("claude: a missing hook or MCP file refuses before claude starts", { skip }
}
assert.equal(api.requests.length, 0);
});
// Row 66 (#1559): the unslop check's Stop and UserPromptSubmit hooks. The scripted model's answers
// are the session's replies; the requests show what the model was sent on the next turn.
const TELL = "Let me delve into the build. It is a pivotal step.";
const replying = (answers) => {
let n = 0;
return () => answers[n++] ?? "Done.";
};
const notices = (api) => api.requests.filter((r) => r.tools.length).map((r) => r.raw.split("UNSLOP NOTICE").length - 1);
test("claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after", { skip }, async (t) => {
const { dir, api, s, env } = await session(t, [], undefined, replying([TELL, "The build is green.", TELL, "Done."]));
assert.equal(env.MOSAIC_UNSLOP_LOG, unslopLog(dir));
const notes = unslopNotes(unslopLog(dir));
for (const [i, request] of ["status?", "and now?", "and the tests?", "thanks"].entries()) {
const r = await turn(env, `Message ${i + 1} from pm, class REQUEST:\n\n${request}`, s.workspace);
assert.equal(r.code, 0, r.stderr);
notes.read();
// The runner's note for the operator: on each turn whose reply had the tell, and on no other.
assert.equal(/1 reply of this session flagged since the last note: banned word "delve" x1; banned word "pivotal" x1\./.test(notes.note()), i % 2 === 0, `turn ${i + 1}: ${notes.note()}\n${readFileSync(unslopLog(dir), "utf8")}`);
notes.sent();
}
// The request after each tell carries a notice; the one after a clean reply has no new one, and
// a tell after a clean reply is still caught.
assert.deepEqual(notices(api), [0, 1, 1, 2]);
const second = api.requests.filter((r) => r.tools.length)[1].raw;
assert.match(second, /UNSLOP NOTICE \(mechanical style check, not the user speaking\)[^"]*banned word \\"delve\\" x1/);
const counts = unslopCounts(unslopLog(dir));
assert.deepEqual({ ...counts, patterns: Object.keys(counts.patterns).length > 0 }, { checked: 4, flagged: 2, patterns: true, notices: 2, broken: null });
});
test("claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers", { skip }, async (t) => {
const { dir, api, s, env } = await session(t, [], undefined, replying(["The build is green.", TELL]));
writeFileSync(join(dir, "empty-lists.json"), "");
const notes = unslopNotes(unslopLog(dir));
const told = [];
for (const [i, request] of ["status?", "and now?"].entries()) {
const r = await turn({ ...env, UNSLOP_LISTS: join(dir, "empty-lists.json") }, `Message ${i + 1} from pm, class REQUEST:\n\n${request}`, s.workspace);
assert.equal(r.code, 0, r.stderr);
assert.ok(r.stdout.trim().length > 0, "the session answered");
notes.read();
told.push(notes.note());
notes.sent();
}
assert.match(told[0], /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: lists\.json invalid: empty file/);
assert.equal(told[1], "");
assert.deepEqual(notices(api), [0, 0]);
const counts = unslopCounts(unslopLog(dir));
assert.equal(counts.checked, 0);
assert.equal(counts.flagged, 0);
assert.match(counts.broken, /^lists\.json invalid: empty file/);
});
+9
View File
@@ -5,6 +5,9 @@
// FAIL exit 3
// NOMARKER answer without writing the turn marker
// ENV <name> answer "env: <value of name>"
// UNSLOP <json> append each event of the JSON array to $MOSAIC_UNSLOP_LOG, as the harness's
// unslop check does (row 66), and answer "unslop: <count>"
// UNSLOPLONG <json> the same, with an answer of 40000 more characters, over the runner's clip
// anything else answer "echo: <request>"
// Every turn appends its pid to $FAKE_PIDS when set.
@@ -33,6 +36,12 @@ if (body.startsWith("TOOL ")) {
} else if (body.startsWith("ENV ")) {
process.stdout.write(`env: ${process.env[body.slice(4)]}\n`);
done();
} else if (body.startsWith("UNSLOP ") || body.startsWith("UNSLOPLONG ")) {
const long = body.startsWith("UNSLOPLONG ");
const events = JSON.parse(body.slice(long ? 11 : 7));
for (const e of events) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
process.stdout.write(`unslop: ${events.length}${long ? " " + "x".repeat(40000) : ""}\n`);
done();
} else if (body === "NOMARKER") {
process.stdout.write("half an answer");
} else {
+59 -4
View File
@@ -4,17 +4,17 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildBundle } from "../src/bundle.mjs";
import { adapterEnv } from "../src/runner.mjs";
import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
import { REPLY_CAP } from "../src/tools.mjs";
import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
// The chief of staff (row 119) has no write tool; tests that write use the PM.
async function session(t, script, place = (dir) => join(dir, "ws"), instance = "chief") {
async function session(t, script, place = (dir) => join(dir, "ws"), instance = "chief", done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}`) {
const dir = scratch(t);
const workspace = place(dir);
const agentDir = join(dir, "pi-agent");
@@ -22,7 +22,7 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
mkdirSync(agentDir);
writeFileSync(join(workspace, "notes.txt"), "inside\n");
writeFileSync(join(dir, "secret.txt"), "outside\n");
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` });
const api = await mockAnthropic(t, { script, done });
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { probe: { baseUrl: api.url, api: "anthropic-messages", apiKey: "probe-dummy", models: [{ id: "probe-model" }] } } }));
const resolved = resolvedFor(t, instance, (d) => {
d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "probe-model" };
@@ -42,6 +42,9 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
// Row 66: the run directory, so the unslop check logs to <dir>/unslop.jsonl, which the
// runner makes before the first turn.
runDir: dir,
};
const models = { "probe-model": { provider: "probe", model: "probe-model" } };
const { files, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, harness: HARNESS.pi, models, skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "jason" } });
@@ -263,3 +266,55 @@ test("pi: an extension without its configuration fails pi's start", async (t) =>
assert.equal(api.requests.length, 0);
assert.ok(MOSAIC_POLICY_FILE);
});
// Row 66 (#1559): the unslop check as a second extension. The scripted model's answers are the
// session's replies; the requests show what the model was sent on the next turn.
const TELL = "Let me delve into the build. It is a pivotal step.";
const replying = (answers) => {
let n = 0;
return () => answers[n++] ?? "Done.";
};
const notices = (api) => api.requests.filter((r) => r.tools.length).map((r) => r.raw.split("UNSLOP NOTICE").length - 1);
test("pi: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after", async (t) => {
const { dir, api, s, env } = await session(t, [], undefined, undefined, replying([TELL, "The build is green.", TELL, "Done."]));
assert.equal(env.MOSAIC_UNSLOP_LOG, unslopLog(dir));
const notes = unslopNotes(unslopLog(dir));
for (const [i, request] of ["status?", "and now?", "and the tests?", "thanks"].entries()) {
const r = await turn(env, `Message ${i + 1} from jason, class REQUEST:\n\n${request}`, s.workspace);
assert.equal(r.code, 0, r.stderr);
notes.read();
// The runner's note for the operator: on each turn whose reply had the tell, and on no other.
assert.equal(/1 reply of this session flagged since the last note: banned word "delve" x1; banned word "pivotal" x1\./.test(notes.note()), i % 2 === 0, `turn ${i + 1}: ${notes.note()}\n${readFileSync(unslopLog(dir), "utf8")}`);
notes.sent();
}
// The request after each tell carries a notice; the one after a clean reply has no new one, and
// a tell after a clean reply is still caught.
assert.deepEqual(notices(api), [0, 1, 1, 2]);
const second = api.requests.filter((r) => r.tools.length)[1].raw;
assert.match(second, /UNSLOP NOTICE \(mechanical style check, not the user speaking\)[^"]*banned word \\"delve\\" x1/);
const counts = unslopCounts(unslopLog(dir));
assert.deepEqual({ ...counts, patterns: Object.keys(counts.patterns).length > 0 }, { checked: 4, flagged: 2, patterns: true, notices: 2, broken: null });
});
test("pi: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers", async (t) => {
const { dir, api, s, env } = await session(t, [], undefined, undefined, replying(["The build is green.", TELL]));
writeFileSync(join(dir, "empty-lists.json"), "");
const notes = unslopNotes(unslopLog(dir));
const told = [];
for (const [i, request] of ["status?", "and now?"].entries()) {
const r = await turn({ ...env, UNSLOP_LISTS: join(dir, "empty-lists.json") }, `Message ${i + 1} from jason, class REQUEST:\n\n${request}`, s.workspace);
assert.equal(r.code, 0, r.stderr);
assert.ok(r.stdout.trim().length > 0, "the session answered");
notes.read();
told.push(notes.note());
notes.sent();
}
assert.match(told[0], /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: lists\.json invalid: empty file/);
assert.equal(told[1], "");
assert.deepEqual(notices(api), [0, 0]);
const counts = unslopCounts(unslopLog(dir));
assert.equal(counts.checked, 0);
assert.equal(counts.flagged, 0);
assert.match(counts.broken, /^lists\.json invalid: empty file/);
});
+129 -1
View File
@@ -13,7 +13,7 @@ import { join } from "node:path";
import { Store } from "../../bus/src/store.mjs";
import { Broker, BusError } from "../../bus/src/broker.mjs";
import { serve } from "../../bus/src/server.mjs";
import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest } from "../src/runner.mjs";
import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
import { typedTools } from "../src/tools.mjs";
import { resolvedFor, scratch } from "./helpers.mjs";
@@ -836,3 +836,131 @@ test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn m
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.deepEqual(ctx.inbox.map((m) => m.body.trim()), ["env: xhigh", "half an answer"]);
});
// Row 66 (#1559): the harness's unslop check logs to <run>/unslop.jsonl. The fake adapter's
// UNSLOP body writes the events a real check would; pi-session and claude-session tests run the real
// extension and hooks.
const tell = (detail, source = "ms-unslop:7") => ({ ev: "flagged", clean: false, charsChecked: 40, findings: [{ rule: "word", source, detail, count: 1 }] });
const unslopAsk = (ctx, events, cls = "REQUEST") => ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOP ${JSON.stringify(events)}`, class: cls });
test("row 66 the adapter gets the run's unslop log, and a flagged reply's note rides that reply's RESULT, once", async (t) => {
const ctx = await setup(t, { session: { harnessEnv: { MOSAIC_UNSLOP_LOG: "/elsewhere.jsonl" } } });
ctx.inbox = [];
// A thread run's sessionDir is elsewhere and the run has no session/; the log needs none.
rmSync(join(ctx.runDir, "session"), { recursive: true, force: true });
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
const env = await ask(ctx, "ENV MOSAIC_UNSLOP_LOG");
assert.equal(env.body, `env: ${join(ctx.runDir, "unslop.jsonl")}`);
assert.equal(existsSync(join(ctx.runDir, "session")), false);
unslopAsk(ctx, [{ ev: "checked", clean: true, charsChecked: 10 }, tell('banned word "delve" x1')]);
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.equal(ctx.inbox[1].body, 'unslop: 2\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
const clean = await ask(ctx, "ENV MOSAIC_MODEL");
assert.equal(clean.body, "env: fake-model");
assert.match(r.stderr(), /runner: unslop: a reply flagged: banned word "delve" x1\n/);
});
test("row 66 a turn with no reply holds its note for the next reply; a broken check is told once", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
// A RESULT takes a turn and gets no reply, so its flagged reply waits.
const res = unslopAsk(ctx, [tell('banned word "tapestry" x1', "ms-unslop:7")], "RESULT");
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", res.id));
assert.equal(replies(ctx)(), null);
unslopAsk(ctx, [{ ev: "broken_lists", reason: "lists.json invalid: empty file" }, tell('filler phrase "in order to" x1', "ms-unslop:23")]);
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
assert.equal(ctx.inbox[0].body, "unslop: 2\n\n[unslop: the check is broken, so this session's replies go unchecked: lists.json invalid: empty file]\n" +
'[unslop: 2 replies of this session flagged since the last note: banned word "tapestry" x1; filler phrase "in order to" x1. The session gets a correction notice on its next turn.]');
// The next broken event isn't told again.
unslopAsk(ctx, [{ ev: "skipped_broken", reason: "lists.json invalid: empty file" }]);
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.equal(ctx.inbox[1].body, "unslop: 1");
assert.equal(r.stderr().match(/runner: unslop: the check is broken/g).length, 1);
const counts = unslopCounts(unslopLog(ctx.runDir));
assert.deepEqual(counts, { checked: 2, flagged: 2, patterns: { "ms-unslop:7": 1, "ms-unslop:23": 1 }, notices: 0, broken: "lists.json invalid: empty file" });
});
test("row 66 unslopNotes keeps a note until sent(), reads only whole lines, and unslopCounts reads a log after the session", (t) => {
const dir = scratch(t);
const path = join(dir, "unslop.jsonl");
const notes = unslopNotes(path);
notes.read();
assert.equal(notes.note(), "");
writeFileSync(path, JSON.stringify(tell('banned word "pivotal" x1')) + "\n" + '{"ev":"flagged","findings":[{"detail":"half');
notes.read();
const first = notes.note();
assert.match(first, /1 reply of this session flagged since the last note: banned word "pivotal" x1\./);
// A send that failed calls no sent(), so the note is still there.
assert.equal(notes.note(), first);
notes.sent();
assert.equal(notes.note(), "");
// The half line completes; it is read once, from where the last read stopped.
writeFileSync(path, readFileSync(path, "utf8") + ' x","source":"ms-unslop:9"}]}\nnot json\n[1]\n' + JSON.stringify({ ev: "notice_injected" }) + "\n");
notes.read();
assert.match(notes.note(), /1 reply of this session flagged since the last note: half x\./);
assert.deepEqual(unslopCounts(path), { checked: 2, flagged: 2, patterns: { "ms-unslop:7": 1, "ms-unslop:9": 1 }, notices: 1, broken: null });
assert.deepEqual(unslopCounts(join(dir, "none.jsonl")), { checked: 0, flagged: 0, patterns: {}, notices: 0, broken: null });
// A note is bounded however many findings a reply had.
const many = unslopNotes(join(dir, "many.jsonl"));
writeFileSync(join(dir, "many.jsonl"), Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x")))).join("\n") + "\n");
many.read();
assert.ok(many.note().length <= 600, many.note().length);
assert.match(many.note(), /^\n\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
// Both parts together are bounded too, and a reason the runner makes itself is cut like one the
// log gives: here the log path is a directory with a long name, so the read fails with EISDIR.
const deep = join(dir, "d".repeat(250));
mkdirSync(deep);
const both = unslopNotes(deep);
both.read();
assert.match(both.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: the runner can't read .*\.\.\.\]$/);
assert.equal(both.note().length, 2 + 71 + 300);
const both2 = unslopNotes(join(dir, "both.jsonl"));
writeFileSync(join(dir, "both.jsonl"), [JSON.stringify({ ev: "broken_lists", reason: "b".repeat(400) }), ...Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x"))))].join("\n") + "\n");
both2.read();
assert.equal(both2.note().length, 600);
assert.match(both2.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: b{297}\.\.\.\]\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
});
test("row 66 a clipped answer with both note parts stays under the bus's body limit, and the note under 600", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const events = [{ ev: "hook_failed", reason: "r".repeat(400) }, ...Array.from({ length: 30 }, (_, i) => tell(`banned word "w${i}" x1`.padEnd(60, "x")))];
ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOPLONG ${JSON.stringify(events)}`, class: "REQUEST" });
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
const body = ctx.inbox[0].body;
const at = body.indexOf("\n[answer cut at 32000 characters]");
assert.equal(at, 32000);
const note = body.slice(at + "\n[answer cut at 32000 characters]".length);
assert.ok(note.length <= 600, note.length);
assert.ok(body.length <= 32768, body.length);
assert.match(note, /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: r+\.\.\.\]\n\[unslop: 30 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
});
test("row 66 a refused send keeps the note: it rides the next RESULT the session sends", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
// The broker refuses the session's first RESULT once, as it would a body it can't take.
const request = ctx.broker.request.bind(ctx.broker);
let refused = 0;
ctx.broker.request = (cap, r) => {
if (r.verb === "message.send" && r.args?.class === "RESULT" && refused++ === 0) throw new BusError("invalid-request");
return request(cap, r);
};
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const sent = unslopAsk(ctx, [tell('banned word "delve" x1')]);
await until(() => r.stderr().includes(`runner: reply to ${sent.id} failed: invalid-request\n`));
// The refused message was delivered and isn't read; its reply never reached the bus.
assert.ok(!ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
const next = await ask(ctx, "ENV MOSAIC_MODEL");
assert.equal(next.body, 'env: fake-model\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
assert.equal(ctx.inbox.length, 1);
});
+5
View File
@@ -153,6 +153,11 @@ export returns:
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
how a turn ends and where its transcripts land.
- Row 66: both modules wire the unslop check from `tools/unslop-hook`.
`mounts` adds its three files read-only and `bundle` names them in the
manifest's sources. Pi's `env` loads `extension.ts` as the second entry of
`MOSAIC_EXTENSIONS`; Claude Code's `bundle` adds Stop and UserPromptSubmit
hooks that run `claude-hook.js` and never block.
`plugins.harness` switches harnesses on, and an instance's `harness` and
`fallback.harness` vars each name one. A launch on a harness that isn't
+23 -8
View File
@@ -16,6 +16,13 @@ export const interfaceVersion = 1;
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
const GATE = fileURLToPath(new URL("../../../harness/src/claude-gate.mjs", import.meta.url));
const MCP_SERVER = fileURLToPath(new URL("../../../harness/src/mcp-server.mjs", import.meta.url));
// Row 66 (#1559): the unslop check's hook script, which the Stop and UserPromptSubmit hooks run. It
// logs to MOSAIC_UNSLOP_LOG, which the runner sets, and the runner tells the operator.
const UNSLOP = Object.freeze({
unslopHook: join(REPO, "tools", "unslop-hook", "claude-hook.js"),
unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"),
unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"),
});
// `claude --effort <level>` on 2.1.302 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
// It has no off or minimal.
const LEVELS = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
@@ -69,11 +76,13 @@ export function version({ env }) {
// Its launches spend the business's launch.account (row 126).
export const countsAccount = () => true;
// The pinned binary, when it lives outside /usr (which the sandbox already binds).
// The pinned binary, when it lives outside /usr (which the sandbox already binds), and the unslop
// hook's files.
export function mounts({ env }) {
if (!env.MOSAIC_CLAUDE_BIN) return [];
const unslop = Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's Stop and UserPromptSubmit hooks run" }));
if (!env.MOSAIC_CLAUDE_BIN) return unslop;
const real = realpathSync(env.MOSAIC_CLAUDE_BIN);
return real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }];
return [...(real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }]), ...unslop];
}
// WebSearch and WebFetch are its own, so it serves harness-builtin web.
@@ -83,22 +92,28 @@ export const typedName = (name) => MCP_PREFIX + name;
export const reliesOn = () => RELIES_ON;
export const gateTimeout = () => ({ ...GATE_TIMEOUT });
// settings.json holds the gate hook and mcp.json the typed tools' server. files: the bundle's own
// (policy, tools). write(name, text) puts a file in the bundle directory and returns
// [name, sha256]; quote(path) refuses a single quote.
// settings.json holds the gate hook and the two unslop hooks (row 66), and mcp.json the typed
// tools' server. An unslop hook ends with `; exit 0`, so it never blocks a reply or a prompt.
// files: the bundle's own (policy, tools). write(name, text) puts a file in the bundle directory
// and returns [name, sha256]; quote(path) refuses a single quote.
export function bundle({ dir, files: core, session, write, quote }) {
const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") };
const node = process.execPath;
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(GATE)} ${quote(core.policy)} || exit 2`;
const unslop = (which) => [{ hooks: [{ type: "command", command: `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(UNSLOP.unslopHook)} ${which}; exit 0`, timeout: GATE_TIMEOUT.hook }] }];
const digests = [
write("settings.json", JSON.stringify({
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
hooks: {
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }],
Stop: unslop("stop"),
UserPromptSubmit: unslop("prompt"),
},
}, null, 2) + "\n"),
write("mcp.json", JSON.stringify({
mcpServers: { mosaic: { type: "stdio", command: node, args: [MCP_SERVER, core.tools, session.toolSocket] } },
}, null, 2) + "\n"),
];
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER } };
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER, ...UNSLOP } };
}
export function env({ files, policy, session }) {
+13 -3
View File
@@ -13,6 +13,13 @@ const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
const PACKAGE = "@earendil-works/pi-coding-agent";
const BIN = join(REPO, "node_modules", ".bin", "pi");
const EXTENSION = fileURLToPath(new URL("../../../harness/src/pi-extension.mjs", import.meta.url));
// Row 66 (#1559): the unslop check, loaded as a second extension. It logs to MOSAIC_UNSLOP_LOG,
// which the runner sets, and the runner tells the operator.
const UNSLOP = Object.freeze({
unslopExtension: join(REPO, "tools", "unslop-hook", "extension.ts"),
unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"),
unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"),
});
// `pi --thinking <level>` on 0.85.1 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md).
const LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
// docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5.
@@ -40,20 +47,23 @@ export function version() {
// Pi sessions run on a provider key, never the Claude account limit.
export const countsAccount = () => false;
export const mounts = () => [{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" }];
export const mounts = () => [
{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" },
...Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's second extension runs" })),
];
// Pi has no web tool of its own, so a granted role under harness-builtin is refused.
export const builtinWeb = () => false;
export const builtins = ({ tools }) => [...tools];
export const typedName = (name) => name;
export const reliesOn = () => RELIES_ON;
export const gateTimeout = () => null;
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION } });
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION, ...UNSLOP } });
// The tool limit is the built-ins plus the typed tools, which the extension serves.
export function env({ policy, tools, session }) {
return {
MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","),
MOSAIC_EXTENSIONS: EXTENSION,
MOSAIC_EXTENSIONS: [EXTENSION, UNSLOP.unslopExtension].join(","),
PI_PROVIDER: session.provider ?? "",
PI_MODEL: session.model,
MOSAIC_THINKING: session.thinking,
+12 -4
View File
@@ -71,9 +71,11 @@ test("row 65 claude-code preflight: a missing credential, then a missing or rela
assert.equal(claude.preflight({ env: { ...token, MOSAIC_CLAUDE_BIN: bin } }), null);
assert.equal(claude.preflight({ env: { ...token, PATH: root } }), null);
assert.equal(claude.version({ env: { MOSAIC_CLAUDE_BIN: bin, PATH: process.env.PATH } }), "2.1.302");
// The pinned binary outside /usr is mounted where the adapter looks for it.
assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin]]);
assert.deepEqual(claude.mounts({ env: {} }), []);
// The pinned binary outside /usr is mounted where the adapter looks for it, and the unslop hook's
// files where the hook commands name them (row 66).
const unslop = ["claude-hook.js", "unslop-check.js", "lists.json"].map(f => [join(REPO, "tools", "unslop-hook", f), undefined]);
assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin], ...unslop]);
assert.deepEqual(claude.mounts({ env: {} }).map(m => [m.src, m.dest]), unslop);
});
// Row 68's claudeVersion precedence, moved here with the version (row 65): the manifest records
@@ -100,7 +102,8 @@ test("row 65 pi preflight passes with the repository's pi, and its version is th
assert.equal(pi.preflight({ env: {} }), null);
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
assert.equal(pi.version({ env: {} }), pkg.dependencies?.["@earendil-works/pi-coding-agent"] ?? pkg.devDependencies?.["@earendil-works/pi-coding-agent"]);
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest), [join(REPO, "node_modules")]);
// Row 66: the unslop extension's files, bound read-only at their own paths.
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest ?? m.src), [join(REPO, "node_modules"), ...["extension.ts", "unslop-check.js", "lists.json"].map(f => join(REPO, "tools", "unslop-hook", f))]);
});
test("row 65 env passes the thinking level to both adapters, and claude-code's bundle writes its hook and MCP files", async t => {
@@ -108,6 +111,8 @@ test("row 65 env passes the thinking level to both adapters, and claude-code's b
const session = { provider: "fixture", model: "m", thinking: "high", toolSocket: "/run/tools.sock" };
const piEnv = pi.env({ policy: { tools: ["read"] }, tools: [{ name: "list_tasks" }], session });
assert.deepEqual([piEnv.MOSAIC_THINKING, piEnv.PI_MODEL, piEnv.MOSAIC_TOOLS], ["high", "m", "read,list_tasks"]);
// Row 66: pi loads the unslop check as its second extension.
assert.deepEqual(piEnv.MOSAIC_EXTENSIONS.split(","), [join(REPO, "packages", "harness", "src", "pi-extension.mjs"), join(REPO, "tools", "unslop-hook", "extension.ts")]);
const dir = scratch(t);
const written = [];
const write = (name, text) => { writeFileSync(join(dir, name), text); written.push(name); return [name, "0".repeat(64)]; };
@@ -117,6 +122,9 @@ test("row 65 env passes the thinking level to both adapters, and claude-code's b
const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
assert.match(settings.hooks.PreToolUse[0].hooks[0].command, /^timeout -k 2 10 .* \|\| exit 2$/);
assert.equal(settings.hooks.PreToolUse[0].hooks[0].timeout, 20);
// Row 66: the unslop hooks can't block: each command ends with `; exit 0`.
assert.match(settings.hooks.Stop[0].hooks[0].command, /^timeout -k 2 10 .*claude-hook\.js' stop; exit 0$/);
assert.match(settings.hooks.UserPromptSubmit[0].hooks[0].command, /^timeout -k 2 10 .*claude-hook\.js' prompt; exit 0$/);
assert.equal(JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8")).mcpServers.mosaic.args.at(-1), "/run/tools.sock");
const claudeEnv = claude.env({ files: out.files, policy: { tools: ["read"], typed: [] }, session });
assert.deepEqual([claudeEnv.MOSAIC_THINKING, claudeEnv.MOSAIC_CLAUDE_SETTINGS, claudeEnv.MOSAIC_CLAUDE_MCP_CONFIG], ["high", out.files.settings, out.files.mcp]);
+6 -1
View File
@@ -93,7 +93,12 @@ test("row 65 a harness module's mounts bind read-only after the adapter, each wi
assert.ok(at(join(root, "a.sh")) < at(modules.src) && at(modules.src) < at(join(root, "bus.sock")));
const fixture = binds({ ...args, harnessMounts: [{ src: join(root, "tool"), why: "a fixture harness's tool" }] });
assert.deepEqual(fixture.find((b) => b.src === join(root, "tool")), { kind: "ro", src: join(root, "tool"), why: "a fixture harness's tool" });
assert.equal(binds(args).length, list.length - 1);
// Row 66 (#1559): pi also mounts the unslop extension's three files, each read-only at its own path.
for (const f of ["extension.ts", "unslop-check.js", "lists.json"]) {
const src = join(REPO, "tools", "unslop-hook", f);
assert.deepEqual(list.find((b) => b.src === src), { kind: "ro", src, why: "the unslop check the session's second extension runs" }, f);
}
assert.equal(binds(args).length, list.length - 4);
});
// Row 121 (#1615): a thread run gets only its own thread's two directories,
+2 -2
View File
@@ -33,7 +33,7 @@ function tmp(t, prefix) {
function fixtureRepo(t) {
const dir = tmp(t, 'webui-copy-');
const repo = join(dir, 'repo');
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
@@ -72,7 +72,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
+2
View File
@@ -36,6 +36,8 @@ COPY_PATHS=(
packages/queue/src
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
packages/plugins
# The unslop check both harness modules mount and list in the bundle (row 66).
tools/unslop-hook
)
# stack-release.sh sources this file and calls reachable before an activate (D1).
reachable() {
+49 -4
View File
@@ -1,6 +1,7 @@
# unslop-hook
Mechanical AI-tell enforcement for pi seats. Anti-drift gate for the writing
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.
@@ -18,7 +19,9 @@ sessions; this check cannot forget.
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 and, if it carries tells,
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
@@ -26,6 +29,17 @@ sessions; this check cannot forget.
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
@@ -44,6 +58,15 @@ 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
@@ -60,6 +83,18 @@ lists.json location for both CLI and extension.
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);
@@ -72,7 +107,15 @@ lists.json location for both CLI and extension.
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).
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.
@@ -80,4 +123,6 @@ lists.json location for both CLI and extension.
## 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.
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.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
"use strict";
// unslop-hook for Claude Code: the managed-session counterpart of extension.ts (row 66, #1559).
// Two hooks in a managed session's settings.json run it (packages/plugins/harness/claude-code),
// and neither blocks: the script always exits 0 and prints nothing on Stop.
//
// node claude-hook.js stop Stop hook. Checks each assistant message with text since the
// turn's prompt and logs checked or flagged for each.
// node claude-hook.js prompt UserPromptSubmit hook. Checks the last assistant reply with text
// and, when it has tells, prints the correction notice as
// additionalContext, once per reply.
//
// Both read the hook input on stdin and the transcript it names. Events go to MOSAIC_UNSLOP_LOG in
// the extension's shapes (checked, flagged, notice_injected, broken_lists), so the runner reads one
// log for either harness and tells the operator. A broken lists.json logs broken_lists and checks
// nothing; there is never a clean verdict without the lists.
// Off: MOSAIC_UNSLOP_HOOK=0. Lists: UNSLOP_LISTS=<path>, as for the checker.
//
// The notice text is the extension's, word for word.
const fs = require("node:fs");
const { checkText } = require("./unslop-check.js");
const LOG = process.env.MOSAIC_UNSLOP_LOG;
const log = (ev) => {
if (!LOG) return;
try { fs.appendFileSync(LOG, JSON.stringify({ ts: Date.now(), ...ev }) + "\n"); } catch {}
};
function entries(path) {
let raw;
try { raw = fs.readFileSync(path, "utf8"); } catch { return []; }
const out = [];
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try { out.push(JSON.parse(line)); } catch {}
}
return out.filter((e) => e && typeof e === "object" && !e.isSidechain);
}
const textOf = (content) => {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
};
// A prompt is a user entry that isn't only tool results.
const isPrompt = (e) => e.type === "user" && e.message?.role === "user"
&& !(Array.isArray(e.message.content) && e.message.content.length > 0 && e.message.content.every((b) => b?.type === "tool_result"));
// The assistant messages with text, in order. Claude Code writes one transcript line per content
// block, so adjacent lines that share a message id are one message. Its id is its first line's
// uuid: a message id alone can repeat across messages (a test API answers one id for all).
function replies(list) {
const out = [];
let last = null;
for (const e of list) {
if (e.type !== "assistant" || e.message?.role !== "assistant") {
last = null;
continue;
}
const msg = e.message.id ?? null;
if (!last || msg === null || last.msg !== msg) out.push((last = { msg, id: e.uuid ?? msg ?? "", texts: [] }));
const text = textOf(e.message.content);
if (text.trim()) last.texts.push(text);
}
return out.filter((r) => r.texts.length).map((r) => ({ id: r.id, text: r.texts.join("\n") }));
}
// checkText, or null after logging why there is no verdict.
function check(text, at) {
try {
return checkText(text);
} catch (e) {
const reason = String(e?.message ?? e);
log({ ev: reason.startsWith("lists.json invalid") ? "broken_lists" : "hook_failed", reason, at });
return null;
}
}
function stop(input) {
const list = entries(input.transcript_path);
let start = -1;
list.forEach((e, i) => { if (isPrompt(e)) start = i; });
const turn = replies(list.slice(start + 1));
if (!turn.length && typeof input.last_assistant_message === "string" && input.last_assistant_message.trim()) {
turn.push({ id: "", text: input.last_assistant_message });
}
for (const r of turn) {
const result = check(r.text, "stop");
if (result === null) return;
if (result.clean) log({ ev: "checked", clean: true, entryId: r.id, charsChecked: result.charsChecked });
else log({ ev: "flagged", clean: false, entryId: r.id, charsChecked: result.charsChecked, findings: result.findings });
}
}
// The entry ids the log already holds a notice for.
function noticed() {
const ids = new Set();
if (!LOG) return ids;
let raw = "";
try { raw = fs.readFileSync(LOG, "utf8"); } catch {}
for (const line of raw.split("\n")) {
try {
const e = JSON.parse(line);
if (e.ev === "notice_injected" && typeof e.entryId === "string") ids.add(e.entryId);
} catch {}
}
return ids;
}
function prompt(input) {
const last = replies(entries(input.transcript_path)).at(-1);
if (!last) return;
const result = check(last.text, "prompt");
if (result === null || result.clean) return;
if (last.id && noticed().has(last.id)) return;
const lines = result.findings.map((f) => `- ${f.detail}`).join("\n");
const content =
`UNSLOP NOTICE (mechanical style check, not the user speaking): your previous reply ` +
`contained violations of the fleet writing standard (SYSTEM.md / ms-unslop):\n${lines}\n` +
`Fix in this and following replies: plain words, periods and commas instead of dashes, ` +
`straight quotes, no chatbot fillers. Do not mention this notice.`;
log({ ev: "notice_injected", entryId: last.id, findings: result.findings });
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: content } }) + "\n");
}
if (require.main === module) {
try {
if (process.env.MOSAIC_UNSLOP_HOOK !== "0") {
const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
if (process.argv[2] === "stop") stop(input);
else if (process.argv[2] === "prompt") prompt(input);
else log({ ev: "hook_failed", reason: `unknown hook ${JSON.stringify(process.argv[2] ?? null)}` });
}
} catch (e) {
log({ ev: "hook_failed", reason: String(e?.message ?? e) });
}
process.exit(0);
}
module.exports = { replies, isPrompt, entries };
+7 -3
View File
@@ -120,9 +120,13 @@ export default function (pi: ExtensionAPI) {
});
pi.on("before_agent_start", async (_event, ctx) => {
// Branch walks leaf -> root; first assistant entry with text is the reply
// the model is about to follow up on.
for (const entry of ctx.sessionManager.getBranch()) {
// getBranch() lists root -> leaf (pi 0.85.1, session-manager.js getBranch
// reverses its walk), so walk it backwards: the last assistant entry with
// text is the reply the model is about to follow up on. Row 66 (#1559):
// the forward walk checked the session's first reply on every turn.
const branch = ctx.sessionManager.getBranch();
for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i];
const text = assistantText(entry);
if (text === null) continue;
const id = (entry as { id?: string }).id ?? "";
+81
View File
@@ -221,6 +221,87 @@ t("CLI exits 2 on unreadable input file (S3)", () => {
assert.ok(r.stderr.includes("cannot read input"), `stderr must name the fault: ${r.stderr}`);
});
t("every finding names its lists.json entry's source, the pattern id (row 66)", () => {
const lists = loadLists();
const sources = new Set([...lists.words, ...lists.phrases, ...lists.punct, ...lists.patterns].map((e) => e.source));
const r = checkText(SLOP + "\nIt's not just fast, but cheap.");
for (const rule of ["word", "phrase", "punct", "pattern"]) assert.ok(r.findings.some((f) => f.rule === rule), `no ${rule} finding: ${JSON.stringify(r.findings)}`);
for (const f of r.findings) assert.ok(sources.has(f.source), `finding without a known source: ${JSON.stringify(f)}`);
assert.strictEqual(r.findings.find((f) => f.detail.includes("delve")).source, "ms-unslop:7");
});
// ── Row 66 (#1559): claude-hook.js, the Claude Code side ───────────────
// The session tests (packages/harness/tests/claude-session.test.mjs) run it under real claude;
// these pin its parts without one.
const HOOK = path.join(__dirname, "claude-hook.js");
const { replies } = require("./claude-hook.js");
const line = (type, id, uuid, content) => ({ type, uuid, message: { id, role: type, content } });
t("claude-hook replies(): adjacent lines of one message join, a repeated message id later is a new reply", () => {
const r = replies([
line("assistant", "m1", "u1", [{ type: "text", text: "one" }]),
line("assistant", "m1", "u2", [{ type: "tool_use", id: "x", name: "Read", input: {} }]),
line("user", undefined, "u3", [{ type: "tool_result", tool_use_id: "x", content: "ok" }]),
line("assistant", "m1", "u4", [{ type: "text", text: "two" }]),
line("assistant", "m1", "u5", [{ type: "text", text: "three" }]),
line("assistant", "m2", "u6", [{ type: "tool_use", id: "y", name: "Read", input: {} }]),
]);
assert.deepStrictEqual(r, [{ id: "u1", text: "one" }, { id: "u4", text: "two\nthree" }]);
});
function hook(which, input, env = {}) {
return spawnSync(process.execPath, [HOOK, which], { input: typeof input === "string" ? input : JSON.stringify(input), encoding: "utf8", env: { PATH: process.env.PATH, ...env } });
}
const events = (f) => (fs.existsSync(f) ? fs.readFileSync(f, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
t("claude-hook: Stop logs the turn's reply flagged with pattern ids, the next prompt gets the notice once, and both exit 0", () => {
const transcript = writeTmp("transcript.jsonl", [
{ type: "user", uuid: "p1", message: { role: "user", content: "status?" } },
line("assistant", "m1", "a1", [{ type: "text", text: "Let me delve into it. A pivotal step." }]),
].map((x) => JSON.stringify(x)).join("\n") + "\n");
const log = tmp("hook-log.jsonl");
const stop = hook("stop", { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: log });
assert.strictEqual(stop.status, 0, stop.stderr);
assert.strictEqual(stop.stdout, "");
const [flagged] = events(log);
assert.strictEqual(flagged.ev, "flagged");
assert.strictEqual(flagged.entryId, "a1");
assert.deepStrictEqual(flagged.findings.map((f) => f.source), ["ms-unslop:7", "ms-unslop:7"]);
const first = hook("prompt", { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: log });
assert.strictEqual(first.status, 0, first.stderr);
const out = JSON.parse(first.stdout).hookSpecificOutput;
assert.strictEqual(out.hookEventName, "UserPromptSubmit");
assert.ok(out.additionalContext.startsWith("UNSLOP NOTICE (mechanical style check, not the user speaking)"), out.additionalContext);
assert.ok(out.additionalContext.includes('banned word "delve" x1'), out.additionalContext);
const again = hook("prompt", { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: log });
assert.strictEqual(again.status, 0);
assert.strictEqual(again.stdout, "", "one notice per reply");
assert.deepStrictEqual(events(log).map((e) => e.ev), ["flagged", "notice_injected"]);
// Off: nothing logged, nothing printed.
const off = tmp("hook-off.jsonl");
const quiet = hook("prompt", { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: off, MOSAIC_UNSLOP_HOOK: "0" });
assert.strictEqual(quiet.status, 0);
assert.strictEqual(quiet.stdout, "");
assert.ok(!fs.existsSync(off));
});
t("claude-hook: a broken lists.json logs broken_lists and no verdict; bad input logs hook_failed; both exit 0", () => {
const transcript = writeTmp("transcript-clean.jsonl", JSON.stringify(line("assistant", "m1", "a1", [{ type: "text", text: "The build is green." }])) + "\n");
const log = tmp("hook-broken.jsonl");
for (const which of ["stop", "prompt"]) {
const r = hook(which, { transcript_path: transcript }, { MOSAIC_UNSLOP_LOG: log, UNSLOP_LISTS: writeTmp("hook-empty.json", "") });
assert.strictEqual(r.status, 0, r.stderr);
assert.strictEqual(r.stdout, "");
}
assert.deepStrictEqual(events(log).map((e) => [e.ev, e.at]), [["broken_lists", "stop"], ["broken_lists", "prompt"]]);
assert.ok(events(log).every((e) => /^lists\.json invalid: empty file/.test(e.reason)));
const bad = tmp("hook-bad.jsonl");
const r = hook("stop", "{not json", { MOSAIC_UNSLOP_LOG: bad });
assert.strictEqual(r.status, 0);
assert.deepStrictEqual(events(bad).map((e) => e.ev), ["hook_failed"]);
});
let failed = 0;
for (const [name, ok] of results) { console.log(`${ok ? "PASS" : "FAIL"} ${name}`); if (!ok) failed++; }
console.log(`${results.length - failed}/${results.length} passed`);
+7 -4
View File
@@ -19,6 +19,9 @@
// Provenance note (2026-08-19): the inline lists this file carried before C1
// moved to lists.json unchanged — 16 words, 17 phrases, 3 punct rules, 1 pattern.
// The suite pins those counts; a list edit without a test edit is a drift signal.
//
// Row 66 (#1559): each finding carries its entry's source, the pattern id, so a managed
// session's unslop.jsonl can count the ids a reply hit.
const fs = require("node:fs");
const path = require("node:path");
@@ -125,12 +128,12 @@ function checkText(raw) {
for (const w of lists.words) {
const re = new RegExp("\\b" + escapeRegex(w.value) + "\\b", "gi");
const count = (text.match(re) || []).length;
if (count > 0) findings.push({ rule: "word", detail: `banned word "${w.value}" x${count}`, count });
if (count > 0) findings.push({ rule: "word", source: w.source, detail: `banned word "${w.value}" x${count}`, count });
}
for (const p of lists.phrases) {
const count = phraseHay.split(p.value).length - 1;
if (count > 0) findings.push({ rule: "phrase", detail: `phrase "${p.value}" x${count}`, count });
if (count > 0) findings.push({ rule: "phrase", source: p.source, detail: `phrase "${p.value}" x${count}`, count });
}
// Density-gated punctuation. The deliberate divergence from SYSTEM.md's
@@ -140,14 +143,14 @@ function checkText(raw) {
for (const ch of pc.chars) count += text.split(ch).length - 1;
if (count < lists.thresholds.punctMinCount) continue;
if (count / Math.max(text.length, 1) * 1000 < lists.thresholds.punctDensityPer1k) continue;
findings.push({ rule: "punct", detail: `${pc.label} x${count} (density-gated)`, count });
findings.push({ rule: "punct", source: pc.source, detail: `${pc.label} x${count} (density-gated)`, count });
}
for (const pt of lists.patterns) {
const flags = pt.flags.includes("g") ? pt.flags : pt.flags + "g";
const m = text.match(new RegExp(pt.regex, flags));
const count = m ? m.length : 0;
if (count > 0) findings.push({ rule: "pattern", detail: `"${pt.detail}" x${count}`, count });
if (count > 0) findings.push({ rule: "pattern", source: pt.source, detail: `"${pt.detail}" x${count}`, count });
}
return { clean: findings.length === 0, findings, charsChecked: text.length };