Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2369c00e36 | ||
|
|
688280eefe |
+17
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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.
|
||||
@@ -411,6 +411,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.
|
||||
|
||||
@@ -14,7 +14,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'};
|
||||
@@ -31,6 +31,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -32,6 +32,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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user