Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27f01ef63a | ||
|
|
68f2dfd4b7 | ||
|
|
c19b4167ed | ||
|
|
e5e4b2e5af | ||
|
|
3b0aef9952 | ||
|
|
2c38325eca | ||
|
|
640aa03f27 |
+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. |
|
||||
@@ -110,3 +111,18 @@ argument, which Linux caps at 128 KiB; row 213 passes the file instead, so no
|
||||
local user reads the prompt in `/proc/<pid>/cmdline`. Pi 0.85.1 uses the path
|
||||
string itself as the prompt when the file is missing or can't be read, so the
|
||||
pi adapter exits 2 unless the path is a readable regular file.
|
||||
|
||||
### 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,191 @@
|
||||
# Row 251 build: T27 lists no identities right after `ssh-add -t 1` (Vikunja #453)
|
||||
|
||||
Brief `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` (rev 2931), Mos ruling mos-1185 item 2, assigned in
|
||||
sage-2075. Owner Darkwing, reviewer Dewey, gate owner Sage. Branch `darkwing/row251` from 3b0aef995,
|
||||
claimed in-progress at rev 2938.
|
||||
|
||||
## Step 1: the failure names its cause (e5e4b2e5a)
|
||||
|
||||
T27's two lists now carry a message that prints only when they fail. It gives the time from addKey's
|
||||
start to its resolve and to each `ssh-add -l` call, the hrtime at each with how far into its second it
|
||||
fell, `/proc/uptime` at each, and whether the agent process and its socket were still there. The checks
|
||||
themselves did not change. The commit holds nothing else.
|
||||
|
||||
## Step 2: the cause
|
||||
|
||||
ssh-agent counts a key's lifetime in whole seconds of CLOCK_BOOTTIME, at the add and at the reap. I read
|
||||
OpenSSH 10.5p1, the version on dragon-lin (`ssh -V`: OpenSSH_10.5p1), at tag V_10_5_P1 (ssh-agent.c
|
||||
sha256 873691c4, misc.c sha256 ee467f43):
|
||||
|
||||
- `misc.c:1790-1798` and `:1828`: `monotime()` returns `ts.tv_sec` of `clock_gettime(CLOCK_BOOTTIME)`,
|
||||
falling back to MONOTONIC. The fraction of the second is dropped.
|
||||
- `ssh-agent.c:1315`: the `-t` constraint sets `*deathp = monotime() + seconds`. Lines 1410 and 1598
|
||||
do the same for the agent's default lifetime.
|
||||
- `ssh-agent.c:1064`: the reaper drops a key when `now >= id->death`, `now` being `monotime()` again.
|
||||
- `ssh-agent.c:2650`: the main loop runs `reaper()` after every poll, before it reads requests. A list
|
||||
that arrives after the boundary sees the key gone.
|
||||
|
||||
So a key added at second s plus a fraction f dies at s + N, and `-t N` keeps it N - f seconds, between
|
||||
N - 1 and N. With `-t 1`, an add late in a second leaves the key milliseconds to live. That matches
|
||||
Sage's probe in the brief: 8 of 80 `-t 1` adds missed, each one made at 0.9947-0.9973 of a monotonic
|
||||
second and finished at 0.998-0.0008, and none earlier in the second missed. It also matches proof 5's
|
||||
failure, where the first list ran 31.9 ms into the test and found no identities.
|
||||
|
||||
Node's hrtime is CLOCK_MONOTONIC. BOOTTIME differs from it by the time the host spent suspended. On
|
||||
dragon-lin the difference read 0.000001 s at 19:09Z, so a monotonic second boundary is a BOOTTIME one
|
||||
here.
|
||||
|
||||
`addKey` and the agent start are not the cause. `addKey` passes the lifetime through as `-t`
|
||||
(`host-run.mjs:90`), and the agent starts with no default lifetime (`:175`). Production adds the key
|
||||
with `timeout + 30` (`:180`), where losing up to a second makes no difference. The only caller that
|
||||
can't afford the lost fraction is T27, which asks for 1 s. The fix belongs in the test's lifetime choice,
|
||||
and `host-run.mjs` doesn't change.
|
||||
|
||||
The slot confirms this against T27 itself (below), not just from the source.
|
||||
|
||||
## Step 3: the fix (this commit)
|
||||
|
||||
T27 adds the key with `-t 2`. The agent then keeps it more than 1 s and at most 2 s. The first list
|
||||
runs milliseconds after the add, so it has over a second of margin even under load. The sleep before
|
||||
the second list grows from 2.5 s to 3.5 s, which keeps the old 1.5 s margin past the new 2 s ceiling.
|
||||
Both checks stay: listed after the add, gone after the lifetime. A comment above the add says why the
|
||||
lifetime is 2.
|
||||
|
||||
I considered `-t 3` with a 4.5 s sleep for a 2 s floor. One second of floor is far above anything the
|
||||
first list has needed (31.9 ms in proof 5), and the concurrent-suite run below tests it under load, so I
|
||||
took the smaller change.
|
||||
|
||||
## Base
|
||||
|
||||
The branch stays on 3b0aef995. By 19:15Z origin/refactor was at 6096b051b, after rows 66 and 243
|
||||
landed. `git diff 3b0aef995 6096b051b` touches neither `packages/bus/src/host-run.mjs` nor
|
||||
`packages/bus/tests/host-run.test.mjs`, so there's no rebase (sage-2084). Row 243 did change
|
||||
`packages/bus/tests/process.test.mjs`. My slot's bus suites run that file as it was at 3b0aef995, and the
|
||||
gate rerun on Sage's slot runs it as landed.
|
||||
|
||||
## Prediction for part A on e5e4b2e5a (written before the slot)
|
||||
|
||||
The hook starts `ssh-add -t 1` at the given fraction of a monotonic second. From the brief's probe, the
|
||||
agent stores the key about 3 ms later. The first list reaches the agent a few ms after addKey resolves:
|
||||
Node's exit event, the mark, and `ssh-add -l`'s own start. A red needs the add to land before the second
|
||||
boundary and the list to land after it.
|
||||
|
||||
- 0.990: green. The add and the list both land before the boundary.
|
||||
- 0.993, 0.995, 0.996, 0.997: red expected. The add lands before the boundary, the list after. At 0.993
|
||||
and 0.997 I'm less sure, because the list (at 0.993) or the add (at 0.997) can land right at the
|
||||
boundary. A green at either is in line with the cause, and so is a red.
|
||||
- 0.998: green. The add lands after the boundary, so the key gets almost a full second.
|
||||
|
||||
Every red should show "first list got" an answer with no SHA256 line. It should also show addKey
|
||||
resolving before a second boundary and the first list starting after it or within a few ms of it, with
|
||||
the agent alive and its socket present.
|
||||
|
||||
Under sage-2084's rule, on e5e4b2e5a only a red at 0.990 or 0.998 counts toward the stop, and two such
|
||||
reds stop the run. On the fix commit any red counts, and two stop it. In parts B to D, the first red stops
|
||||
the run.
|
||||
|
||||
## Slot plan
|
||||
|
||||
All scripts are in `/mnt/storage/scratch/darkwing-251-out/`, launched from `/`, each behind the busy
|
||||
checks and chained with `&&`, TMPDIR under `/mnt/storage/scratch/tmp`.
|
||||
|
||||
1. Confirmation. `align-hooks.mjs` is a load hook, outside the repo, that serves `host-run.mjs` with
|
||||
`addKey` wrapped to start `ssh-add` at a chosen fraction of a monotonic second. T27 runs unchanged
|
||||
under it, alone (`--test-name-pattern`). It runs at fractions 0.990, 0.993, 0.995, 0.996, 0.997 and
|
||||
0.998, three times each:
|
||||
- on e5e4b2e5a (step 1, `-t 1`). Expected: some first lists fail, and the step 1 message shows the
|
||||
add's resolve and the list on either side of a second boundary, with the agent alive and the
|
||||
socket present. These reds are the point of the run.
|
||||
- on the fix commit (`-t 2`). Expected: all 18 pass.
|
||||
2. `host-run.test.mjs` whole, once, on the fix commit.
|
||||
3. The bus suite whole, three times in a row.
|
||||
4. The bus suite whole in two clean worktrees at the same commit, started together.
|
||||
|
||||
## Slot results
|
||||
|
||||
Sage's CLEAR came in sage-2154 at 20:58:55Z. `slot-251.sh` ran parts A and B from 20:59:22Z to 21:01:33Z,
|
||||
on node v26.8.1 and OpenSSH_10.5p1, with TMPDIR `/mnt/storage/scratch/tmp/r251`. At the start, BOOTTIME
|
||||
minus MONOTONIC read 0.000001 s, then 0.000000 s twice (dewey-0769 N1). So the hrtime fractions below
|
||||
put the agent's second boundary in the right place.
|
||||
|
||||
### Part A: T27 alone under `align-hooks.mjs`, 3 runs per fraction
|
||||
|
||||
The fraction is when the hook started `ssh-add`. The hook logged each start within 0.0001 of its target.
|
||||
|
||||
| fraction | e5e4b2e5a (`-t 1`) | 68f2dfd4b (`-t 2`) |
|
||||
|---|---|---|
|
||||
| 0.990 | 3 pass | 3 pass |
|
||||
| 0.993 | 3 pass | 3 pass |
|
||||
| 0.995 | 3 red | 3 pass |
|
||||
| 0.996 | 3 red | 3 pass |
|
||||
| 0.997 | 3 pass | 3 pass |
|
||||
| 0.998 | 3 pass | 3 pass |
|
||||
|
||||
e5e4b2e5a went red 6 times out of 18, every time at a fraction the prediction named, so none of them
|
||||
counted toward the stop. 68f2dfd4b passed all 18. The fix runs used 68f2dfd4b, whose test blob is the same
|
||||
as c19b4167e's (280132c7).
|
||||
|
||||
Every red failed on the first list and got `"The agent has no identities.\n"`, with the agent alive and
|
||||
its socket present. The step 1 message gave these hrtime fractions. "addKey start" is the mark before the
|
||||
hook's wait, so its fraction is earlier than the ssh-add start.
|
||||
|
||||
| run | addKey resolved | first list mark |
|
||||
|---|---|---|
|
||||
| 0.995 rep 1 | 0.9992 (+317.6 ms) | 0.9993 |
|
||||
| 0.995 rep 2 | 0.9991 (+846.5 ms) | 0.9991 |
|
||||
| 0.995 rep 3 | 0.9988 (+845.0 ms) | 0.9988 |
|
||||
| 0.996 rep 1 | 0.9999 (+839.8 ms) | 1.0000 |
|
||||
| 0.996 rep 2 | 0.0004 (+845.0 ms) | 0.0004 |
|
||||
| 0.996 rep 3 | 0.0002 (+835.9 ms) | 0.0002 |
|
||||
|
||||
At 0.995 the add finished about 4 ms in, before the boundary, and the list mark came just before it. The
|
||||
`ssh-add -l` process then reached the agent after the boundary. At 0.996 the agent stored the key before
|
||||
the boundary, and addKey resolved just after it. In both cases the key's whole second ended between the
|
||||
add and the list, which is the cause step 2 gives. The two edge fractions came out green on the first
|
||||
build. At 0.993 the list still reached the agent before the boundary, and at 0.997 the add landed after
|
||||
it, so the key got almost a full second. The prediction allowed either result at those two.
|
||||
|
||||
### Part B: `host-run.test.mjs` whole on 68f2dfd4b
|
||||
|
||||
24 tests, 24 passed (21:01:18Z, 14.0 s).
|
||||
|
||||
### The stop before C1, and the resume
|
||||
|
||||
At 21:01:33Z the script stopped before C1, with exit 4 and no red. My own `busy.sh` matched two `sleep 60`
|
||||
processes, mine, in `darkwing-r251/packages/bus`. Part B left them. They come from T27 "the start sweep
|
||||
keeps a live bus dir and kills only a matching live process" (`host-run.test.mjs:528` and `:536`). Its
|
||||
`t.after` SIGKILLs the `/bin/sh -c 'sleep 60; :'` parent, but the `sleep` child lives on for up to 60 s.
|
||||
That code is outside this row's diff, so the row leaves it alone, and it's recorded below as a follow-up.
|
||||
Sage confirmed (sage-2159) that the right move is to resume only the scope that hadn't run.
|
||||
|
||||
`slot-251-cd.sh` runs C and D as `slot-251.sh` has them, with the same two busy checks. Before each check,
|
||||
it waits up to 75 s for those orphans to exit: my uid, my trees, a command line of exactly `sleep 60`.
|
||||
It logs them as it waits. Any other process still trips the checks.
|
||||
|
||||
### Parts C and D: the bus suite whole on 68f2dfd4b
|
||||
|
||||
| run | start | tests | pass | fail | time |
|
||||
|---|---|---|---|---|---|
|
||||
| C1 | 21:02:48Z | 598 | 598 | 0 | 21.6 s |
|
||||
| C2 | 21:04:06Z | 598 | 598 | 0 | 21.4 s |
|
||||
| C3 | 21:05:26Z | 598 | 598 | 0 | 22.1 s |
|
||||
| D1, darkwing-r251 | 21:06:44Z | 598 | 598 | 0 | 24.2 s |
|
||||
| D2, darkwing-r251b, TMPDIR `r251-b` | 21:06:44Z | 598 | 598 | 0 | 24.2 s |
|
||||
|
||||
The script waited 33 s before C2, 34 s before C3, 33 s before D and 32 s before the sweep. Each time it
|
||||
was waiting for that run's `sleep 60` orphans (two per suite, four after D), and nothing else. After the
|
||||
wait, the sweep of my trees, the out dir and TMPDIR found 0 processes left (21:08:04Z). Sage confirmed
|
||||
(sage-2159, sage-2160) that the short gitea-api and `node --check` runs they made during the slot weren't
|
||||
stops. None of my busy checks refused on any of them.
|
||||
|
||||
These runs used the bus files as they were at 3b0aef995. Since then, origin/refactor (666173292, then
|
||||
7247d80c7 and c3d88840c with row 196) has changed `broker.mjs`, `runtime.mjs` and `server.mjs`, which T27's
|
||||
file imports, along with other bus files. None of those commits touch this row's two paths or
|
||||
`host-run.mjs`, and `git merge-tree` of c3d88840c with 68f2dfd4b is clean. The gate rerun in
|
||||
a clean worktree on Sage's slot runs T27 against them.
|
||||
|
||||
### Follow-up, not in this row
|
||||
|
||||
T27's start-sweep test leaves the `sleep 60` child of each planted shell running for up to 60 s after
|
||||
the test. Killing the process group (`process.kill(-pid)`; both shells are spawned `detached`) or
|
||||
`exec sleep 60` in the shell would end the child along with its parent.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Row 243: too-open mode refusal tests set their mode with chmod
|
||||
|
||||
Brief: `docs/plans/2026-10-21_gate-env-rows.md` § "The startup token refusal test sets its file mode"
|
||||
(Sage, sage-1909). Reviewer Webby. Lands after row 197 (mos-1162). Design notes written before 197
|
||||
lands, read at `origin/refactor` 01e1fe512. Recheck the line cites against 197's landing before code
|
||||
starts.
|
||||
|
||||
## The defect
|
||||
|
||||
`writeFileSync(file, data, { mode })` and `mkdirSync(dir, { mode })` apply the process umask to the
|
||||
mode. A test that creates a "too open" file this way and expects a refusal passes under `umask 022`
|
||||
(0644 stays 0644) and fails under `umask 077` (0644 becomes 0600, and the source accepts it).
|
||||
|
||||
Row 197 r6 (df0c59608) runs each gate step under `umask 022` (`gate-lane.mjs:46`), so the lane no
|
||||
longer hits this. Row 243 still makes the tests independent of the umask, as the brief asks.
|
||||
|
||||
## Sweep
|
||||
|
||||
Method: every source-side mode check in `packages/*/src` and `scripts/` (grep for `mode & 0o...`), then
|
||||
the tests that reach each check's refusal branch, deciding whether the too-open object gets its mode at
|
||||
creation or from an explicit chmod. Also a scan of every `writeFileSync`/`mkdirSync`/`openSync` in
|
||||
`packages/*/tests` with a group or other bit in its mode (50 hits, 41 without a following chmod), and a
|
||||
grep of the refusal messages for objects created with no mode or by a shell-out. Read at
|
||||
`origin/refactor` with `git show` and `git grep`; nothing run.
|
||||
|
||||
Affected refusal tests, all three verified by reading the test (the fourth file, an accept test, follows the table):
|
||||
|
||||
| Creation | Test | Source check | Expected refusal |
|
||||
|---|---|---|---|
|
||||
| `packages/bus/tests/process.test.mjs:76` `writeFileSync(file, secret, { mode: 0o644 })` | startup token refusal returns safe code... | `bus/src/credentials.mjs:205` (must be 0600) | `credential-file`, exit 2 (:83-84) |
|
||||
| `packages/webui/tests/proof-cli.test.mjs:28` `writeFileSync(file, value, { mode })`, mode 0o644 from the `mode` case at :27 | proof startup refuses partial flags... | `webui/src/serve.mjs:234` (0600 or 0400) | status 2 (:38) |
|
||||
| `packages/webui/tests/sso.test.mjs:360` `writeFileSync(.../secrets/mosaic-stack/a, ..., { mode: 0o644 })` | 11: the CLI refuses --sso... | `webui/src/sso.mjs:45` (no group or other bits) | status 2 and the "no group or other permission bits" line (:361-363); the chmod to 0600 at :364 comes after |
|
||||
|
||||
Fix for each: keep the write, then `chmodSync(<path>, 0o644)` right after it. For `proof-cli.test.mjs`
|
||||
the chmod goes inside the case map after the write, with the case's own mode, so the 0o600 cases are set
|
||||
explicitly too.
|
||||
|
||||
Not affected (each sets the mode with a chmod or a chmodding helper before the code under test reads it):
|
||||
bus credentials.test :52, credential-store.test :55-61; business access.test :225-226, business.test
|
||||
:159/161/174, credentials.test :60, cli.test :151, helpers `writeJson`/`tokenFile`; cli cli.test :176,
|
||||
notifier.test :318/:321, stack-release.test :1220/1239/1294/1313/1552, stack-upgrade.test :426-430,
|
||||
case2-unit.test :340/:510/:635; control-board discord.test :79; discord binding.test :106/118/140/277,
|
||||
notify.test :76, setspark.test :110, git.test :138/:360, offline-check.test :21; mosaic safety.test :80;
|
||||
queue review.test :214/:228; ledger gitea-helper-raw.test :113; webui service.test :797, sso.test :310;
|
||||
bus host-tools.test :182/:266. The 0o644/0o755 adapter and fake-binary writes (launcher, thread-launch,
|
||||
session, sandbox, checkout, commit, etc.) aren't read by a mode check, or are only checked for the exec
|
||||
bit, which 0700 keeps. `host-tools.test.mjs:205`, `:271` and the confDir at `:77` expect acceptance and
|
||||
only need "not group-writable".
|
||||
|
||||
Mode checks with no refusal test at all (not this row; listed for the record): `bus/src/store.mjs:121`,
|
||||
`bus/src/host-run.mjs:71`, `business/src/organization.mjs:28/45/109/141`,
|
||||
`conversation/src/controller.mjs:299`, `mosaic/src/materialize-fixture.mjs:16`,
|
||||
`mosaic/src/refresh-fixture.mjs:64`, `seat/src/session.mjs:72`, `scripts/git-credential-mosaic.mjs:49`,
|
||||
and the mode branch of `scripts/stack-release.sh:1952`.
|
||||
|
||||
Fourth file, folded in by Sage (sage-1918): `packages/cli/tests/stack-instances.test.mjs:84` and `:89`
|
||||
assert 0644 on the config and `instance.json`. `scripts/mosaic-config.mjs:337` creates them with
|
||||
`openSync(file, "wx", 0o644)` and no chmod, so under `umask 077` they come out 0600 and the test fails.
|
||||
This is an accept test of a source mode, not a refusal test.
|
||||
|
||||
| Creation | Test | Source | Ruling |
|
||||
|---|---|---|---|
|
||||
| `scripts/mosaic-config.mjs:337` via `bootstrap()` (`stack-instances.test.mjs:41`, a `spawnSync` of `scripts/bootstrap.sh`) | the `--instance` create test, :79-89 | `createExclusive` passes 0o644 and the umask narrows it | test-only (sage-1918) |
|
||||
|
||||
- No change to `mosaic-config.mjs`. Letting the operator's umask narrow the config is the source's
|
||||
choice; a chmod to 0644 would make the config wider than that umask. No row asks for it.
|
||||
- `bootstrap()` runs its child under an explicit `umask 022`, the pattern from `gate-lane.mjs:46`:
|
||||
`run("/bin/bash", ["-c", 'umask 022; exec "$@"', "bash", BOOTSTRAP, ...args], homeEnv(home))`. Every
|
||||
`bootstrap()` call goes through it (:79, :99, :102, :119, :127, :136, :140, :152, :165, :169), so the
|
||||
0644 assertions still prove the mode the source asks for, whatever the gate's umask.
|
||||
- New assertion: a `bootstrap --instance` run under `umask 077` makes the config and `instance.json`
|
||||
0600. That pins that the source honors the umask, so a later chmod "fix" fails a test. The directories
|
||||
stay 0700 in both runs (they're created with `mode: 0o700`, which 077 can't narrow).
|
||||
- The `config()` helper (:42) has no mode assertion and stays as it is.
|
||||
|
||||
Tests outside `packages/*/tests`: the five `scripts/foundation/*.test.mjs` files have no open-mode
|
||||
write. `cli.test.mjs:144/148` chmods to 0o000 and 0o600, which doesn't depend on the umask. The
|
||||
`agents/*/work` probe tests are review evidence, not gate steps (`discoverSteps` doesn't run them), so
|
||||
they're out of scope.
|
||||
|
||||
## Tests
|
||||
|
||||
- Each of the four files runs whole twice, once under `umask 022` and once under `umask 077`
|
||||
(`bash -c 'umask 077; node --test <file>'`). Both pass. The brief requires this for process.test.mjs;
|
||||
Sage extended it to all four (sage-1918).
|
||||
- Proof that the change matters: before the change, the same four files under `umask 077` fail on the
|
||||
named assertions. I record that run too, on the slot, so the 077 pass after the change is meaningful.
|
||||
- Order (mos-1162): this lands after 197 r6, so r6's proof ran on the unchanged test.
|
||||
|
||||
## Files (expected)
|
||||
|
||||
`packages/bus/tests/process.test.mjs`, `packages/webui/tests/proof-cli.test.mjs`,
|
||||
`packages/webui/tests/sso.test.mjs`, `packages/cli/tests/stack-instances.test.mjs`. Test-only. No
|
||||
source change.
|
||||
|
||||
## Build (sage-2056: start on row 197's landing a36523713)
|
||||
|
||||
Branch `morgana/row243`, one commit on a36523713. Cites rechecked there: all hold except proof-cli's
|
||||
write, which is now `proof-cli.test.mjs:30` (was :28).
|
||||
|
||||
- `bus/tests/process.test.mjs`: imports `chmodSync`; `chmodSync(file, 0o644)` after the write at :76.
|
||||
- `webui/tests/proof-cli.test.mjs:30`: `chmodSync(file, mode)` after the write, with each case's own
|
||||
mode, so the 0o600 cases are set explicitly too.
|
||||
- `webui/tests/sso.test.mjs:360`: `chmodSync(..., 0o644)` after the write, before the `--sso` start
|
||||
that must refuse.
|
||||
- `cli/tests/stack-instances.test.mjs`: `underUmask(mask, home, args)` runs `scripts/bootstrap.sh`
|
||||
through `/bin/bash -c 'umask <mask>; exec "$@"'`; `bootstrap()` is `underUmask("022", ...)`. New
|
||||
subtest in T-I1: under umask 077 the config dir and root are 0700, and the config and `instance.json`
|
||||
are 0600.
|
||||
- Hash pins: no test or script names any of the four files (a grep for the basenames; positive control
|
||||
`stack-release.test` found in two files). `stack-release.test.mjs:2122` mentions
|
||||
`stack-instances.test.mjs` only in a comment.
|
||||
|
||||
Tests: on Sage's slot (sage-2062, plan approved in sage-2059), 18:55Z. My busy check read CLEAR before
|
||||
and after. Each file ran whole with `node --test --test-concurrency=2` from its package, under
|
||||
`bash -c 'umask <mask>; exec ...'`, with TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/g243XXXX`.
|
||||
Script `/mnt/storage/scratch/morgana-r243/slot243.sh`, logs in `logs/` next to it.
|
||||
|
||||
| Tree | umask | process | proof-cli | sso | stack-instances |
|
||||
|---|---|---|---|---|---|
|
||||
| base a36523713 | 077 | 7/1 | 2/1 | 27/1 | 21/2 |
|
||||
| candidate 137bda3af | 022 | 8/0 | 3/0 | 28/0 | 24/0 |
|
||||
| candidate 137bda3af | 077 | 8/0 | 3/0 | 28/0 | 24/0 |
|
||||
|
||||
(pass/fail.) The base fails are the predicted ones: "startup token refusal returns safe code...",
|
||||
"proof startup refuses partial flags...", "11: the CLI refuses --sso...", and in stack-instances the
|
||||
0644 subtest "alpha and beta get a 0700 config dir..." plus its parent T-I1 (so 2). stack-instances has
|
||||
one more test on the candidate (the umask 077 subtest). No unpredicted failure.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Row 66: managed sessions run the unslop check on both harnesses
|
||||
|
||||
Sage, 2026-10-20. Brief: `docs/plans/2026-10-11_managed-session-config.md`, section "Managed
|
||||
sessions run the unslop check on both harnesses", blob 21ae25a328d4. Issue #1559. Reviewers:
|
||||
Dewey and Filbert. Built in the worktree `/mnt/storage/scratch/sage-b66` (branch `sage/row66`)
|
||||
on base 5487ed39d.
|
||||
|
||||
Candidate: see the commit line in the review request. `candidate-manifest.sha256` in this
|
||||
directory has the sha256 of each changed file at the candidate.
|
||||
|
||||
## What changed
|
||||
|
||||
**Wiring.** Both harness modules mount `tools/unslop-hook/lists.json`, `unslop-check.js` and
|
||||
the harness's own entry (pi `extension.ts`, Claude Code `claude-hook.js`) read-only at their
|
||||
own paths, and the bundle manifest lists the three in `sources` with their digests. Pi loads
|
||||
`extension.ts` as the second entry of `MOSAIC_EXTENSIONS`, after the gate extension. Claude
|
||||
Code's `settings.json` gains a Stop hook and a UserPromptSubmit hook, each
|
||||
`timeout -k 2 10 '<node>' '<dir>claude-hook.js' stop|prompt; exit 0`, hook timeout 20, no
|
||||
matcher. Neither can block a reply or a prompt.
|
||||
|
||||
**claude-hook.js** (new). `stop` checks each assistant message with text since the turn's
|
||||
prompt and logs `checked` or `flagged`. `prompt` checks the last reply and, when it has tells,
|
||||
prints the extension's notice, word for word, as `additionalContext`, once per reply (it reads
|
||||
the log for an earlier `notice_injected` with the same entry id). A broken lists.json logs
|
||||
`broken_lists`; any other fault logs `hook_failed`; the exit is always 0.
|
||||
|
||||
**The operator channel.** The runner sets `MOSAIC_UNSLOP_LOG` to `<run>/unslop.jsonl` after the
|
||||
module's variables, so a module can't point it elsewhere. After the landing turn and after each message turn it reads what the
|
||||
log gained (whole lines only, from an offset). The next RESULT it sends ends with a note. The
|
||||
whole note, both parts and the blank line before them, is at most 600 characters (r2):
|
||||
|
||||
- `[unslop: N reply|replies of this session flagged since the last note: <details>. The session gets a correction notice on its next turn.]`
|
||||
- `[unslop: the check is broken, so this session's replies go unchecked: <reason>]`, once a run.
|
||||
|
||||
That RESULT is what `mosaic talk` and `mosaic follow` print. A turn that sends no reply (an
|
||||
incoming RESULT, the landing turn) keeps its note for the next one. `note()` doesn't change
|
||||
state and `sent()` runs only after `message.send` succeeds, so a failed send keeps the note.
|
||||
`clip()` caps the body at 32000 characters plus its 33-character cut line before the note, so the
|
||||
sum is at most 32633, under the bus limit of 32768. The broken reason is cut at 300 where the
|
||||
runner takes it, from the log or from its own read failure, so the broken part is at most 371
|
||||
characters and the flagged part is cut to what is left.
|
||||
|
||||
**Counts.** `unslopCounts(path)` reads the log after the session ended:
|
||||
`{checked, flagged, patterns: {<source>: n}, notices, broken}`. A missing log gives zeros.
|
||||
Findings now carry `source`, the entry's pattern id (`ms-unslop:7`), so `patterns` is per id.
|
||||
|
||||
**The stack copy (r2).** `scripts/stack-copy.sh` `COPY_PATHS` gains `tools/unslop-hook`. Both
|
||||
modules join their files under `REPO`, and a session launched from a pinned copy has
|
||||
`REPO = copies/<sha>`. Without it every managed session from a copy past this row would fail at
|
||||
bundle build with ENOENT (Dewey P1, comment 29440).
|
||||
|
||||
## Round 2
|
||||
|
||||
Round 1 (candidate 8ec03b45a, manifest e68c6bce5ec4) got changes from Filbert (29439) and Dewey
|
||||
(29440). Round 2 carries:
|
||||
|
||||
- F1 (both): a note with both parts wasn't bounded. A 300-character reason, a long flagged part
|
||||
and a clipped answer came to 32934 (Filbert) or 33004 (Dewey), over 32768, and the broker
|
||||
refused the send. `note()` now gives the flagged part only the room the broken part leaves, and
|
||||
`fresh()` cuts every reason at 300, the runner's own read-failure reason included. Tests: the
|
||||
runner test with an answer of 40000 characters, a 400-character `hook_failed` reason and 30
|
||||
flagged replies (note ≤ 600, body ≤ 32768, cut line at 32000), and a unit check that both
|
||||
parts come to exactly 600 and that a read failure on a 250-character path is cut.
|
||||
- F2 (Filbert; Dewey's M3): no test failed a send through `main()`. The new runner test has the
|
||||
broker refuse the first RESULT. The note then rides the next RESULT. One correction to the
|
||||
F1 consequence as written: the broker marks a received message delivered, so the refused
|
||||
message isn't received again in that run. It stays unread and its sender gets no reply; the
|
||||
turn doesn't rerun.
|
||||
- P1 (Dewey): `COPY_PATHS` gains `tools/unslop-hook`. The copy fixtures in
|
||||
`stack-copy.test.mjs` and `pinned-copy.test.mjs` copy `tools/unslop-hook` too, and the
|
||||
pinned-copy top-level list gains `tools`. A new `stack-copy.test.mjs` test makes a copy, loads
|
||||
both harness modules from it, and checks that the adapter, the six unslop paths and the mounts
|
||||
are under the copy and exist.
|
||||
- N1 (Filbert): the two session-test comments say `<dir>/unslop.jsonl`.
|
||||
- N2 (Filbert), doc-only follow-up: the brief's `<run>/session/unslop.jsonl` (:463) and "print
|
||||
mode notifies nobody" (:477) are stale. The brief is Jason's text for this row; the deviation
|
||||
below records the path.
|
||||
- Dewey's notes, no change: the Claude Code Stop timing (finding 3 below) gets a look in the
|
||||
landing session; `patterns` keys per entry source, which is the brief's "per pattern".
|
||||
|
||||
r2 mutants, each killed by its intended test (`mutants-r2.log`):
|
||||
|
||||
| Mutant | Killed by |
|
||||
|---|---|
|
||||
| K1 the r1 `note()` cut | the clipped-answer runner test (the send is refused and it times out) and the unit check |
|
||||
| K2 `fresh()` doesn't cut the reason | the unit check |
|
||||
| K3 `sent()` before `message.send` | the refused-send runner test |
|
||||
| K4 `COPY_PATHS` without `tools/unslop-hook` | the copy test |
|
||||
|
||||
## Ship list against the brief
|
||||
|
||||
| Brief item | Where |
|
||||
|---|---|
|
||||
| A reply with a tell gets a correction notice on the next turn, both harnesses | `pi-session.test.mjs`, `claude-session.test.mjs`, row 66 four-turn tests: notices per request `[0, 1, 1, 2]`, notice text matched in the second request |
|
||||
| A clean reply gets none | same tests: turn 2 is clean and turn 3's request carries no new notice |
|
||||
| Broken lists.json fails closed: no clean verdict, operator told once, session alive | the row 66 broken-lists tests per harness: checked 0, flagged 0, first note broken, second note empty, the session answers both turns |
|
||||
| Counts readable after the session ends | `unslopCounts` on each session's log: `{checked: 4, flagged: 2, notices: 2, broken: null}`, patterns non-empty |
|
||||
| Interface 1 and the export list unchanged | no harness module export added or removed; the runner gains `unslopLog`, `unslopCounts`, `unslopNotes` |
|
||||
|
||||
## Findings made while building
|
||||
|
||||
1. **extension.ts read the wrong reply.** Pi 0.85.1 `getBranch()` returns entries root first
|
||||
(`session-manager.js:958-967` reverses the leaf-to-root walk). `before_agent_start` took the
|
||||
first assistant entry it met, which was the session's first reply, every turn. In the
|
||||
four-turn test the old code gave notices `[0, 1, 2]` over three turns (turn 3 re-noticed
|
||||
turn 1); walking from the end gives `[0, 1, 1]`. Fixed in `extension.ts`; this also affects
|
||||
the fleet copy under `~/.mosaic/tools`, which I didn't touch.
|
||||
2. **claude-hook replies() merged replies.** Grouping by `message.id` across the transcript
|
||||
merged two replies that shared an id (the test API answers `msg_mock` for all). It now groups
|
||||
adjacent assistant lines only, keyed by the first line's uuid. Before the fix turn 4 got no
|
||||
notice (`[0, 1, 1, 1]`).
|
||||
3. **Claude Code Stop timing.** On the first turn of a new session the transcript didn't yet
|
||||
hold the reply when Stop ran, so the hook checked `last_assistant_message` with an empty
|
||||
`entryId`. On resumed turns the reply was in the transcript. The verdict is the same either
|
||||
way; only the entry id differs.
|
||||
4. The finding detail format is `banned word "delve" x1`.
|
||||
|
||||
## Deviations
|
||||
|
||||
- **The log path is `<run>/unslop.jsonl`, not the brief's `<run>/session/unslop.jsonl`.** A
|
||||
thread run's session directory is the thread's store, and
|
||||
`packages/cli/tests/thread-launch.test.mjs:179` pins that a thread run keeps no per-run
|
||||
`session/`. My first build made `<run>/session` for the log and failed that test in the gate
|
||||
(452/453). The brief's other line, "`unslop.jsonl` in the run directory", still holds, and each
|
||||
run keeps its own log.
|
||||
|
||||
- **`packages/seat/tests/sandbox.test.mjs`** isn't an owned file. Its pin
|
||||
`binds(args).length === list.length - 1` broke because the modules now mount three more files.
|
||||
It is now `- 4`, and the test asserts the three unslop read-only binds by name.
|
||||
- **`scripts/stack-copy.sh`** and the two copy tests aren't owned files. The copy change is P1
|
||||
above. PB3 of the Part B window (`repin-partB-proof.md`) holds `scripts/stack-copy.sh` still
|
||||
until B7, so this row lands after the Part B window has run.
|
||||
- `MOSAIC_EXTENSIONS` is set through the pi module's `env`, not in the runner.
|
||||
- `unslop-check.js` gained the `source` field on findings (needed for per-pattern counts).
|
||||
|
||||
## Limits
|
||||
|
||||
- The log is in the run directory, which the session can write. A session could forge or erase
|
||||
its own counts. They are the session's report, not host evidence.
|
||||
- The pi extension's dedupe is in memory: after a restart, or when a turn's final message is
|
||||
tool-only, the same earlier reply can get a second notice.
|
||||
- `hook_failed`, `skipped_broken` and `broken_lists` all count as broken for the operator note.
|
||||
- No blocking (Q84 option B), no list changes, no skill changes: out of scope by the brief.
|
||||
|
||||
## Gate
|
||||
|
||||
Recorded with the candidate in the review request: test-unslop-check, the harness, plugins,
|
||||
seat and cli suites, and every `scripts/test-*.sh`. At landing Sage runs one managed session per
|
||||
harness and attaches the notice, the correction and the counts.
|
||||
|
||||
## Round 4
|
||||
|
||||
Round 3 (2369c00e3 on sage/row66-r3, base c40d846cb, manifest 42e3876ca168) had approvals from
|
||||
Filbert (29770) and Dewey (29771). Since then rows 200, 199, 232, 233, 240 and 238 changed three
|
||||
of the 23 paths: `docs/TOOLS.md`, `scripts/stack-copy.sh` and
|
||||
`packages/cli/tests/stack-copy.test.mjs`. Round 4 is the two round 3 commits cherry-picked onto
|
||||
4ced5e63d (sage/row66-r4). Neither pick had a conflict, and the tree equals a hand merge of each
|
||||
path with `git merge-file`. No code changed: `git diff BASE TIP | git patch-id --stable` is
|
||||
6d085621fd35 for both rounds.
|
||||
|
||||
- Row 232 added `copy --bundle` (`copy_bundle`). It builds the bundle with `copy_tree`, which
|
||||
copies `COPY_PATHS`, so a bundle copy also carries `tools/unslop-hook`. No extra change needed.
|
||||
- The Part B hold under Deviations is void. Part B was superseded by the Q175 SSO work and will
|
||||
not run, so nothing holds `scripts/stack-copy.sh`.
|
||||
- Review scope: the merge on the three moved paths and this section. The other 19 paths have the
|
||||
same blobs as round 3.
|
||||
|
||||
Rebased again on fefd54e13, after row 212 landed (ee7b2f137), as branch `sage/row66-r4b`, before the round 4
|
||||
request went out. 212 added `packages/library/src` to `COPY_PATHS` and a library line to the
|
||||
`stack-copy.test.mjs` file list. None of the four picks had a conflict. Every file's patch-id equals the 4ced5e63d
|
||||
build's except `scripts/stack-copy.sh`, where the only change is the hunk's context line
|
||||
(`packages/queue/src packages/library/src`). Row 66's two added lines are the same.
|
||||
|
||||
Rebased again on 29ce1e360, after rows 195, 213 and 231 landed, as branch `sage/row66-r4c` (r4b stays at
|
||||
49ad61963). Two of row 66's paths moved: `adapters/README.md` and `docs/TOOLS.md`. TOOLS.md merged without a
|
||||
conflict. In adapters/README.md, row 213 rewrote the skill-cap paragraph, because `claude` now gets the prompt
|
||||
as a file. The merge keeps 213's paragraph whole and adds row 66's "The unslop check (row 66)" section after
|
||||
it. Row 66's old cap sentence is dropped, since it described the argument limit that 213 removed. Every other
|
||||
path has the same blob as on r4b. Row 213 also changed the harness and both adapter scripts, so all of row 66's
|
||||
harness tests run whole again on this base before the request.
|
||||
|
||||
Rebased again on 7b9737953, after rows 245, 248 and 249 landed, as branch `sage/row66-r4d` (r4c stays at
|
||||
670b83f65). Only `docs/TOOLS.md` moved under row 66: row 245 rewrote the paragraph on what root reads in the
|
||||
host check. The cherry-pick had no conflict. `git patch-id --stable` of row 66's whole diff is 508bb684eade on
|
||||
both bases, and so is each file's own patch-id, TOOLS.md included. So row 66's hunks are byte-identical and only
|
||||
their context moved. The one test that names TOOLS.md, `packages/cli/tests/stack-release.test.mjs`, uses it in a
|
||||
message string and is in the singles list.
|
||||
|
||||
Rebased on a36523713 after row 197 landed (mos-1186 item 4), as branch `sage/row66-r5` (r4d stays at
|
||||
82c830779). Row 197 also touched `packages/harness/tests/claude-session.test.mjs` and `pi-session.test.mjs`.
|
||||
The cherry-pick had no conflict. The range-diff shows commit 1 differing only in one hunk header of
|
||||
pi-session.test.mjs, where git now names the context line. Commits 2-7 are `=`. `git patch-id --stable` is
|
||||
0049d05fb585 for the whole diff and d67275ac4251 without `agents/` on both bases. Correction: the r4d note
|
||||
above gives 508bb684eade for the whole diff. That number came from e25f76f54 before the 7b9737953 rebase. The
|
||||
whole-diff number on r4d is 0049d05fb585. The harness suite runs whole again on this base before landing.
|
||||
|
||||
Correction (Filbert round 4 N2, comment 30086): the r4c paragraph says "Row 66's old cap sentence is
|
||||
dropped." That's wrong. In round 3's diff the 64 KiB cap sentence is unchanged context, so row 66 never
|
||||
wrote it. Row 213 replaced it, and none of row 66's lines were dropped. The landing sessions are in
|
||||
LANDING-SESSION.md.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Row 66 landing sessions (mos-1186 items 2-3)
|
||||
|
||||
Sage, 2026-10-21. Both ran on a scratch data root built from the candidate tree, branch `sage/row66-r5`
|
||||
at 273fff6f9 on a36523713, as row 64's D2 did (rc13bcd555652). The scripts were
|
||||
`/mnt/storage/scratch/sage-r66r5x/d2s/d2-build.mjs` and `d2-drive.mjs`. They used the business example
|
||||
with stand-in token files, tracker and Gitea URLs pointing at 127.0.0.1:9, and a scratch project repo.
|
||||
Nothing live was touched. The six skills were installed and enabled on each data root with
|
||||
`scripts/skill.sh` from the candidate tree.
|
||||
|
||||
Before the sessions, the harness suite ran whole on 273fff6f9: 149 pass, 0 fail, 12 s
|
||||
(2026-10-21T18:47:42Z).
|
||||
|
||||
## Claude Code, run r4ab24630d323 (18:48Z)
|
||||
|
||||
The chief and cto were bound, and the coder was launched through `launcher.fromSession` on claude-code,
|
||||
model claude-sonnet-5-5. The launch bundle names `tools/unslop-hook/claude-hook.js`, `unslop-check.js`
|
||||
and `lists.json`.
|
||||
|
||||
- Turn 1. cto asked for one sentence with four banned words. The RESULT body `mosaic talk` would print
|
||||
ends with the operator note:
|
||||
`[unslop: 1 reply of this session flagged since the last note: banned word "additionally" x1; banned word "crucial" x1; banned word "testament" x1; banned word "vibrant" x1. The session gets a correction notice on its next turn.]`
|
||||
- Turn 2. `unslop.jsonl` logs `notice_injected` for that turn. The coder's transcript has the notice as
|
||||
UserPromptSubmit hook additional context in the API request:
|
||||
`UNSLOP NOTICE (mechanical style check, not the user speaking): your previous reply contained violations of the fleet writing standard (SYSTEM.md / ms-unslop): ...`.
|
||||
cto asked the coder to quote it. The coder answered "I can't answer that one, and I won't quote
|
||||
anything from it." So the notice reached the model, which chose not to repeat it. Its reply checked
|
||||
clean.
|
||||
- Counts from `unslopCounts`: checked 2, flagged 1, patterns `ms-unslop:7` 4, notices 1, broken null.
|
||||
|
||||
This scratch instance has no `mosaic talk` view of its own, so the note above is the RESULT body the bus
|
||||
returned to cto. That is the text `mosaic talk` and `mosaic follow` print.
|
||||
|
||||
## pi, run r1dd94171bede (launch only)
|
||||
|
||||
The coder launched on pi, model claude-sonnet-5-5. The launch bundle names
|
||||
`tools/unslop-hook/extension.ts`, `unslop-check.js` and `lists.json`. No turn ran. This host has no
|
||||
credential pi reads until P19 #383 settles, and mos-0865 D2 rules out mapping the Claude Code token into
|
||||
a pi variable. No `unslop.jsonl` was written. Counts: checked 0, flagged 0, notices 0.
|
||||
|
||||
## Still open
|
||||
|
||||
Vikunja #454 tracks both live checks. Claude Code runs in the first re-pin whose tree includes row 66,
|
||||
backup first. Pi runs one full session with a tell after #383. See `docs/plans/DEFERRED.md`.
|
||||
@@ -418,6 +418,7 @@ One read-only tree supplies the bus, Console and human CLI (row 85,
|
||||
`copy` requires a canonical checkout (.git directory); --allow-worktree
|
||||
is a test override. COMMIT must be reachable from both local refactor and
|
||||
origin/refactor. It archives the runtime/import closure, adapters, roles,
|
||||
`tools/unslop-hook` (the unslop check both harness modules mount, row 66),
|
||||
RELEASE, package.json and package-lock.json; refuses missing manifests;
|
||||
runs npm ci inside the temporary copy; removes write permissions; and
|
||||
renames beside the target. A published existing copy stays unchanged.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Brief: Console inbox Approve and Deny for grant requests (Vikunja #435, J2)
|
||||
|
||||
Sage, 2026-10-21, for Mos's order mos-1193 (Jason asked at 18:35Z to test J2 sooner). Vikunja #435, parent
|
||||
#424, DECISIONS Q188. Owner Gander, reviewer Webby, gate owner Sage. Chained on row 196's candidate (Q177):
|
||||
the design starts now on Gander's row 196 build. The code lands after row 196 lands and rebases onto that
|
||||
landing. Design first. The first deliverable is `agents/gander/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
## What Jason asked for
|
||||
|
||||
Q188, as #435 records it: the chief surfaces each grant request to Jason's inbox in the WebUI, and he clicks
|
||||
Approve or Deny. It needs the Console to know who is signed in (Q175 SSO, #412). Until then Approve is
|
||||
`mosaic decide`.
|
||||
|
||||
## Where things stand
|
||||
|
||||
- The Console inbox shows decisions and prints `mosaic decide <id> <key>` for each choice
|
||||
(`packages/webui/README.md`, Inbox section). It takes no human writes: without `--send` every method
|
||||
other than GET and HEAD answers 403 `read-only`, and the live unit runs without it (row 189).
|
||||
- Q175 phase 1 (row 192, landed 4429a104f) builds the Console's own OIDC session. It is live only after the
|
||||
phase 2 re-pin window turns on `--sso`. Under `--sso` every request other than `GET /healthz` needs a
|
||||
verified session.
|
||||
- Row 196 (Gander, Duckworth) settles how the bus tells the human's own call from a same-uid process. A
|
||||
Console write that resolves a decision must go through whatever row 196 lands, not around it.
|
||||
- The PRD doesn't allow this yet. REQ-DEC-3 says a human resolution comes only from a `mosaic` CLI session
|
||||
started outside any agent run. Jason approved REQ-WEB-2 and the REQ-DEC-3 amendment in decision
|
||||
00ed58e2 (Vikunja 19/266), and the PRD holds the id REQ-WEB-2 for it (1.8, "held for the Console decide
|
||||
buttons amendment"). That amendment must land before this row's code does. PRD wording goes to Mos first,
|
||||
then Jason. This row doesn't edit the PRD.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Which decisions get the buttons. #435 names grant requests (Q188). Say whether the first cut covers only
|
||||
those or every open decision under REQ-WEB-2, and why.
|
||||
2. The identity. The resolving human is the verified OIDC subject from the Console session (row 192), mapped to
|
||||
an authorized human the bus knows. Nothing comes from a proxy header or a shared secret (Q175). A request
|
||||
from an agent session or a seat credential is refused.
|
||||
3. The write path. How the Console's POST reaches the bus's decide, using row 196's human boundary, and what
|
||||
the decision event records: `via=console` and the subject.
|
||||
4. The guards: CSRF, a confirmation step, a decision that is no longer open, a stale option key, a double
|
||||
click, and the read-only and `--no-send` modes. State each refusal's status code and message.
|
||||
5. Without `--sso` the buttons don't exist or are disabled. Say which, and what the inbox shows instead
|
||||
(today's `mosaic decide` line).
|
||||
6. Tests on stand-ins only: a fake OIDC provider and the test bus, as row 192's tests do.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to who can reach the Console from outside. That stays Jason's.
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. No request to the
|
||||
live 127.0.0.1:7332 or 7333, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
- Turning the buttons on live needs the phase 2 window (`--sso`) and then a re-pin whose tree has this row.
|
||||
Both are windows with Jason. This row ships the code and the tests.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The webui, bus and cli suites and every `scripts/test-*.sh` are
|
||||
green on Sage's gate, and the mutants named in DESIGN.md are killed. The PRD amendment for REQ-WEB-2 has
|
||||
landed first.
|
||||
@@ -334,6 +334,17 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
lanes scan only at lane end, about 15 minutes after pkg-bus, so they
|
||||
can't see it (mos-1169). (2026-10-21)
|
||||
|
||||
- **Row 66 live checks: the unslop notice on a live managed session, both harnesses.**
|
||||
(mos-1186, Vikunja #454.) Row 66's gate asked for one landing session per
|
||||
harness with a prompt that draws a known tell. Mos changed that gate. The
|
||||
Claude Code session ran before landing on a scratch root from the
|
||||
candidate tree (run r4ab24630d323, landed 2c38325ec). Pi ran launch only there (run r1dd94171bede):
|
||||
this host has no credential pi reads until P19 #383 settles. Two live
|
||||
checks remain. Claude Code: in the first re-pin whose tree includes row 66,
|
||||
backup first (Q173). Pi: one full session with a tell after #383. Each
|
||||
attaches the operator note on the RESULT, the next-turn correction and the
|
||||
`unslop.jsonl` counts. (2026-10-21)
|
||||
|
||||
## Queue
|
||||
|
||||
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
|
||||
|
||||
@@ -458,17 +458,36 @@ test('T27: ssh-add -t drops the key from an agent the test keeps alive', async (
|
||||
const agent = spawn('ssh-agent', ['-D', '-a', sock], { stdio: 'ignore' });
|
||||
t.after(() => agent.kill('SIGKILL'));
|
||||
assert.ok(await until(() => existsSync(sock)));
|
||||
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 1);
|
||||
const list = () => {
|
||||
// A failed list names its cause: when each step ran on the monotonic clock (hrtime) and on
|
||||
// /proc/uptime (CLOCK_BOOTTIME, which ssh-agent's lifetime counts in whole seconds), and
|
||||
// whether the agent and its socket were still there.
|
||||
const marks = [];
|
||||
const mark = (name) => marks.push({ name, ns: process.hrtime.bigint(), uptime: readFileSync('/proc/uptime', 'utf8').split(' ')[0] });
|
||||
const timing = () => {
|
||||
const at = (m) => `${m.name} +${(Number(m.ns - marks[0].ns) / 1e6).toFixed(1)} ms (hrtime ${(Number(m.ns % 1_000_000_000n) / 1e9).toFixed(4)} s into its second, uptime ${m.uptime})`;
|
||||
const alive = agent.exitCode === null && agent.signalCode === null;
|
||||
return `${marks.map(at).join('; ')}; agent ${alive ? 'alive' : `gone (${agent.exitCode ?? agent.signalCode})`}, socket ${existsSync(sock) ? 'present' : 'missing'}`;
|
||||
};
|
||||
// ssh-agent sets a key's death to the whole second of CLOCK_BOOTTIME at the add plus the
|
||||
// lifetime, and drops it once that whole second is reached. So `-t N` keeps a key between N - 1
|
||||
// and N seconds: `-t 1` added just before a second boundary was gone within milliseconds (#453).
|
||||
// `-t 2` keeps it at least 1 s, and the sleep waits past the 2 s ceiling.
|
||||
mark('addKey start');
|
||||
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 2);
|
||||
mark('addKey resolved');
|
||||
const list = (name) => {
|
||||
mark(name);
|
||||
try {
|
||||
return execFileSync('ssh-add', ['-l'], { env: { PATH: '/usr/bin:/bin', SSH_AUTH_SOCK: sock }, encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
return e.stdout;
|
||||
}
|
||||
};
|
||||
assert.match(list(), /SHA256:/);
|
||||
await sleep(2500);
|
||||
assert.match(list(), /no identities/);
|
||||
const first = list('first list');
|
||||
assert.match(first, /SHA256:/, `first list got ${JSON.stringify(first)}: ${timing()}`);
|
||||
await sleep(3500);
|
||||
const second = list('second list');
|
||||
assert.match(second, /no identities/, `second list got ${JSON.stringify(second)}: ${timing()}`);
|
||||
});
|
||||
|
||||
// The test's own limit makes a hung addKey (M37a, M37b) fail in seconds, not at the batch timeout.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fork } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
@@ -74,8 +74,6 @@ test('startup token refusal returns safe code without value or partial listening
|
||||
const secret = 'fixture-token-never-in-db-948723';
|
||||
const file = join(root, 'secret');
|
||||
writeFileSync(file, secret, { mode: 0o644 });
|
||||
// writeFileSync applies the umask; chmod sets the too-open mode under any umask (row 243).
|
||||
chmodSync(file, 0o644);
|
||||
const config = structuredClone(businesses);
|
||||
config.demo.roles.cto.credentials = { gitea: { file, rotateBy: '2099-01-01' } };
|
||||
const { child, logs } = launch(t);
|
||||
|
||||
@@ -15,7 +15,7 @@ export function setup(t, realPin=false){
|
||||
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
|
||||
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
|
||||
mkdirSync(repo);mkdirSync(bin);
|
||||
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
||||
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
|
||||
const pkg={name:'copy-fixture',version:'1.0.0'};
|
||||
@@ -32,6 +32,20 @@ export function setup(t, realPin=false){
|
||||
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
|
||||
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
|
||||
}
|
||||
// Row 66: a session launched from a pinned copy reads each harness module's files from the copy,
|
||||
// so every path a module names under its REPO (adapter, mounts, bundle sources) is in COPY_PATHS.
|
||||
test('a copy carries every file the harness modules read from it',async t=>{
|
||||
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
|
||||
const copy=join(s.copies,s.id),paths=[];
|
||||
for(const kind of ['pi','claude-code']){
|
||||
const m=await import(pathToFileURL(join(copy,'packages','plugins','harness',kind,'index.mjs')).href);
|
||||
// pi's mounts() resolves node_modules, which this fixture copy lacks; its unslop mounts are its
|
||||
// bundle sources, so the sources stand in for them.
|
||||
paths.push(m.adapter(),...(kind==='pi'?Object.values(m.bundle().sources):m.mounts({env:{}}).map(x=>x.src)));
|
||||
}
|
||||
assert.equal(paths.filter(p=>p.includes('/tools/unslop-hook/')).length,6,paths.join('\n'));
|
||||
for(const p of paths){assert.ok(p.startsWith(copy+'/'),p);assert.ok(existsSync(p),p);}
|
||||
});
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
|
||||
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
|
||||
@@ -37,10 +37,7 @@ function run(cmd, args, env) {
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
// The child runs under an explicit umask, so the mode assertions hold under any gate umask (row 243,
|
||||
// sage-1918). mosaic-config.mjs creates with 0644 and lets the umask narrow it.
|
||||
const underUmask = (mask, home, args) => run("/bin/bash", ["-c", `umask ${mask}; exec "$@"`, "bash", BOOTSTRAP, ...args], homeEnv(home));
|
||||
const bootstrap = (home, ...args) => underUmask("022", home, args);
|
||||
const bootstrap = (home, ...args) => run(BOOTSTRAP, args, homeEnv(home));
|
||||
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
|
||||
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
|
||||
|
||||
@@ -97,16 +94,6 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
|
||||
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
|
||||
});
|
||||
|
||||
await t.test("under umask 077 the config and marker are 0600: the source honors the operator's umask", (t) => {
|
||||
const home = tmp(t, "si-");
|
||||
const r = underUmask("077", home, ["--instance", "alpha"]);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(mode(join(home, ".config", "mosaic-alpha")), 0o700);
|
||||
assert.equal(mode(cfgOf(home, "alpha")), 0o600);
|
||||
assert.equal(mode(join(home, ".mosaic-alpha")), 0o700);
|
||||
assert.equal(mode(join(home, ".mosaic-alpha", "instance.json")), 0o600);
|
||||
});
|
||||
|
||||
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
|
||||
const home = tmp(t, "si-");
|
||||
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,7 +11,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");
|
||||
@@ -19,14 +19,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 };
|
||||
});
|
||||
@@ -42,6 +42,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;
|
||||
@@ -304,3 +307,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 {
|
||||
|
||||
@@ -5,17 +5,17 @@ import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
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");
|
||||
@@ -23,7 +23,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" };
|
||||
@@ -43,6 +43,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" } });
|
||||
@@ -264,3 +267,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');
|
||||
|
||||
@@ -27,7 +27,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
|
||||
const cases = [
|
||||
...sets.map(flags => ({ flags })),
|
||||
...[['mode', VALUE, 0o644], ['short', 'short_fixture', 0o600], ['newline', VALUE.slice(0, 20) + '\n' + VALUE.slice(20), 0o600], ['bad', VALUE + '!', 0o600], ['two-final-lines', VALUE + '\n\n', 0o600]].map(([name, value, mode]) => {
|
||||
const file = join(f.root, name); writeFileSync(file, value, { mode }); chmodSync(file, mode); // umask-proof (row 243)
|
||||
const file = join(f.root, name); writeFileSync(file, value, { mode });
|
||||
return { flags: [...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], value };
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -358,7 +358,6 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, and
|
||||
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
|
||||
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
|
||||
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o644); // writeFileSync applies the umask (row 243)
|
||||
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(wide.status, 2);
|
||||
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
|
||||
|
||||
@@ -36,6 +36,8 @@ COPY_PATHS=(
|
||||
packages/queue/src packages/library/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