Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ab4c2fe6 | ||
|
|
81e037d20d | ||
|
|
47148991b4 |
+1
-17
@@ -75,8 +75,7 @@ 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. 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_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. |
|
||||
| `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. |
|
||||
@@ -111,18 +110,3 @@ 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.
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# Row 250 build notes (Gizmoduck)
|
||||
|
||||
Vikunja #450, Mos ruling mos-1182 item 3. Brief: docs/plans/2026-10-21_k12-cohort-events.md. Reviewer
|
||||
Filbert, gate owner Sage. Branch gizmoduck/row250 from origin/refactor 3b0aef995. Line numbers below are on
|
||||
this branch unless a commit is named.
|
||||
|
||||
## Step 1
|
||||
|
||||
cbba6ff07 changes only the K12 assertion (packages/conversation/tests/cohort.test.mjs:292 on 3b0aef995).
|
||||
On failure it prints the shim's answer, with `ok` and `unavailable`, and `h.ctrl.evidence.releases`.
|
||||
|
||||
## Step 2: the cause
|
||||
|
||||
K12 reads the shim after the controller has started to release it.
|
||||
|
||||
- The force stop sets the binding `stopped` (packages/conversation/src/controller.mjs:1615). Then it pauses at
|
||||
`scope-release` (:1623) and calls `#releaseScope` (:1624).
|
||||
- `#releaseScope` calls `releaseCohort` (:1639). That sends `hello`, then `release`
|
||||
(packages/conversation/src/cohort.mjs:261-265).
|
||||
- The shim answers `release` and exits 10 ms later. It unlinks its socket first (shim.mjs:165-176). systemd
|
||||
then collects the empty scope, `engine` included.
|
||||
- K12's `forceStop` helper returns once it sees `stopped` (cohort.test.mjs:148, polling every 10 ms). The
|
||||
test then reads `engine/cgroup.freeze` (:281 on 3b0aef995) and asks the shim for `events` (:291).
|
||||
|
||||
Nothing in K12 orders those reads before the release. Unloaded, the test usually sends its `events` request
|
||||
within the 10 ms before the shim exits, so it passes. Under load the release wins. `shimRequest` then
|
||||
returns `{ ok: false, unavailable: "the shim socket is gone" }` (cohort.mjs:46), or "unreachable" or
|
||||
"closed the connection" (:62-63), and `ev.populated` is undefined. Both recorded failures fit this
|
||||
pattern:
|
||||
|
||||
- Row 197 proof 4, pair1-b: K12 failed in 339 ms on `undefined !== 0` at :292. 339 ms is far below the
|
||||
3 s `shimRequest` timeout (cohort.mjs:56), so the request didn't time out. The `cgroup.freeze` read at
|
||||
:281 passed, so the scope still existed then.
|
||||
- BUILD-LOG.md:4092, row 56's gate: "`populated` undefined with load around 12".
|
||||
|
||||
Row 50 (a9cc522a0, #1536) added the release after a proven stop. K12 is older. Its comment still says
|
||||
"the shim holds the scope", and that stopped being true in row 50. E1 (cohort.test.mjs:923-925) already
|
||||
holds `scope-release` so it can read the shim while the shim still answers. K12 never got the hold. No
|
||||
other test reads the shim after a proven stop and expects an answer. R1 expects the shim gone, R3 is an
|
||||
uncertain stop, and K15 reads before its stop.
|
||||
|
||||
The cgroup read itself has no race. The kill's `waitFor` (shim.mjs:117-126) had already read `populated 0`
|
||||
for the stop to be proven. The read is the shim's call to `events()` (shim.mjs:83-96), and it reads one
|
||||
kernel file. So the brief's allowance for a bounded retry doesn't apply, and there is none.
|
||||
|
||||
The fix goes in the test's setup, which the brief allows ("Use the shim, the cohort controller or the
|
||||
test's setup and teardown, whichever the evidence points to"). The controller's release is correct and
|
||||
specified (README "Scope release", R1). Delaying it for a reader would change row 50's behavior.
|
||||
|
||||
## Step 2: the change
|
||||
|
||||
packages/conversation/tests/cohort.test.mjs, K12:
|
||||
|
||||
- It runs with `gate()`, holds `scope-release` before the stop, and waits until the hold is reached. Then it
|
||||
does every read that needs the scope: `cgroup.freeze`, `events` and `members`.
|
||||
- After the reads it lets the release go and asserts one entry, `["force-stop", "released"]`. This is new
|
||||
coverage: K12 now also shows the forking cohort's scope released.
|
||||
- The assertions are unchanged. `populated` must be 0 and the member list must be empty. The step 1 message
|
||||
stays.
|
||||
|
||||
packages/conversation/src/shim.mjs:167-168, `release` (:167 on 3b0aef995). It answered a failed `events()` with "the engine
|
||||
cgroup is not empty", so it reported unavailable as not empty. The refusal was already safe, but the reason
|
||||
was false. Now an `events()` answer that isn't `ok` is returned as it is, with the read's own reason. Only
|
||||
`populated` 1 gives "not empty". Nothing else matched that string (grep of packages/).
|
||||
|
||||
## Tests
|
||||
|
||||
- K12 as above.
|
||||
- R13, the brief's new case, placed after R3. After a proven stop, with `scope-release` held, the test makes
|
||||
`engine/cgroup.events` mode 000. Then:
|
||||
- `events` returns `ok: false`, has no `populated`, and the reason is "engine/cgroup.events unreadable
|
||||
(EACCES)".
|
||||
- On release, the controller records one entry `["force-stop", "unavailable", <that reason>]`.
|
||||
- The unit stays active, and the shim still answers `hello`.
|
||||
|
||||
Then `close()` reuses the stop's release, so there is still one entry. The `finally` closes the
|
||||
controller again (a second close is a no-op), restores mode 444, the kernel's mode for cgroup.events,
|
||||
and releases the scope. On
|
||||
3b0aef995's shim.mjs, R13 fails on the reason, because it reads "the engine cgroup is not empty".
|
||||
- K15 already covers a stop, as opposed to a release, with `events` unavailable: the shim gone, the file
|
||||
mode 000, or `engine` missing, each ending `uncertain` with its reason.
|
||||
|
||||
## File added beyond the brief
|
||||
|
||||
packages/conversation/README.md:599 listed "R1–R3, R5–R12" for cohort.test.mjs. With R13 it now reads
|
||||
"R1–R3, R5–R13". The brief's files don't include the README. Sage added it to this row in sage-2082.
|
||||
|
||||
## Runs
|
||||
|
||||
None yet. They need Sage's slot. Sage's sage-2082 says the run is on the pushed candidate in a clean
|
||||
worktree, and that it adds the control-board suite whole, so it counts as the gate run.
|
||||
|
||||
## Round 2
|
||||
|
||||
Filbert's C1 (#1755 comment 30125): R13 never closed its in-process controller, so the controller's
|
||||
socket server (controller.mjs:548) stayed open, and `reap` only kills units and engines. Only `close()`
|
||||
closes it (:1789). R13 now calls `h.ctrl.close()` after its checks and asserts one release entry, as R2
|
||||
does. Its `finally` also closes the controller, for a failure partway through. The second close is safe:
|
||||
`server` is null by then (:1791), and `#releaseScope` returns the cached release (:1634-1648). Branch
|
||||
gizmoduck/row250-r2, on e9cfd92f8.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Row 244: gate runs use a fixture pi auth file
|
||||
|
||||
Brief: `docs/plans/2026-10-21_gate-env-rows.md` § "Gate runs use a fixture pi auth file" (Sage, sage-1909).
|
||||
Reviewer Webby. DEFERRED entry "Gate runs mount the host's pi auth file" (059577886). Design notes
|
||||
written before row 197 lands, read against `origin/herb/row197-r6` (df0c59608). Recheck every line cite
|
||||
against 197's landing before code starts.
|
||||
|
||||
## Where Compose is reached
|
||||
|
||||
`compose.yaml:43` mounts `${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}` read-only. The default is
|
||||
a literal path, not `$HOME`, so keeping `HOME` in the lane env doesn't change it.
|
||||
|
||||
Gate steps come from `discoverSteps` (`gate-lane.mjs:32`): one `pkg-<name>` step per package with tests
|
||||
(not queue) and one `script-<name>` step per `scripts/test-*.sh`. A grep of those for `run-task.sh`,
|
||||
`docker compose`, `hello.sh`, `verify.sh`, `agent.sh` and `release.sh activate` finds two Compose
|
||||
paths. The other hits (`cohort.test.mjs` "hello" shim requests, `test-auth.sh`) don't reach Compose.
|
||||
|
||||
| Step | Path to Compose | Adapter |
|
||||
|---|---|---|
|
||||
| `test-task.sh` :150-385 | `scripts/run-task.sh run` → `mosaic-task.mjs:396` `docker compose run` with `spawnEnv = {...process.env}` | mock (`mock_config`) |
|
||||
| `test-release.sh` :69, :85, :93 | `release.sh activate` → `health_check` (:41-55) → `run-task.sh run tasks/hello-marker.json` | **pi** (config :61 sets no adapter; provider zai, model glm-5.3-flash) |
|
||||
|
||||
`mosaic-task.mjs` passes `process.env` through, so a `PI_AUTH_FILE` exported by a suite reaches Compose.
|
||||
|
||||
## The conflict: test-release's healthy activation is a live provider call
|
||||
|
||||
"healthy activation succeeds" (:85) and "repeat activation succeeds" (:93) need `health_check` to
|
||||
pass. That runs the real pi adapter, asks glm-5.3-flash for `MOSAIC_HELLO_OK`, and authenticates through
|
||||
the mounted auth file. The lane's `cleanEnv` drops `ZAI_API_KEY`, so the mount is the only credential.
|
||||
With a fixture auth file the provider call fails, and four checks fail: :85, the pointer check after it,
|
||||
:93 and "log is append-only". So every gate run today spends a real provider call on the host credential.
|
||||
|
||||
Options put to Sage (morgana-0037):
|
||||
- **A (recommended).** test-release's config sets `"adapter":"mock"`, and the two healthy activations
|
||||
run with `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`. The fault injection still refuses, because its task
|
||||
expects `MOSAIC_FAULT_<random>`. The suite keeps proving the release state machine, the image and
|
||||
Compose path, and the pointer and log. It stops proving that the pi image can reach the provider.
|
||||
Real `release.sh activate` runs still do that, with the real credential. No credential and no spend
|
||||
in the gate.
|
||||
- **B.** Keep the live call. The lane passes the real auth file explicitly. That contradicts the brief
|
||||
and DEFERRED, so I don't take it without a ruling.
|
||||
|
||||
**Ruling: A (sage-1914, 09:51Z).** B keeps a live billed call on every gate, which is what this row
|
||||
stops. Sage's conditions:
|
||||
1. test-release.sh's suite config sets `"adapter":"mock"`, with a comment beside it saying why: no
|
||||
credential and no provider call in the gate (row 244). The healthy activations run with
|
||||
`MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`.
|
||||
2. Fault injection still refuses. It keeps the random `MOSAIC_FAULT_<n>` expectation, and the mock's
|
||||
fixed reply can't satisfy it. Mutant M-F forces the fault task's expected value to `MOSAIC_HELLO_OK`.
|
||||
It must fail the suite.
|
||||
3. The fake-docker test also asserts that test-release's activations ran with the mock adapter. The
|
||||
fake docker records `MOSAIC_ADAPTER`, which `mosaic-task.mjs` sets in `spawnEnv` from the resolved
|
||||
config. Then a suite drifting back to pi fails the test.
|
||||
4. Coverage dropped: test-release.sh no longer proves that the pi image reaches the provider. A real
|
||||
`release.sh activate` still proves it, with the real credential. Sage adds a DEFERRED line at landing
|
||||
if Webby wants one.
|
||||
|
||||
guardDocker's new refusal also gets a mutant (M-G, below).
|
||||
|
||||
## Change
|
||||
|
||||
1. `scripts/test-task.sh` and `scripts/test-release.sh`: right after `SANDBOX` is made, write
|
||||
`$SANDBOX/pi/auth.json` (`{}`, mode set by `chmod 600` after the write, so it doesn't depend on the
|
||||
umask, row 243) and `export PI_AUTH_FILE`. It has to be a regular file that exists, because a missing
|
||||
bind source makes Docker create a directory on the host. The suites set it themselves, so the legacy
|
||||
gate (outside the lane) is covered too.
|
||||
2. `scripts/test-release.sh`: option A above.
|
||||
3. `scripts/testing/gate-lane.mjs` `cleanEnv`: set `PI_AUTH_FILE` to `join(o.tempRoot,'pi-auth.json')`,
|
||||
and `run` writes that file (`{}`, `flag:'wx'`, then chmod 0600) next to where it writes `bin/docker`.
|
||||
Any later step that reaches Compose without its own fixture then gets the lane's fixture.
|
||||
4. `scripts/testing/lane-docker.mjs` `guardDocker`: `compose run` also refuses unless `PI_AUTH_FILE` is
|
||||
set, resolves inside `run.tempRoot` and isn't the host default (refusal text
|
||||
`Compose auth mount not a lane fixture`). The guard already sees every Docker call a lane step makes,
|
||||
so this is where "no gate step mounts the host file" is enforced at run time. The CLI entry passes
|
||||
`process.env`.
|
||||
5. `compose.yaml` is unchanged (brief: not in scope).
|
||||
6. At landing: the DEFERRED entry moves to Done with the landing commit.
|
||||
|
||||
## Tests
|
||||
|
||||
In `packages/cli/tests/gate-lane.test.mjs` (row 197's file; the exact file follows 197's landing):
|
||||
|
||||
- `guardDocker` refuses `compose run` with `PI_AUTH_FILE` unset, set to the host default, or set outside
|
||||
`tempRoot`; it accepts the lane fixture and a suite fixture under `tempRoot`. Other verbs are unchanged.
|
||||
- `cleanEnv` returns a `PI_AUTH_FILE` inside `tempRoot` that isn't the host default.
|
||||
- The suites, read through their Compose environment: run `test-task.sh` and `test-release.sh` with a
|
||||
fake `docker` first on `PATH`. It answers `info` and `image inspect` with 0, and for `compose run` it
|
||||
appends `PI_AUTH_FILE` and whether that file is a regular 0600 file to a record. Then it prints the
|
||||
mock response. The test ignores the suites' own exit code and asserts that the record has at least one
|
||||
`compose run` from each suite and that every entry names an existing fixture file, never
|
||||
`/home/jwoltje/.pi/agent/auth.json`. The test never reads the host file. If a full suite run with the
|
||||
fake docker turns out too slow or brittle, I'll say so in the review request, with timings, and fall
|
||||
back to the guard and `cleanEnv` tests plus a static check that each suite exports `PI_AUTH_FILE`
|
||||
before its first Compose call. I won't drop it silently.
|
||||
|
||||
Mutants (each must fail a test; run on Sage's slot):
|
||||
- M-F: the fault task's `expectExact` forced to `MOSAIC_HELLO_OK` in `release.sh` `health_check`. Must
|
||||
fail `test-release.sh` ("fault-injected activation refuses").
|
||||
- M-G: the `guardDocker` `PI_AUTH_FILE` check removed. Must fail the guard test.
|
||||
- M-A: test-release's config back to no adapter (pi). Must fail the fake-docker test (condition 3).
|
||||
- M-L: `cleanEnv` stops setting `PI_AUTH_FILE`. Must fail the `cleanEnv` test.
|
||||
|
||||
Gate: those tests, `test-task.sh` and `test-release.sh` whole, the 197 lane tests, and the four
|
||||
mutants, all on Sage's slot.
|
||||
|
||||
## Files (expected)
|
||||
|
||||
`scripts/test-task.sh`, `scripts/test-release.sh`, `scripts/testing/gate-lane.mjs`,
|
||||
`scripts/testing/lane-docker.mjs`, `packages/cli/tests/gate-lane.test.mjs` (or a new
|
||||
`gate-lane-auth.test.mjs`), `docs/plans/DEFERRED.md` (at landing), `scripts/testing/README.md` if it
|
||||
lists the guard's rules.
|
||||
|
||||
## Correction (morgana-0040, 18:50Z)
|
||||
|
||||
The table above says `test-task.sh` uses only the mock adapter. That was wrong. On a36523713 two
|
||||
sections run the pi adapter through `$CONFIG` (provider zai) and authenticate through the host mount:
|
||||
`:443-501` (live hello, result.json written and correct, wrong expectExact, distinct run dirs, list,
|
||||
fork base, fork child, fork recall, ancestor untouched, child branch file) and `:518-530` (user recall
|
||||
run, recalled the user name). That's 13 checks and about 6 billed provider calls per gate run. I missed
|
||||
them because I read the mock section and stopped there.
|
||||
|
||||
**Ruling C (sage-2061, 18:50Z).** D (keep them live) refused.
|
||||
1. The run-record checks (hello, result.json, wrong expectExact, distinct run dirs, list) move to the
|
||||
mock adapter. Every assertion is kept. Only the response source changes.
|
||||
2. Fork recall and user recall run only under `MOSAIC_LIVE_PROVIDER=1`. Otherwise the suite prints
|
||||
`skip live provider cases (row 244: no provider call in the gate)`, which the lane's requiredSkips
|
||||
regex doesn't match, on purpose. A test asserts the line prints when the variable is unset and that
|
||||
the live checks run, and fail under a fixture, when it's 1 (M-T).
|
||||
3. The dropped coverage and the DEFERRED wording are below. Sage writes the entry at landing.
|
||||
|
||||
## Build (on a36523713, sage-2056)
|
||||
|
||||
Rechecked against 197's landing: `lane-docker.mjs:30` guardDocker, `gate-lane.mjs:109` cleanEnv and
|
||||
`:181` (bin/docker written), `compose.yaml:43`, `release.sh:41-55` health_check, `mosaic-task.mjs:321-322`
|
||||
spawnEnv and MOSAIC_ADAPTER, `test-release.sh:61` config. All hold.
|
||||
|
||||
- `scripts/testing/lane-docker.mjs`: `guardDocker(argv,run,env=process.env)`. After the option check, a
|
||||
`compose run` calls `laneAuthFile`. It refuses (`Compose auth mount not a lane fixture`) unless
|
||||
`PI_AUTH_FILE` is absolute, isn't the host default, resolves (realpath) inside the realpath of
|
||||
`run.tempRoot`, and is a regular file. Symlinks out of the root and relative paths refuse. `info` and
|
||||
`image inspect` need no auth file. The CLI entry passes `process.env`.
|
||||
- `scripts/testing/gate-lane.mjs`: `cleanEnv` sets `PI_AUTH_FILE=join(o.tempRoot,'pi-auth.json')`.
|
||||
`runGate` writes it (`{}`, flag `wx`, then `chmodSync` 0600) right after `bin/docker`.
|
||||
`MOSAIC_LIVE_PROVIDER` isn't on cleanEnv's keep list, so the lane can't request live cases.
|
||||
- `scripts/test-release.sh`: exports a `$SANDBOX/pi/auth.json` fixture (`{}`, `chmod 600` after the
|
||||
write, exits 1 if the write fails). The config adds `"adapter":"mock"` with the ruling A comment. The
|
||||
fault, healthy and repeat activations all run with `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`, so the
|
||||
fault activation refuses only because its `MOSAIC_FAULT_<n>` expectation is wrong. (Correction,
|
||||
before any run: the first build gave the fault activation no mock response. The mock then answered
|
||||
empty, which fails any expectation, so M-F would have survived. Fixed in the follow-up commit.)
|
||||
- `scripts/test-task.sh`: same fixture, exported only when `MOSAIC_LIVE_PROVIDER` isn't 1, so an
|
||||
operator's live run keeps their own `PI_AUTH_FILE` (or Compose's default). Ruling C (a) and (b) as
|
||||
above. The section header changes from "live: real runs" to "run records through the mock adapter",
|
||||
the check name "live hello task..." becomes "hello task...", and the docker-less branch of the new
|
||||
split prints `skip run-record cases (docker unavailable)`.
|
||||
- `packages/cli/tests/gate-lane-image.test.mjs` (:21, :106, :119): the compose-run calls pass
|
||||
`run.env`, the composeFixture env, which already holds a 0600 `PI_AUTH_FILE` inside tempRoot (accepted
|
||||
by sage-2061).
|
||||
- `scripts/testing/README.md`: one sentence on the lane fixture and the refusal.
|
||||
- New `packages/cli/tests/gate-lane-auth.test.mjs`, five tests:
|
||||
1. guardDocker accepts the lane fixture and a suite fixture under tempRoot. It refuses unset, empty,
|
||||
the host default, a file outside, a symlink inside that points outside, a missing path, a directory
|
||||
and a relative path. The option check still comes first. `info` and `image inspect` pass with no env.
|
||||
2. cleanEnv sets the lane fixture path and drops `MOSAIC_LIVE_PROVIDER` and the caller's
|
||||
`PI_AUTH_FILE`.
|
||||
3. test-release.sh in a private copy of the checkout, with a fake `docker` first on PATH. The fake
|
||||
answers every verb with 0. For `compose run` it records `PI_AUTH_FILE`, `stat -c '%F %a'` of it
|
||||
(never for the host path) and `MOSAIC_ADAPTER`, and it prints `MOSAIC_MOCK_RESPONSE` when the
|
||||
adapter is mock. Asserts: at least 3 compose runs, every one on a regular 0600 file under the
|
||||
suite's TMPDIR, and every adapter mock (condition 3). The healthy and fault lines print OK.
|
||||
4. test-task.sh, same harness: every compose run on its fixture and the mock adapter, the skip line
|
||||
printed exactly twice, and the hello check OK.
|
||||
5. M-T: test-task.sh with `MOSAIC_LIVE_PROVIDER=1` and an operator-style fixture. No skip line,
|
||||
`FAIL fork base: teach succeeds` and `FAIL recalled user name`, at least one pi run, and every run
|
||||
on the operator's file.
|
||||
The copy leaves out `.git`, `node_modules` (symlinked), `v1`, `agents`, `docs`, `BUILD-LOG.md` and
|
||||
`scripts/foundation`, about 14 MB. Copying is needed because `test-release.sh` rewrites `RELEASE`
|
||||
while it runs, which would race other cli tests that read it. The suites ignore their own exit codes
|
||||
here: the fake doesn't emit the mock adapter's env lines, so some seam checks fail by design.
|
||||
- Readers: `gate-lane.mjs` is imported by gate-lane.test, gate-lane-state.test,
|
||||
gate-lane-fixtures.test, gate-lane-image.test and fixtures/gate-lane-child.mjs (unchanged exports).
|
||||
`lane-docker.mjs` by gate-lane-image.test. No test hash-pins any changed file. vars.test.mjs names
|
||||
`scripts/test-task.sh` only as a string value.
|
||||
|
||||
Coverage dropped from the gate (for Webby, and for the DEFERRED entry):
|
||||
- `test-release.sh` no longer proves that the pi image reaches the provider (ruling A).
|
||||
- `test-task.sh` no longer proves session fork recall or onboarding user recall against a real model
|
||||
(ruling C).
|
||||
|
||||
DEFERRED wording for Sage (sage-2061 condition 3): "**Real-model suite cases run outside the gate.**
|
||||
Row 244 moved `test-task.sh` fork recall (M11) and user recall (M16) behind `MOSAIC_LIVE_PROVIDER=1`,
|
||||
and `test-release.sh` activations to the mock adapter. Before any re-pin whose tree changes
|
||||
`packages/harness` or the session fork code (`scripts/mosaic-task.mjs` session and `sessionForkFrom`
|
||||
handling, the pi adapter), an operator runs `MOSAIC_LIVE_PROVIDER=1 bash scripts/test-task.sh` with the
|
||||
real pi credential and a real `release.sh activate`, and records both results on the re-pin row."
|
||||
|
||||
Mutants (each must fail a named test; on Sage's slot):
|
||||
- M-F: `release.sh` health_check fault task `expectExact` forced to `MOSAIC_HELLO_OK`. Fails
|
||||
test-release.sh "fault-injected activation refuses", and test 3's OK match. This needs the fault
|
||||
activation's `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK` (see the correction above).
|
||||
- M-G: the `laneAuthFile(env,run)` call removed from guardDocker. Fails test 1.
|
||||
- M-A: `,"adapter":"mock"` removed from test-release's config. Fails test 3 (adapter set is pi).
|
||||
- M-L: the `PI_AUTH_FILE` key removed from cleanEnv. Fails test 2.
|
||||
- M-T: test 5 itself (sage-2061). As a mutant check too: the `MOSAIC_LIVE_PROVIDER` gate in test-task
|
||||
forced on fails test 4 (skip line count and pi adapter).
|
||||
|
||||
Results, Sage's slot (sage-2147), candidate 81e037d20, 2026-10-21 20:46:16Z to 20:46:59Z. Busy check
|
||||
CLEAR before and after. Image `mosaic-poc-agent:0.85.1-r0.0.12` present, so nothing was built. No
|
||||
unexpected failure. Singles ran under umask 022 with TMPDIR under /mnt/storage/scratch/tmp.
|
||||
|
||||
| Run | rc | Result | Time |
|
||||
|---|---|---|---|
|
||||
| gate-lane-auth.test.mjs (new) | 0 | 5 pass, 0 fail | 14 s |
|
||||
| gate-lane-image.test.mjs | 0 | 11 pass, 0 fail | 0 s |
|
||||
| gate-lane.test.mjs | 0 | 13 pass, 0 fail | 0 s |
|
||||
| gate-lane-state.test.mjs | 0 | 14 pass, 0 fail | 2 s |
|
||||
| gate-lane-fixtures.test.mjs | 0 | 8 pass, 0 fail | 0 s |
|
||||
| test-task.sh | 0 | OK 91, FAIL 0, the two live-skip lines | 12 s |
|
||||
| test-release.sh | 0 | OK 14, FAIL 0, Docker section on the mock adapter | 2 s |
|
||||
|
||||
Mutants, each run on its named gate-lane-auth test and restored after:
|
||||
|
||||
| Mutant | Target | Result |
|
||||
|---|---|---|
|
||||
| M-G | test 1 | killed (0 pass, 1 fail) |
|
||||
| M-L | test 2 | killed (0 pass, 1 fail) |
|
||||
| M-A | test 3 | killed (0 pass, 1 fail) |
|
||||
| M-T | test 4 | killed (0 pass, 1 fail), 6 s |
|
||||
| M-F | test 3 | killed (0 pass, 1 fail) |
|
||||
| M-F | real test-release.sh | killed: rc 1, OK 11, FAIL 3, the first `fault-injected activation refuses (exit 0, expected 1)` |
|
||||
@@ -1,200 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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,7 +418,6 @@ 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.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# 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,17 +334,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Row 244: no gate step mounts the host's pi auth file or makes a provider call.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync, cpSync, readFileSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { guardDocker } from '../../../scripts/testing/lane-docker.mjs';
|
||||
import { cleanEnv } from '../../../scripts/testing/gate-lane.mjs';
|
||||
|
||||
const REPO = realpathSync(fileURLToPath(new URL('../../../', import.meta.url)));
|
||||
const HOST_AUTH = '/home/jwoltje/.pi/agent/auth.json';
|
||||
const LIVE_SKIP = 'skip live provider cases (row 244: no provider call in the gate)';
|
||||
const scratch = t => { const root = mkdtempSync('/mnt/storage/scratch/gt-'); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
const fixture = (dir, name = 'auth.json') => { const file = join(dir, name); writeFileSync(file, '{}'); chmodSync(file, 0o600); return file; };
|
||||
const RUN = ['compose', 'run', '--rm', '-T', 'mosaic-agent', 'prompt'];
|
||||
|
||||
test('guardDocker refuses a compose run unless PI_AUTH_FILE is a regular file inside the lane temp root', t => {
|
||||
const tempRoot = scratch(t), outside = scratch(t);
|
||||
const run = { tempRoot, checkout: join(tempRoot, 'checkout'), imageId: 'sha256:' + 'a'.repeat(64), tag: 'mosaic-poc-agent:fixture' };
|
||||
const lane = fixture(tempRoot, 'pi-auth.json');
|
||||
mkdirSync(join(tempRoot, 'suite', 'pi'), { recursive: true });
|
||||
const suite = fixture(join(tempRoot, 'suite', 'pi'));
|
||||
for (const file of [lane, suite]) assert.equal(guardDocker(RUN, run, { PI_AUTH_FILE: file })[0], 'compose');
|
||||
const link = join(tempRoot, 'escape.json'); symlinkSync(fixture(outside), link);
|
||||
mkdirSync(join(tempRoot, 'dir.json'));
|
||||
for (const env of [{}, { PI_AUTH_FILE: '' }, { PI_AUTH_FILE: HOST_AUTH }, { PI_AUTH_FILE: fixture(outside) }, { PI_AUTH_FILE: link },
|
||||
{ PI_AUTH_FILE: join(tempRoot, 'missing.json') }, { PI_AUTH_FILE: join(tempRoot, 'dir.json') }, { PI_AUTH_FILE: relative(process.cwd(), lane) }])
|
||||
assert.throws(() => guardDocker(RUN, run, env), /Compose auth mount not a lane fixture/, JSON.stringify(env));
|
||||
// The option check still comes first, and the other allowed verbs need no auth file.
|
||||
assert.throws(() => guardDocker(['compose', 'run', '--build', 'mosaic-agent'], run, {}), /options not allowed/);
|
||||
assert.deepEqual(guardDocker(['info'], run, {}), ['info']);
|
||||
assert.deepEqual(guardDocker(['image', 'inspect', run.tag], run, {}), ['image', 'inspect', run.tag]);
|
||||
});
|
||||
|
||||
test('cleanEnv points PI_AUTH_FILE at the lane fixture and drops MOSAIC_LIVE_PROVIDER', t => {
|
||||
const tempRoot = scratch(t), saved = { PI_AUTH_FILE: process.env.PI_AUTH_FILE, MOSAIC_LIVE_PROVIDER: process.env.MOSAIC_LIVE_PROVIDER };
|
||||
process.env.PI_AUTH_FILE = HOST_AUTH; process.env.MOSAIC_LIVE_PROVIDER = '1';
|
||||
let env;
|
||||
try { env = cleanEnv({ tempRoot, lane: 'a', stateRoot: join(tempRoot, 'state'), out: join(tempRoot, 'out') }, { project: 'fixture' }, '/fake/docker'); }
|
||||
finally { for (const [k, v] of Object.entries(saved)) if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||
assert.equal(env.PI_AUTH_FILE, join(tempRoot, 'pi-auth.json'));
|
||||
assert.equal(env.MOSAIC_LIVE_PROVIDER, undefined);
|
||||
});
|
||||
|
||||
// Both suites run in a private copy of the checkout: test-release.sh rewrites RELEASE while it runs.
|
||||
// A fake docker records each compose run's PI_AUTH_FILE and MOSAIC_ADAPTER, and answers as the mock adapter.
|
||||
// It never reads or stats the host auth file.
|
||||
const SKIP_COPY = new Set(['.git', 'node_modules', 'v1', 'agents', 'docs', 'BUILD-LOG.md', join('scripts', 'foundation')]);
|
||||
function suiteLane(t) {
|
||||
const root = scratch(t), checkout = join(root, 'c'), bin = join(root, 'bin'), tmp = join(root, 'tmp');
|
||||
cpSync(REPO, checkout, { recursive: true, filter: src => !SKIP_COPY.has(relative(REPO, src)) });
|
||||
symlinkSync(realpathSync(join(REPO, 'node_modules')), join(checkout, 'node_modules'));
|
||||
mkdirSync(bin); mkdirSync(tmp, { mode: 0o700 });
|
||||
writeFileSync(join(bin, 'docker'), `#!/bin/bash
|
||||
[ "$1" = compose ] || exit 0
|
||||
f="\${PI_AUTH_FILE-}"
|
||||
if [ -z "\${PI_AUTH_FILE+x}" ]; then kind=unset; elif [ "$f" = ${HOST_AUTH} ]; then kind=host; else kind="$(stat -c '%F %a' -- "$f" 2>/dev/null || echo missing)"; fi
|
||||
printf '%s\\t%s\\t%s\\n' "$f" "$kind" "\${MOSAIC_ADAPTER-}" >> "$FAKE_DOCKER_RECORD"
|
||||
[ "\${MOSAIC_ADAPTER-}" = mock ] && printf '%s\\n' "\${MOSAIC_MOCK_RESPONSE-}"
|
||||
exit 0
|
||||
`);
|
||||
chmodSync(join(bin, 'docker'), 0o700);
|
||||
return (script, extra = {}) => {
|
||||
const record = join(root, `${script}-${Object.keys(extra).length}.tsv`), begin = performance.now();
|
||||
const env = { PATH: `${bin}:${process.env.PATH}`, HOME: process.env.HOME, TMPDIR: tmp, LANG: 'C.UTF-8', FAKE_DOCKER_RECORD: record, ...extra };
|
||||
const r = spawnSync('bash', [join(checkout, 'scripts', script)], { env, encoding: 'utf8', timeout: 600000 });
|
||||
t.diagnostic(JSON.stringify({ script, extra: Object.keys(extra), wallMs: Math.round(performance.now() - begin), status: r.status }));
|
||||
const runs = existsSync(record) ? readFileSync(record, 'utf8').trim().split('\n').filter(Boolean).map(l => { const [file, kind, adapter] = l.split('\t'); return { file, kind, adapter }; }) : [];
|
||||
return { out: r.stdout + r.stderr, runs, tmp };
|
||||
};
|
||||
}
|
||||
const fixtureRuns = (runs, tmp) => runs.every(r => r.file !== HOST_AUTH && r.file.startsWith(tmp + '/') && r.kind === 'regular file 600');
|
||||
|
||||
test('test-release.sh activates through the mock adapter with a 0600 fixture auth file', t => {
|
||||
const { out, runs, tmp } = suiteLane(t)('test-release.sh');
|
||||
assert.ok(runs.length >= 3, `fault, healthy and repeat activations reach Compose\n${out}`);
|
||||
assert.ok(fixtureRuns(runs, tmp), JSON.stringify(runs));
|
||||
assert.deepEqual([...new Set(runs.map(r => r.adapter))], ['mock'], 'ruling A: no pi adapter in the gate');
|
||||
assert.match(out, /OK +healthy activation succeeds/); assert.match(out, /OK +fault-injected activation refuses/);
|
||||
});
|
||||
|
||||
test('test-task.sh mounts only its fixture, uses only the mock adapter and prints the live skip twice', t => {
|
||||
const { out, runs, tmp } = suiteLane(t)('test-task.sh');
|
||||
assert.ok(runs.length >= 1, out);
|
||||
assert.ok(fixtureRuns(runs, tmp), JSON.stringify(runs));
|
||||
assert.deepEqual([...new Set(runs.map(r => r.adapter))], ['mock']);
|
||||
assert.equal(out.split('\n').filter(l => l === LIVE_SKIP).length, 2, out);
|
||||
assert.match(out, /OK +hello task succeeds with exact marker/);
|
||||
});
|
||||
|
||||
test('M-T: MOSAIC_LIVE_PROVIDER=1 runs the real-model cases, which fail under a fixture auth file', t => {
|
||||
const lane = suiteLane(t), auth = join(scratch(t), 'operator-auth.json'); writeFileSync(auth, '{}'); chmodSync(auth, 0o600);
|
||||
const { out, runs } = lane('test-task.sh', { MOSAIC_LIVE_PROVIDER: '1', PI_AUTH_FILE: auth });
|
||||
assert.ok(!out.includes(LIVE_SKIP), out);
|
||||
// The fake answers a pi run with nothing: the fork base misses REMEMBERED, recall misses the name.
|
||||
assert.match(out, /FAIL fork base: teach succeeds/); assert.match(out, /FAIL recalled user name/);
|
||||
assert.ok(runs.some(r => r.adapter === 'pi'), JSON.stringify(runs));
|
||||
assert.ok(runs.every(r => r.file === auth), 'live mode keeps the operator\'s PI_AUTH_FILE');
|
||||
});
|
||||
@@ -18,7 +18,7 @@ test('a changed COPY input or actual image ID refuses compatible image sharing',
|
||||
test('warm Docker guard disables implicit build and pull and refuses image mutation',t=>{
|
||||
assert.equal(typeof docker.guardDocker,'function','Docker guard required');
|
||||
const run=composeFixture(t);
|
||||
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
|
||||
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run,run.env),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
|
||||
for(const args of [['build','.'],['image','prune'],['compose','build'],['tag','a','b'],['network','rm','foreign']])assert.throws(()=>docker.guardDocker(args,run),/refused/);
|
||||
});
|
||||
test('exclusive image preparation records actual image ID and build wall time and requires a grant',t=>{
|
||||
@@ -103,7 +103,7 @@ function composeFixture(t) {
|
||||
test('installed Compose accepts rewritten run argv without starting a container',t=>{
|
||||
const run=composeFixture(t);
|
||||
if(docker.writeComposeOverride)docker.writeComposeOverride(run);
|
||||
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run);
|
||||
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run,run.env);
|
||||
const command=argv.indexOf('run');assert.ok(command>0);
|
||||
// --help is before SERVICE, so parsing exits before any service operation.
|
||||
const parsed=spawnSync(installedDocker(),[...argv.slice(0,command+1),'--help',...argv.slice(command+1)],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
|
||||
@@ -116,7 +116,7 @@ test('installed Compose resolves the private override with no build and the pinn
|
||||
assert.equal(override,join(run.tempRoot,'compose-no-build.yaml'));
|
||||
assert.equal(statSync(override).mode&0o777,0o600);
|
||||
assert.equal(docker.writeComposeOverride(run),override,'parallel callers reuse identical private content');
|
||||
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run),command=argv.indexOf('run');
|
||||
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run,run.env),command=argv.indexOf('run');
|
||||
const resolved=spawnSync(installedDocker(),[...argv.slice(0,command),'config','--format','json'],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
|
||||
assert.equal(resolved.status,0,resolved.stdout+resolved.stderr);
|
||||
const service=JSON.parse(resolved.stdout).services['mosaic-agent'];
|
||||
|
||||
@@ -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','tools/unslop-hook','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','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,20 +32,6 @@ 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);
|
||||
|
||||
@@ -596,7 +596,7 @@ no prompt:
|
||||
| `claim.test.mjs` | W1–W17, W20, G1–G3: the writer claim, crash barriers, the guard |
|
||||
| `races.test.mjs` | H1–H4, H9–H23: takeover, Interrupt and force stop, retries, incarnations |
|
||||
| `turns.test.mjs` | N1–N25, N24b: the turn tracker against the fake engine's Pi behaviors; the engine seal and environment |
|
||||
| `cohort.test.mjs` | K1–K19, R1–R3, R5–R13, E1–E6: scopes, force stop, proofs, recovery, eligibility, the scope's environment, scope release and its retry after a crash, the engine-exit stop (needs a systemd user manager) |
|
||||
| `cohort.test.mjs` | K1–K19, R1–R3, R5–R12, E1–E6: scopes, force stop, proofs, recovery, eligibility, the scope's environment, scope release and its retry after a crash, the engine-exit stop (needs a systemd user manager) |
|
||||
| `flows.test.mjs` | S1–S7, P3, E1–E7, the terminal, and a CHAT-01 schema check of every record produced |
|
||||
| `smoke.test.mjs` | the pinned Pi binary, as above |
|
||||
|
||||
|
||||
@@ -164,8 +164,7 @@ async function handle(req) {
|
||||
return waitFor((e) => e.populated === 0, Number(req.timeoutMs) || 5000);
|
||||
case "release": {
|
||||
const e = events();
|
||||
if (!e.ok) return e;
|
||||
if (e.populated !== 0) return { ok: false, unavailable: "the engine cgroup is not empty" };
|
||||
if (!e.ok || e.populated !== 0) return { ok: false, unavailable: "the engine cgroup is not empty" };
|
||||
setTimeout(() => {
|
||||
try {
|
||||
unlinkSync(socketPath);
|
||||
|
||||
@@ -265,19 +265,14 @@ test("K5: a stop during a tool call leaves the effect uncertain, and it is shown
|
||||
// every child the loop forked after its SIGTERM, which nothing else ends
|
||||
// before the kill, is in the proof's list. Mutants r2-B5 (no freeze write,
|
||||
// `frozen 1` answered) and r2-B5b (freeze written, not waited on) fail here.
|
||||
// The stop's own release ends the shim and the scope (#1536), so the test
|
||||
// holds it until the reads are done (row 250).
|
||||
test("K12: a member forking in a loop: the freeze stops it, enumeration is complete, populated 0 after cgroup.kill", NEEDS_SCOPE, async () => {
|
||||
const g = gate();
|
||||
const h = await live({ barrier: g.barrier });
|
||||
const h = await live();
|
||||
try {
|
||||
const pidLog = join(h.fx.base, "fork-pids.log");
|
||||
const forker = await childOf(h, { forkLoop: true, ignoreTerm: true, pidLog });
|
||||
await until(async () => (await memberPids(h.rec().shim)).length >= 6, 4000, "the fork loop");
|
||||
const engineDir = join("/sys/fs/cgroup", (await shimRequest(h.rec().shim, "hello")).scope, "engine");
|
||||
g.hold("scope-release");
|
||||
await forceStop(h);
|
||||
await g.waitHeld("scope-release");
|
||||
assert.equal(h.ctrl.binding.state, "stopped", JSON.stringify(h.ctrl.evidence.stops));
|
||||
const { members } = h.ctrl.stoppedProof.proof;
|
||||
assert.ok(members.some((m) => m.pid === forker));
|
||||
@@ -294,14 +289,9 @@ test("K12: a member forking in a loop: the freeze stops it, enumeration is compl
|
||||
assert.ok(listed.has(pid), `child ${pid} forked after TERM is in the proof's list`);
|
||||
}
|
||||
const ev = await shimRequest(h.rec().shim, "events");
|
||||
// On failure, name the cause: the answer with `ok` and `unavailable`, and any release already run (row 250).
|
||||
assert.equal(ev.populated, 0, JSON.stringify({ ev, releases: h.ctrl.evidence.releases }));
|
||||
assert.equal(ev.populated, 0);
|
||||
assert.deepEqual(await memberPids(h.rec().shim), []);
|
||||
g.release("scope-release");
|
||||
await until(() => h.ctrl.evidence.releases.length > 0, 8000, "the release");
|
||||
assert.deepEqual(h.ctrl.evidence.releases.map((r) => [r.why, r.outcome]), [["force-stop", "released"]]);
|
||||
} finally {
|
||||
g.release("scope-release");
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
@@ -862,45 +852,6 @@ test("R3: no release after an unavailable stop or an unverified proof: the scope
|
||||
}
|
||||
});
|
||||
|
||||
// `release` reads engine/cgroup.events again. An unreadable file answers with
|
||||
// the read's own reason, never "not empty", and the controller records the
|
||||
// release unavailable with that reason (row 250).
|
||||
test("R13: a release with engine/cgroup.events unreadable answers the read's reason; the controller records it unavailable and the scope stays", NEEDS_SCOPE, async () => {
|
||||
const g = gate();
|
||||
const h = await live({ barrier: g.barrier });
|
||||
const unit = h.rec().unitName, shim = h.rec().shim;
|
||||
let events = null;
|
||||
try {
|
||||
g.hold("scope-release");
|
||||
await forceStop(h);
|
||||
await g.waitHeld("scope-release");
|
||||
assert.equal(h.ctrl.binding.state, "stopped", JSON.stringify(h.ctrl.evidence.stops));
|
||||
events = join("/sys/fs/cgroup", (await shimRequest(shim, "hello")).scope, "engine", "cgroup.events");
|
||||
chmodSync(events, 0o000);
|
||||
const ev = await shimRequest(shim, "events");
|
||||
assert.equal(ev.ok, false, JSON.stringify(ev));
|
||||
assert.equal("populated" in ev, false, JSON.stringify(ev));
|
||||
assert.equal(ev.unavailable, "engine/cgroup.events unreadable (EACCES)");
|
||||
g.release("scope-release");
|
||||
await until(() => h.ctrl.evidence.releases.length > 0, 8000, "the release");
|
||||
assert.deepEqual(h.ctrl.evidence.releases.map((r) => [r.why, r.outcome, r.reason]), [["force-stop", "unavailable", ev.unavailable]]);
|
||||
assert.ok(unitActive(unit), "the scope stays up");
|
||||
assert.equal((await shimRequest(shim, "hello")).ok, true, "the shim got no release");
|
||||
await h.ctrl.close();
|
||||
assert.equal(h.ctrl.evidence.releases.length, 1, "close reuses the stop's release");
|
||||
} finally {
|
||||
g.release("scope-release");
|
||||
await h.ctrl.close();
|
||||
if (events) {
|
||||
chmodSync(events, 0o444);
|
||||
await releaseCohort({ kind: "scope", unitName: unit, invocationId: h.rec().invocationId, shimSocket: shim });
|
||||
}
|
||||
h.c.close();
|
||||
h.fake.close();
|
||||
reap(h.fx);
|
||||
}
|
||||
});
|
||||
|
||||
// A normal engine exit leaves the shim and the scope: the shim exits only on
|
||||
// `release`. The controller doesn't release then: the binding is uncertain
|
||||
// and the scope is what a force stop reads. The proven stop releases it.
|
||||
|
||||
@@ -68,13 +68,9 @@ 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, and of the unslop check's files (row 66:
|
||||
`lists.json`, the checker, and pi's extension or Claude Code's hook
|
||||
script).
|
||||
MCP server and tools sources.
|
||||
- Claude Code only: `settings.json` (the wrapped PreToolUse hook, matcher
|
||||
`*`, 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).
|
||||
`*`) 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
|
||||
@@ -262,28 +258,6 @@ 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,12 +21,6 @@
|
||||
// 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
|
||||
@@ -126,113 +120,6 @@ 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");
|
||||
@@ -387,8 +274,6 @@ 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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,7 +358,6 @@ 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;
|
||||
@@ -508,7 +392,6 @@ 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}`}`);
|
||||
@@ -556,7 +439,6 @@ 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"}`); }
|
||||
@@ -565,8 +447,7 @@ 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) + unslop.note(), class: "RESULT", in_reply_to: m.id });
|
||||
unslop.sent();
|
||||
await client.call("message.send", { to: m.from_role, body: clip(body), class: "RESULT", in_reply_to: m.id });
|
||||
}
|
||||
await client.call("message.read", { id: m.id });
|
||||
} catch (e) {
|
||||
|
||||
@@ -7,8 +7,6 @@ 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;
|
||||
|
||||
@@ -82,11 +80,8 @@ 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", "unslopCheck", "unslopExtension", "unslopLists"]);
|
||||
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools"]);
|
||||
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)));
|
||||
});
|
||||
@@ -149,15 +144,7 @@ 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", "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);
|
||||
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
|
||||
});
|
||||
|
||||
// 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, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
|
||||
import { adapterEnv } 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"], done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}`) {
|
||||
async function session(t, script, tools = ["read", "bash"]) {
|
||||
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 });
|
||||
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}` });
|
||||
const resolved = resolvedFor(t, "coder", (d) => {
|
||||
d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5", "limits.tools": tools };
|
||||
});
|
||||
@@ -42,9 +42,6 @@ async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWE
|
||||
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;
|
||||
@@ -307,55 +304,3 @@ 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,9 +5,6 @@
|
||||
// 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.
|
||||
|
||||
@@ -36,12 +33,6 @@ 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, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { buildBundle } from "../src/bundle.mjs";
|
||||
import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
|
||||
import { adapterEnv } 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", done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}`) {
|
||||
async function session(t, script, place = (dir) => join(dir, "ws"), instance = "chief") {
|
||||
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 });
|
||||
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` });
|
||||
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,9 +43,6 @@ 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" } });
|
||||
@@ -267,55 +264,3 @@ 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, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs";
|
||||
import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest } from "../src/runner.mjs";
|
||||
import { typedTools } from "../src/tools.mjs";
|
||||
import { resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
@@ -836,131 +836,3 @@ 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,11 +153,6 @@ 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,13 +16,6 @@ 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"]);
|
||||
@@ -76,13 +69,11 @@ 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), and the unslop
|
||||
// hook's files.
|
||||
// The pinned binary, when it lives outside /usr (which the sandbox already binds).
|
||||
export function mounts({ env }) {
|
||||
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;
|
||||
if (!env.MOSAIC_CLAUDE_BIN) return [];
|
||||
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)" }]), ...unslop];
|
||||
return real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }];
|
||||
}
|
||||
|
||||
// WebSearch and WebFetch are its own, so it serves harness-builtin web.
|
||||
@@ -92,28 +83,22 @@ export const typedName = (name) => MCP_PREFIX + name;
|
||||
export const reliesOn = () => RELIES_ON;
|
||||
export const gateTimeout = () => ({ ...GATE_TIMEOUT });
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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 }] }],
|
||||
Stop: unslop("stop"),
|
||||
UserPromptSubmit: unslop("prompt"),
|
||||
},
|
||||
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
|
||||
}, 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, ...UNSLOP } };
|
||||
return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER } };
|
||||
}
|
||||
|
||||
export function env({ files, policy, session }) {
|
||||
|
||||
@@ -13,13 +13,6 @@ 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.
|
||||
@@ -47,23 +40,20 @@ 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" },
|
||||
...Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's second extension runs" })),
|
||||
];
|
||||
export const mounts = () => [{ src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" }];
|
||||
// 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, ...UNSLOP } });
|
||||
export const bundle = () => ({ files: {}, digests: [], sources: { piExtension: EXTENSION } });
|
||||
|
||||
// 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, UNSLOP.unslopExtension].join(","),
|
||||
MOSAIC_EXTENSIONS: EXTENSION,
|
||||
PI_PROVIDER: session.provider ?? "",
|
||||
PI_MODEL: session.model,
|
||||
MOSAIC_THINKING: session.thinking,
|
||||
|
||||
@@ -71,11 +71,9 @@ 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, 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);
|
||||
// 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: {} }), []);
|
||||
});
|
||||
|
||||
// Row 68's claudeVersion precedence, moved here with the version (row 65): the manifest records
|
||||
@@ -102,8 +100,7 @@ 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"]);
|
||||
// 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))]);
|
||||
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest), [join(REPO, "node_modules")]);
|
||||
});
|
||||
|
||||
test("row 65 env passes the thinking level to both adapters, and claude-code's bundle writes its hook and MCP files", async t => {
|
||||
@@ -111,8 +108,6 @@ 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)]; };
|
||||
@@ -122,9 +117,6 @@ 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,12 +93,7 @@ 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" });
|
||||
// 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);
|
||||
assert.equal(binds(args).length, list.length - 1);
|
||||
});
|
||||
|
||||
// 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', 'tools/unslop-hook', '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', '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', 'tools']);
|
||||
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
|
||||
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');
|
||||
|
||||
@@ -36,8 +36,6 @@ 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() {
|
||||
|
||||
+13
-4
@@ -13,6 +13,11 @@ cp RELEASE "$RELEASE_BACKUP"
|
||||
# One exit trap: the repo RELEASE is ALWAYS restored from the backup,
|
||||
# regardless of how the test run ends.
|
||||
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"' EXIT
|
||||
# Compose mounts PI_AUTH_FILE. Point it at a fixture so no activation reaches
|
||||
# the host's pi credential (row 244). chmod after the write: umask-proof (row 243).
|
||||
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
|
||||
|| { echo "cannot write the pi auth fixture" >&2; exit 1; }
|
||||
export PI_AUTH_FILE="$SANDBOX/pi/auth.json"
|
||||
|
||||
PASS=0
|
||||
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
|
||||
@@ -57,8 +62,11 @@ check "valid RELEASE leaves image tag consistent with version" $?
|
||||
# ---------- sandboxed state machine (Docker required) ----------
|
||||
if docker info >/dev/null 2>&1; then
|
||||
mkdir -p "$SANDBOX/data"
|
||||
# The mock adapter: no credential and no provider call in the gate (row 244,
|
||||
# ruling A, sage-1914). This suite proves the release state machine, image and
|
||||
# Compose path, not that the pi image reaches the provider.
|
||||
cat > "$SANDBOX/config.json" <<EOF
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"glm-5.3-flash"}}
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"glm-5.3-flash","adapter":"mock"}}
|
||||
EOF
|
||||
export MOSAIC_CONFIG="$SANDBOX/config.json"
|
||||
|
||||
@@ -66,7 +74,8 @@ EOF
|
||||
[ ! -e "$SANDBOX/data/state/active.json" ] \
|
||||
&& check "status created no pointer" 0 || check "status created no pointer" 1
|
||||
|
||||
expect_exit "fault-injected activation refuses" 1 -- scripts/release.sh activate --fault-injection
|
||||
# The mock gives the right marker, so only the fault's wrong expectation refuses.
|
||||
expect_exit "fault-injected activation refuses" 1 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate --fault-injection
|
||||
[ ! -e "$SANDBOX/data/state/active.json" ] \
|
||||
&& check "refused activation wrote no pointer" 0 || check "refused activation wrote no pointer" 1
|
||||
if [ -f "$SANDBOX/data/state/activation-log.jsonl" ]; then
|
||||
@@ -82,7 +91,7 @@ process.exit(e.event === "refused" && e.release && e.imageTag && e.at ? 0 : 1);
|
||||
check "refusal logged exactly once with valid fields" 1
|
||||
fi
|
||||
|
||||
expect_exit "healthy activation succeeds" 0 -- scripts/release.sh activate
|
||||
expect_exit "healthy activation succeeds" 0 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
@@ -90,7 +99,7 @@ process.exit(p.pointerVersion === 1 && p.release && p.imageTag && p.activatedAt
|
||||
' "$SANDBOX/data/state/active.json"
|
||||
check "pointer written with valid fields" $?
|
||||
|
||||
expect_exit "repeat activation succeeds (log grows)" 0 -- scripts/release.sh activate
|
||||
expect_exit "repeat activation succeeds (log grows)" 0 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate
|
||||
LINES=$(grep -c '' "$SANDBOX/data/state/activation-log.jsonl")
|
||||
[ "$LINES" -ge 3 ] && check "log is append-only across activations" 0 || check "log is append-only across activations" 1
|
||||
|
||||
|
||||
+34
-9
@@ -9,6 +9,18 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
SANDBOX="$(mktemp -d)"
|
||||
trap 'rm -rf "$SANDBOX"' EXIT
|
||||
# Real-model cases need the operator's credential and a billed provider call,
|
||||
# so they run only on request (row 244, sage-2061). The gate lane never passes
|
||||
# MOSAIC_LIVE_PROVIDER through.
|
||||
LIVE_SKIP="skip live provider cases (row 244: no provider call in the gate)"
|
||||
# Compose mounts PI_AUTH_FILE. Unless live cases were requested, point it at a
|
||||
# fixture so no run reaches the host's pi credential (row 244). chmod after the
|
||||
# write: umask-proof (row 243).
|
||||
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
|
||||
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
|
||||
|| { echo "cannot write the pi auth fixture" >&2; exit 1; }
|
||||
export PI_AUTH_FILE="$SANDBOX/pi/auth.json"
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
|
||||
@@ -423,7 +435,8 @@ else
|
||||
echo "skip workspace/capability cases (docker daemon unavailable)"
|
||||
fi
|
||||
|
||||
# ---------- live: real runs (Docker + credentials required) ----------
|
||||
# ---------- run records through the mock adapter (Docker required) ----------
|
||||
# These prove the run-record path, not the model, so the mock answers (row 244).
|
||||
# On failure, surface the run record + agent stderr BEFORE the sandbox
|
||||
# cleanup destroys them. Never let a wrong-exit mask the real reason.
|
||||
dump_latest_run() {
|
||||
@@ -441,14 +454,15 @@ dump_latest_run() {
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
RUNS1=$(ls "$DATA_ROOT/runs" 2>/dev/null | wc -l)
|
||||
if scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} live hello task succeeds with exact marker"
|
||||
if env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
|
||||
scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} hello task succeeds with exact marker"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} live hello task succeeds with exact marker" >&2
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} hello task succeeds with exact marker" >&2
|
||||
dump_latest_run
|
||||
fi
|
||||
|
||||
R1="$(ls -t "$DATA_ROOT/runs" | head -1)" # newest = the live run above
|
||||
R1="$(ls -t "$DATA_ROOT/runs" | head -1)" # newest = the hello run above
|
||||
[ -f "$DATA_ROOT/runs/$R1/result.json" ] && check "result.json written in run dir" 0 || check "result.json written in run dir" 1
|
||||
node -e '
|
||||
const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
||||
@@ -457,7 +471,8 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e
|
||||
check "result.json contents are correct" $?
|
||||
|
||||
printf '{"taskVersion":1,"id":"t-wrong","prompt":"Return your startup marker and nothing else.","expectExact":"MOSAIC_NOT_OK"}' > "$SANDBOX/wrong.json"
|
||||
scripts/run-task.sh run "$SANDBOX/wrong.json" >/dev/null 2>&1
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
|
||||
scripts/run-task.sh run "$SANDBOX/wrong.json" >/dev/null 2>&1
|
||||
RC=$?
|
||||
WRONG_REASON="$(latest_reason)"
|
||||
if [ "$RC" -eq 1 ] && [ "$WRONG_REASON" = "expect-mismatch" ]; then
|
||||
@@ -473,6 +488,14 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e
|
||||
|
||||
COUNT=$($TASK list | wc -l)
|
||||
[ "$COUNT" -ge 2 ] && check "list shows both runs" 0 || check "list shows both runs" 1
|
||||
else
|
||||
echo "skip run-record cases (docker unavailable)"
|
||||
fi
|
||||
|
||||
# ---------- live: real model (Docker + operator credential, on request) ----------
|
||||
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
|
||||
echo "$LIVE_SKIP"
|
||||
elif docker info >/dev/null 2>&1; then
|
||||
|
||||
# session fork (M11): teach in base, fork into child, child recalls;
|
||||
# ancestor file count must be unchanged by the fork
|
||||
@@ -512,9 +535,11 @@ grep -q "## Skillset" "$ONB/user/USER.md" && grep -q "## Pets" "$ONB/user/USER.m
|
||||
&& check "canon sections present" 0 || check "canon sections present" 1
|
||||
|
||||
# Live recall: real pi, real phrasing - assert containment, not exactness.
|
||||
# Needs Docker like the live task cases above. Without it the pair prints
|
||||
# a skip and counts as neither a pass nor a failure.
|
||||
if docker info >/dev/null 2>&1; then
|
||||
# Needs Docker and MOSAIC_LIVE_PROVIDER=1 like the live fork cases above.
|
||||
# Without either the pair prints a skip and counts as neither a pass nor a failure.
|
||||
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
|
||||
echo "$LIVE_SKIP"
|
||||
elif docker info >/dev/null 2>&1; then
|
||||
printf '{"taskVersion":1,"id":"t-user","prompt":"What is the user name? Reply with only the name.","session":"onb-check","timeoutSeconds":180}' > "$SANDBOX/recall-task.json"
|
||||
RC=0
|
||||
scripts/run-task.sh run "$SANDBOX/recall-task.json" >/dev/null 2>&1 || RC=$?
|
||||
|
||||
@@ -21,7 +21,9 @@ scripts/testing/gate-lane.sh run --lane a --candidate FULL_SHA \
|
||||
Each run creates a private root like `/mnt/storage/scratch/ga-ABCDEF` (30 bytes).
|
||||
An explicit `--temp-root` must already be private, outside `/tmp`, and no longer
|
||||
than 30 UTF-8 bytes after resolving symlinks. The sandbox's own `/tmp` and HOME
|
||||
are unchanged. Host HOME stays real. A listener covering loopback port 9, an
|
||||
are unchanged. Host HOME stays real. Steps get `PI_AUTH_FILE` naming a private `{}`
|
||||
fixture in the temp root, and the Docker guard refuses a Compose run whose
|
||||
`PI_AUTH_FILE` isn't a regular file inside the temp root (row 244). A listener covering loopback port 9, an
|
||||
unsafe privileged-port threshold or unreadable host inventory refuses startup.
|
||||
|
||||
Lane a leases ports 61000..61127, lane b 61128..61255. Refuse any band overlapping
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function executePinnedSteps(steps,options,{receipt,checkout,inspect
|
||||
verifyImage(receipt,checkout,inspect);return {rc:0,imageBuilds:0};
|
||||
}
|
||||
|
||||
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync } from 'node:fs';
|
||||
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync, chmodSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { status, appendRecord, runRecords, processInventory, sameProcess, nonce } from './lane-state.mjs';
|
||||
@@ -109,7 +109,9 @@ export function activeConflicts(o,observations={}){
|
||||
export function cleanEnv(o,run,docker){
|
||||
const kept=['PATH','HOME','USER','LOGNAME','SHELL','LANG','LC_ALL','LC_CTYPE','TZ','TERM','XDG_RUNTIME_DIR','DBUS_SESSION_BUS_ADDRESS','DOCKER_HOST','CHROMIUM'];
|
||||
const env=Object.fromEntries(kept.filter(k=>typeof process.env[k]==='string').map(k=>[k,process.env[k]]));
|
||||
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json')});
|
||||
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json'),
|
||||
// A step reaching Compose mounts this fixture, never the host's pi auth file (row 244).
|
||||
PI_AUTH_FILE:join(o.tempRoot,'pi-auth.json')});
|
||||
env.PATH=`${join(o.out,'bin')}:${env.PATH??'/usr/bin:/bin'}`;
|
||||
return env;
|
||||
}
|
||||
@@ -179,6 +181,7 @@ async function runGate(input){
|
||||
const guard=join(BUILDER,'scripts/testing/lane-docker.mjs');
|
||||
// Launch the guard directly so it validates every real Docker invocation.
|
||||
writeFileSync(join(o.out,'bin','docker'),`#!/bin/sh\nexec '${process.execPath.replaceAll("'","'\\''")}' '${guard.replaceAll("'","'\\''")}' "$@"\n`,{mode:0o700,flag:'wx'});
|
||||
const auth=join(o.tempRoot,'pi-auth.json');writeFileSync(auth,'{}',{mode:0o600,flag:'wx'});chmodSync(auth,0o600);
|
||||
const steps=discoverSteps(o.checkout);record({kind:'plan',steps});
|
||||
const before=homeMetadata(process.env.HOME);record({kind:'home-before',entries:before});
|
||||
const env=cleanEnv(o,run,docker);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, linkSync, unlinkSync, openSync, closeSync, fstatSync, constants } from 'node:fs';
|
||||
import { readFileSync, writeFileSync, linkSync, unlinkSync, openSync, closeSync, fstatSync, realpathSync, statSync, constants } from 'node:fs';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { refuse, shortRoot, nonce } from './lane-state.mjs';
|
||||
import { refuse, shortRoot, nonce, within } from './lane-state.mjs';
|
||||
const HOST_PI_AUTH='/home/jwoltje/.pi/agent/auth.json';
|
||||
function composeFiles(run){
|
||||
if(!/^sha256:[a-f0-9]{64}$/.test(run.imageId??''))refuse('invalid pinned image ID');
|
||||
if(typeof run.checkout!=='string'||!isAbsolute(run.checkout))refuse('invalid candidate checkout');
|
||||
@@ -27,12 +28,19 @@ export function writeComposeOverride(run){
|
||||
finally{if(fd!==undefined)closeSync(fd);}
|
||||
return file;
|
||||
}
|
||||
export function guardDocker(argv,run){
|
||||
// compose.yaml mounts PI_AUTH_FILE, defaulting to the host's real pi auth file (row 244).
|
||||
function laneAuthFile(env,run){
|
||||
const file=env.PI_AUTH_FILE;let real,root;
|
||||
try{real=realpathSync(file);root=realpathSync(run.tempRoot);}catch{refuse('Compose auth mount not a lane fixture');}
|
||||
if(!isAbsolute(file)||file===HOST_PI_AUTH||real===HOST_PI_AUTH||!within(root,real)||!statSync(real).isFile())refuse('Compose auth mount not a lane fixture');
|
||||
}
|
||||
export function guardDocker(argv,run,env=process.env){
|
||||
if(argv[0]==='info'&&argv.length===1)return argv;
|
||||
if(argv[0]==='image'&&argv[1]==='inspect'&&argv[2]===run.tag&&argv.length===3)return argv;
|
||||
if(argv[0]==='compose'&&argv[1]==='run'){
|
||||
const service=argv.indexOf('mosaic-agent',2);
|
||||
if(service<0||argv.slice(2,service).some(a=>!['--rm','-T','--no-deps','--interactive','-i'].includes(a)))refuse('Docker Compose run options not allowed');
|
||||
laneAuthFile(env,run);
|
||||
const [base,override]=composeFiles(run);
|
||||
return ['compose','-f',base,'-f',override,'run','--pull','never','--no-deps',...argv.slice(2)];
|
||||
}
|
||||
@@ -55,6 +63,6 @@ export function cleanupProject(run,docker='docker'){
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){
|
||||
try{const run=JSON.parse(readFileSync(process.env.MOSAIC_GATE_DOCKER_RECORD,'utf8'));if(process.env.COMPOSE_PROJECT_NAME!==run.project)refuse('Compose project mismatch');
|
||||
const argv=guardDocker(process.argv.slice(2),run);if(argv[0]==='compose')writeComposeOverride(run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
|
||||
const argv=guardDocker(process.argv.slice(2),run,process.env);if(argv[0]==='compose')writeComposeOverride(run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
|
||||
}catch(e){process.stderr.write(`${e.message}\n`);process.exit(2);}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# unslop-hook
|
||||
|
||||
Mechanical AI-tell enforcement for pi seats and for managed sessions on
|
||||
both harnesses (row 66, #1559). Anti-drift gate for the writing
|
||||
Mechanical AI-tell enforcement for pi seats. Anti-drift gate for the writing
|
||||
standard in SYSTEM.md / ms-unslop: prose distribution alone decays over long
|
||||
sessions; this check cannot forget.
|
||||
|
||||
@@ -19,9 +18,7 @@ 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 (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,
|
||||
recent assistant reply from the session file 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
|
||||
@@ -29,17 +26,6 @@ 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
|
||||
@@ -58,15 +44,6 @@ 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
|
||||
@@ -83,18 +60,6 @@ lists.
|
||||
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);
|
||||
@@ -107,15 +72,7 @@ lists.
|
||||
does not revive checks until the seat restarts. Acceptable for an advisory
|
||||
gate (review S1).
|
||||
- The fail-closed operator notification requires a UI. Print-mode sessions
|
||||
log `skipped_broken` but notify nobody (review S2). Managed sessions are
|
||||
the exception: the runner tells the operator in the next reply (row 66).
|
||||
- In a managed session the log sits in the run directory, which the session
|
||||
can write. The counts are the session's own report, not host evidence.
|
||||
- The extension's per-reply dedupe is in memory. After a restart the same
|
||||
reply can get a second notice.
|
||||
- On the first turn of a new Claude Code session the transcript may not
|
||||
hold the reply when Stop runs; the hook then checks
|
||||
`last_assistant_message` and logs an empty `entryId`.
|
||||
log `skipped_broken` but notify nobody (review S2).
|
||||
- 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.
|
||||
@@ -123,6 +80,4 @@ lists.
|
||||
## Promotion path
|
||||
|
||||
Stack issue (A4): checker shared as the single source for a matching Claude
|
||||
Code Stop-hook script; lists versioned beside SYSTEM.md contract text. Row 66
|
||||
did the first part: `claude-hook.js` runs the same checker and lists.
|
||||
Blocking a reply (Q84 option B) is a later decision.
|
||||
Code Stop-hook script; lists versioned beside SYSTEM.md contract text.
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/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,13 +120,9 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
// 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];
|
||||
// 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()) {
|
||||
const text = assistantText(entry);
|
||||
if (text === null) continue;
|
||||
const id = (entry as { id?: string }).id ?? "";
|
||||
|
||||
@@ -221,87 +221,6 @@ 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,9 +19,6 @@
|
||||
// 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");
|
||||
@@ -128,12 +125,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", source: w.source, detail: `banned word "${w.value}" x${count}`, count });
|
||||
if (count > 0) findings.push({ rule: "word", 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", source: p.source, detail: `phrase "${p.value}" x${count}`, count });
|
||||
if (count > 0) findings.push({ rule: "phrase", detail: `phrase "${p.value}" x${count}`, count });
|
||||
}
|
||||
|
||||
// Density-gated punctuation. The deliberate divergence from SYSTEM.md's
|
||||
@@ -143,14 +140,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", source: pc.source, detail: `${pc.label} x${count} (density-gated)`, count });
|
||||
findings.push({ rule: "punct", 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", source: pt.source, detail: `"${pt.detail}" x${count}`, count });
|
||||
if (count > 0) findings.push({ rule: "pattern", detail: `"${pt.detail}" x${count}`, count });
|
||||
}
|
||||
|
||||
return { clean: findings.length === 0, findings, charsChecked: text.length };
|
||||
|
||||
Reference in New Issue
Block a user