cowork-harness 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,8 @@ name: cowork-harness
3
3
  description: Test or debug a Claude Code skill/plugin under Claude Cowork's runtime — sandboxed agent, default-deny egress, the can_use_tool permission/question protocol — using the cowork-harness CLI. Use when validating or regression-testing a skill, authoring or debugging a scenario YAML (prompt + scripted answers + assert:), choosing a fidelity tier, scripting AskUserQuestion / tool-permission answers, or asserting artifacts, egress, or sub-agent dispatch. Especially when a harness run no-ops an assertion, fails on an unanswered gate, false-greens, a steered answer never reaches the model, or a web_fetch is unexpectedly denied or gated. Also when iterating or hardening a skill across fixes, or grounding a skill's self-critique against its own run evidence — including a document-analysis skill (cap table, deck, financial model, transcript) that needs an uploaded file attached to be critiqued at all. NOT for generic unit testing (pytest/vitest of your own scripts) or non-Cowork CI. Covers the skill / run / chat / record / replay / trace / decide / assertions / scaffold commands and the session-vs-scenario split.
4
4
  metadata:
5
5
  author: cowork-harness
6
- version: 1.18.0
7
- tracks-harness: cowork-harness 1.18.0 (baseline desktop-1.25927.0)
6
+ version: 1.19.0
7
+ tracks-harness: cowork-harness 1.19.0 (baseline desktop-1.25927.0)
8
8
  ---
9
9
 
10
10
  # cowork-harness
@@ -22,7 +22,7 @@ flagged with a loud `::warning::`, not silent — auto-answer a gate, observe an
22
22
  allowlist). This skill exists mostly to keep you out of those traps — the Gotchas section below is
23
23
  the highest-value part. Read it.
24
24
 
25
- > **Version note:** the facts and `file:line` pointers here track `cowork-harness 1.18.0` (baseline
25
+ > **Version note:** the facts and `file:line` pointers here track `cowork-harness 1.19.0` (baseline
26
26
  > `desktop-1.25927.0`). If your checkout is newer, prefer the live `--help` and — in a repo checkout —
27
27
  > `SPEC.md` / `docs/*.md` over this snapshot, and re-run the bundled linter.
28
28
 
@@ -39,7 +39,7 @@ Before the first command, confirm the CLI is reachable and **fail loud** (never
39
39
 
40
40
  - **One-shot check.** Run `cowork-harness doctor [--tier <tier>]` first — a read-only prerequisite check that inspects Docker, the staged agent, the token, and the baseline in one pass. The bullets below explain each thing it checks (and how to fix it).
41
41
  - **Replay-only? Skip `doctor`.** Replaying committed cassettes needs no Docker, no staged agent, and no token — and every tier's `doctor` validates the auth token (the live tiers also Docker + the staged agent), so a ✗ there is expected, not a blocker. Go straight to `cowork-harness replay <cassette>`.
42
- - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 1.18.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=1.18.0" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@>=1.18.0"`. **Pin `@>=1.18.0`, never `@latest`** — `@latest` can silently fetch an older CLI and the new commands fail as "unknown command", whereas the floor **fails loud** if no compatible version is published.
42
+ - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 1.19.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=1.19.0" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@>=1.19.0"`. **Pin `@>=1.19.0`, never `@latest`** — `@latest` can silently fetch an older CLI and the new commands fail as "unknown command", whereas the floor **fails loud** if no compatible version is published.
43
43
 
44
44
  This skill documents the CURRENT surface, not release history. If `cowork-harness --version` is
45
45
  OLDER than the floor, the per-release record of what you are missing is [CHANGELOG.md](https://github.com/yaniv-golan/cowork-harness/blob/main/CHANGELOG.md)
@@ -547,7 +547,7 @@ Docker, no re-record.
547
547
  | Situation | Symptom | Reach for (in order) |
548
548
  |---|---|---|
549
549
  | **The skill misbehaved** | wrong output, an unexpected gate, a denied tool, an opaque crash | `inspect` — what did it produce? · `trace <run-dir> --view <view>` — what did it actually do (tools, gates, sub-agent tree)? · `verify-run` — re-assert cheaply when only an assertion is wrong · `diff <old-run> <new-run>` — what changed since it worked · `chat` — reproduce it by hand |
550
- | **A green you don't trust** | an assert that may have tested nothing, a stale cassette, an auto-answered or decided gate | `replay --explain` — the evidence trail behind each *passing* assert · `replay --mutate` — perturbs each recorded JSON artifact value and reports which perturbations NOTHING caught (reporting only; never moves the verdict/exit code) · `lint` — assertions on the wrong CI lane / mixed-class keys · `verify-cassettes` — privacy + staleness over committed cassettes · the Gotchas landmine catalog — how a check passes vacuously · `run --repeat N` / `skill --repeat N` — did it pass, or pass once? · `stats` — flaky or expensive over time |
550
+ | **A green you don't trust** | an assert that may have tested nothing, a stale cassette, an auto-answered or decided gate | `replay --explain` — the evidence trail behind each *passing* assert · `replay --mutate` — perturbs a CAPPED SAMPLE of recorded JSON values (10/file, 50 total) and reports which perturbations NOTHING caught; the report names the sample size, so read it as a sample not a total (reporting only; never moves the verdict/exit code) · `lint` — assertions on the wrong CI lane / mixed-class keys · `verify-cassettes` — privacy + staleness over committed cassettes · the Gotchas landmine catalog — how a check passes vacuously · `run --repeat N` / `skill --repeat N` — did it pass, or pass once? · `stats` — flaky or expensive over time |
551
551
 
552
552
  A failed run also records `errorSource` (where the failure originated) and `stderrLogPath` (the captured
553
553
  agent stderr) — read those before re-running; a re-record rarely tells you more than the captured stderr
@@ -1,6 +1,6 @@
1
1
  # CI recipe — replay vs live lanes
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 1.18.0` (baseline `desktop-1.25927.0`).
3
+ Self-contained reference. Tracks `cowork-harness 1.19.0` (baseline `desktop-1.25927.0`).
4
4
 
5
5
  **Fastest path: the packaged Action.** One step gets you `replay`/`lint`/`verify-cassettes` plus a PR
6
6
  job-summary reporter (verdict table, staleness findings, cost/turns when available):
@@ -13,7 +13,7 @@ job-summary reporter (verdict table, staleness findings, cost/turns when availab
13
13
  ```
14
14
 
15
15
  The Action's `version` input defaults to `latest` — intentional so a copy-pasted recipe tracks the current
16
- release; pin an exact version (e.g. `version: "1.18.0"`) for reproducible CI.
16
+ release; pin an exact version (e.g. `version: "1.19.0"`) for reproducible CI.
17
17
 
18
18
  Reach for the manual multi-step form below only when you need per-step control the Action's inputs don't
19
19
  cover (a custom flag combination, a different runner matrix per step, or `lint`/`verify-cassettes` gated
@@ -58,7 +58,7 @@ sha256-*checked* but not hard-blocking on mismatch — it's advisory for an inte
58
58
  GitHub-hosted runners, no token/Docker/agent:
59
59
 
60
60
  ```yaml
61
- - run: npm i -g "cowork-harness@>=1.18.0"
61
+ - run: npm i -g "cowork-harness@>=1.19.0"
62
62
  - run: cowork-harness lint scenarios/*.yaml --strict --min-severity WARN
63
63
  # no silent false-greens. WITHOUT --strict this
64
64
  # step cannot fail on a WARN-class rule (e.g.
@@ -279,7 +279,7 @@ jobs:
279
279
  with: { node-version: '24' }
280
280
  - uses: actions/setup-python@v5
281
281
  with: { python-version: '3.x' } # python3 only — PyYAML is bundled with the linter
282
- - run: npm i -g "cowork-harness@>=1.18.0"
282
+ - run: npm i -g "cowork-harness@>=1.19.0"
283
283
  - run: cowork-harness lint scenarios/*.yaml # no-silent-false-green (needs python3; PyYAML bundled)
284
284
  - run: cowork-harness verify-cassettes cassettes/ --output-format json # privacy + staleness gate
285
285
  - run: cowork-harness replay cassettes/ --output-format json # token-free content/structure
@@ -308,7 +308,7 @@ jobs:
308
308
  echo "live=true" >> "$GITHUB_OUTPUT"
309
309
  fi
310
310
  - if: steps.guard.outputs.live == 'true'
311
- run: npm i -g "cowork-harness@>=1.18.0"
311
+ run: npm i -g "cowork-harness@>=1.19.0"
312
312
  - if: steps.guard.outputs.live == 'true'
313
313
  run: cowork-harness run scenarios/ --output-format json
314
314
  env:
@@ -1,6 +1,6 @@
1
1
  # Critique — the facts a plugin install can't otherwise reach
2
2
 
3
- Tracks `cowork-harness 1.18.0` (baseline `desktop-1.25927.0`). This is **not** a trim of the full
3
+ Tracks `cowork-harness 1.19.0` (baseline `desktop-1.25927.0`). This is **not** a trim of the full
4
4
  [`docs/critique.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/critique.md) (repo-only —
5
5
  flags, cost, reproduction discipline, known limitations all live there). This file covers exactly what a
6
6
  plugin install cannot otherwise discover: the run-dir artifact a harvester actually reads, the report's
@@ -1,6 +1,6 @@
1
1
  # Fidelity tiers & answer paths
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 1.18.0` (baseline `desktop-1.25927.0`).
3
+ Self-contained reference. Tracks `cowork-harness 1.19.0` (baseline `desktop-1.25927.0`).
4
4
 
5
5
  ## Fidelity tiers (`fidelity:` in the scenario)
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Scenario & session schema, assertion catalog, web_fetch, full gotchas
2
2
 
3
- Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 1.18.0`
3
+ Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 1.19.0`
4
4
  (baseline `desktop-1.25927.0`). If your checkout is newer, prefer the live [`docs/scenario.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/scenario.md),
5
5
  [`docs/session.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/session.md), and `SPEC.md`.
6
6
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Each recipe composes facts that live scattered across SKILL.md and the other references into one
4
4
  decision path. Every one answers a question a real fleet owner had to work out the hard way.
5
- Tracks `cowork-harness 1.18.0` (baseline `desktop-1.25927.0`), same as SKILL.md's front-matter. Recipe 2's `resolved-tier`/`unverifiable-tier` staleness classes and
5
+ Tracks `cowork-harness 1.19.0` (baseline `desktop-1.25927.0`), same as SKILL.md's front-matter. Recipe 2's `resolved-tier`/`unverifiable-tier` staleness classes and
6
6
  Recipe 3's `init-redact` shipped in 0.24.0 and are part of the current feature set — no version gate
7
7
  needed if your CLI meets SKILL.md's version floor.
8
8
 
package/CHANGELOG.md CHANGED
@@ -6,6 +6,61 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.19.0] — 2026-08-06
10
+
11
+ Follow-up to the consumer report against published 1.18.0: two false-positive/misreporting fixes in the
12
+ tooling 1.18.0 introduced, plus the scoping and summary controls those reports asked for.
13
+
14
+ ### Upgrade notes
15
+
16
+ - **The new `skills[]` axis can fail a cassette that passed under 1.18.0.** It applies only at
17
+ host-inheriting tiers (`protocol`, `hostloop`, or `cowork` resolving to hostloop). A flagged entry is a
18
+ skill name from the recording machine; re-record against a clean environment, or scope an allow with
19
+ `--allow-host-inventory <regex>` after reviewing the finding.
20
+ - **Conversely, `host-inventory` failures on a plugin's own agents go away.** If 1.18.0 forced you to add
21
+ an allow regex for `<plugin>:<agent>` entries, that exemption is now automatic and the regex can be
22
+ removed. No re-record is needed.
23
+
24
+ ### Added
25
+
26
+ - **`verify-cassettes` reads `skills[]` as a `host-inventory` axis.** A skill name discloses what is
27
+ installed on the recording machine, the same class of disclosure as an MCP server name, and the field
28
+ was previously read by no axis. Two exemptions: the agent's built-in skills, and a `<plugin>:<skill>`
29
+ whose plugin the same recording declares in `plugins[]`. Host-inheriting tiers only — at `protocol`
30
+ with local OAuth the harness keeps the operator's real `CLAUDE_CONFIG_DIR`, so personally installed
31
+ skills are discoverable there. `plugins[].name` is deliberately not scanned: that array holds only
32
+ entries the harness supplied via `--plugin-dir`, so it carries no host inventory to find.
33
+ - **`replay --mutate` accepts scoping and cap overrides.** `--mutate-include <glob>` and
34
+ `--mutate-exclude <glob>` (both repeatable; exclude applied last) restrict which artifact paths are
35
+ perturbed — `*` matches within a path segment, `**` across them. Filtering is applied before planning,
36
+ so an out-of-scope artifact is excluded from the eligible count as well. `--mutate-max-per-file <n>`
37
+ and `--mutate-max-total <n>` raise the sampling caps (defaults 10 and 50); prefer the per-file one,
38
+ which binds first. Any of these without `--mutate` is a usage error. Cost is linear — one full
39
+ assertion re-run per perturbation.
40
+
41
+ ### Changed
42
+
43
+ - **`verify-cassettes` prints a per-class findings count before the per-file listing**, e.g.
44
+ `findings by class: host-inventory 240`. Informational classes such as `unscanned` are counted too, so
45
+ the header agrees with the rows beneath it. The per-file rows are unchanged — the rollup is additive.
46
+ JSON output is unchanged; `findings[].cls` already carried this.
47
+
48
+ ### Fixed
49
+
50
+ - **`replay --mutate` reported its sample as though it were the full set.** The plan is capped at 10
51
+ values per file and 50 in total, with the per-file cap applied first, but the report showed only
52
+ `<uncaught>/<planned>`. It now appends the eligible total and names the binding cap — `(sampled 30 of
53
+ 120 eligible value(s); per-file cap 10 reached on 3 file(s))` — and omits the note entirely when
54
+ nothing was truncated. A bare ratio from 1.18.0 or earlier describes the sample, not the corpus.
55
+ - **`replay --mutate` produced no machine-readable output.** Coverage now rides the JSON envelope as
56
+ `mutation`, carrying `sampled`, `eligible`, `truncatedBy`, `caps`, and `uncaught`.
57
+ - **The `host-inventory` check flagged the scenario's own plugin agents.** At `hostloop` the agent roster
58
+ necessarily includes the agents of the mounted plugin, so any plugin declaring agents failed the gate
59
+ on upgrade to 1.18.0. An agent named `<plugin>:<agent>` whose plugin the same recording declares in
60
+ `plugins[]` is now exempt, matching the existing carve-out for `mcp.config`-attached servers. The
61
+ exemption is derived from the cassette, so it applies to recordings made before this release without
62
+ re-recording. A foreign agent, or one namespaced to a plugin the run never declared, still flags.
63
+
9
64
  ## [1.18.0] — 2026-08-06
10
65
 
11
66
  ### Upgrade notes
@@ -22,6 +77,11 @@ All notable changes to this project are documented here. The format is based on
22
77
 
23
78
  ### Fixed
24
79
 
80
+ - **`critique --help` and `docs/critique.md` misstated the tier prerequisites.** Both said the `container`
81
+ and `hostloop` tiers need an authenticated `claude` CLI on PATH. They need a token in the environment
82
+ or `.env` (`CLAUDE_CODE_OAUTH_TOKEN`, or `ANTHROPIC_API_KEY` as a CI fallback) — the graded turns run
83
+ the staged agent binary, not the host CLI. The evaluator passes are what require `claude` on PATH,
84
+ overridable with `COWORK_HARNESS_CLAUDE_BIN`.
25
85
  - **A scenario's `on_unanswered:` overrode an explicit `--on-unanswered` in silence.** The precedence is
26
86
  intentional and documented (`run --help`: "per-scenario answers/on_unanswered in the YAML take
27
87
  precedence where set") — a committed scenario is the reproducible definition of its own test — but a
package/README.md CHANGED
@@ -92,7 +92,7 @@ node dist/cli.js replay examples/replays/example-pdf-skill.cassette.json
92
92
 
93
93
  > **Installed globally instead?** Once linked/installed, the same command is `cowork-harness replay
94
94
  > <cassette>` — but the relative path above only resolves from a source checkout's `examples/replays/`.
95
- > From a global install (`npm i -g "cowork-harness@>=1.18.0"`), point at the package root instead:
95
+ > From a global install (`npm i -g "cowork-harness@>=1.19.0"`), point at the package root instead:
96
96
  > `cowork-harness replay "$(npm root -g)/cowork-harness/examples/replays/example-pdf-skill.cassette.json"`
97
97
  > (or copy the cassette into your own project and pass that path).
98
98
 
@@ -102,7 +102,7 @@ Live `run`/`skill` need the prerequisites in the next section — note the `prot
102
102
  > - **Replay only (zero setup):** `cowork-harness replay <cassette>` — no token, no Docker, no agent. The command above.
103
103
  > - **`protocol` (real model, no Docker):** needs only the auth token (item 3 below).
104
104
  > - **Live `container` / `microvm` / `hostloop` / `cowork`:** needs Docker (or Lima for `microvm`), a staged agent, and the token — run `cowork-harness doctor` first.
105
- > - **Invocation:** from a source checkout, `node dist/cli.js <cmd>` (or `npm link` to get the `cowork-harness` command); from a global install, `cowork-harness <cmd>`; the companion skill falls back to `npx "cowork-harness@>=1.18.0"`.
105
+ > - **Invocation:** from a source checkout, `node dist/cli.js <cmd>` (or `npm link` to get the `cowork-harness` command); from a global install, `cowork-harness <cmd>`; the companion skill falls back to `npx "cowork-harness@>=1.19.0"`.
106
106
 
107
107
  Two more worked examples worth knowing about: `examples/scenarios/protocol-smoke.yaml` (zero-Docker smoke
108
108
  test) and `examples/scenarios/skill-loads.yaml` (container-tier acceptance check) — see
@@ -127,7 +127,7 @@ claude plugin marketplace add yaniv-golan/cowork-harness
127
127
  claude plugin install cowork-harness@cowork-harness
128
128
  ```
129
129
 
130
- The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx "cowork-harness@>=1.18.0"` (a version floor that fails loud rather than silently fetching a too-old CLI; Node ≥ 22). Tiers above `protocol` still need Docker/Lima and a Claude Desktop agent binary — see the prerequisites below.
130
+ The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx "cowork-harness@>=1.19.0"` (a version floor that fails loud rather than silently fetching a too-old CLI; Node ≥ 22). Tiers above `protocol` still need Docker/Lima and a Claude Desktop agent binary — see the prerequisites below.
131
131
 
132
132
  It also follows the open [Agent Skills](https://agentskills.io) spec, so it installs cross-editor (Cursor, Codex, OpenCode, …) via [`npx skills`](https://github.com/vercel-labs/skills) (Vercel Labs' CLI implementation of that spec):
133
133
 
@@ -149,7 +149,7 @@ A global install is enough for CI `lint`, reading the teaching skill, and replay
149
149
  To `run` the worked examples live or copy them as a starting point, use a source checkout. (The marketplace
150
150
  skill install itself only pulls `.claude/skills/cowork-harness/` — SKILL.md + `references/` + `scenario.py`/
151
151
  assertion keys, per `.claude-plugin/marketplace.json`'s `source` — not the rest of this table; the full set
152
- above becomes available once the skill's first command self-bootstraps `npx "cowork-harness@>=1.18.0"` — see
152
+ above becomes available once the skill's first command self-bootstraps `npx "cowork-harness@>=1.19.0"` — see
153
153
  [above](#drive-it-from-claude-code-companion-skill) — which pulls the same npm package as the global-install row.)
154
154
 
155
155
  ### Prerequisites for anything above `protocol` fidelity
@@ -708,7 +708,7 @@ jobs:
708
708
  anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
709
709
  ```
710
710
 
711
- Every run writes a Markdown verdict table (scenario, pass/fail, signals, cost/turns when available, staleness findings, and the replay-skipped-assertions honesty line) to the job summary. Inputs: `command`, `path` (required), `version` (npm dist-tag/version, default `latest` — intentional so recipes track the current release; pin an exact version for reproducible CI. The companion skill's `cowork-harness@>=1.18.0` floor guidance applies to ad-hoc CLI installs, not this input), `strict` (applies to `replay` (staleness findings), `lint`/`lint-skill` (WARN/INFO), and `analyze-skill` (any advisory finding); IGNORED — not forwarded — for `verify-cassettes`/`run`, which don't accept the flag), `fail-on-skill-drift` (**`replay`-only** — never forwarded to the analyzers), `extra-args`, `summary` (default `true`), `anthropic-api-key` (live lane only). Outputs: `ok` (`"true"`/`"false"`, mirrors the exit code), `envelope-path` (path to the raw JSON envelope, for post-processing), `summary-md` (the rendered verdict table, exposed as an output — not just written to `$GITHUB_STEP_SUMMARY` — because that file is scoped to this action's own invocation and a caller's later step gets a fresh, empty one). See [`action.yml`](./action.yml) for the full input/output reference.
711
+ Every run writes a Markdown verdict table (scenario, pass/fail, signals, cost/turns when available, staleness findings, and the replay-skipped-assertions honesty line) to the job summary. Inputs: `command`, `path` (required), `version` (npm dist-tag/version, default `latest` — intentional so recipes track the current release; pin an exact version for reproducible CI. The companion skill's `cowork-harness@>=1.19.0` floor guidance applies to ad-hoc CLI installs, not this input), `strict` (applies to `replay` (staleness findings), `lint`/`lint-skill` (WARN/INFO), and `analyze-skill` (any advisory finding); IGNORED — not forwarded — for `verify-cassettes`/`run`, which don't accept the flag), `fail-on-skill-drift` (**`replay`-only** — never forwarded to the analyzers), `extra-args`, `summary` (default `true`), `anthropic-api-key` (live lane only). Outputs: `ok` (`"true"`/`"false"`, mirrors the exit code), `envelope-path` (path to the raw JSON envelope, for post-processing), `summary-md` (the rendered verdict table, exposed as an output — not just written to `$GITHUB_STEP_SUMMARY` — because that file is scoped to this action's own invocation and a caller's later step gets a fresh, empty one). See [`action.yml`](./action.yml) for the full input/output reference.
712
712
 
713
713
  The provided [GitHub Actions workflow](.github/workflows/ci.yml) runs an **eight-stage pipeline**. The **build** + **test** stages are the token-free gate you can copy into your skill repo; the `floor`, `action-self-test`, `python`, `boundary`, `scenarios`, and `parity-drift` stages are this repo's own fidelity self-tests and are not directly portable (they build the harness's Docker image and run harness-specific e2e scenarios — see [`ci-recipe.md`](./.claude/skills/cowork-harness/references/ci-recipe.md) for the skill-repo template):
714
714
 
@@ -32,7 +32,7 @@ import { isVmSessionsPath } from "../vm-paths.js";
32
32
  * pool (each run creates two networks) and press model API rate limits — both surface as actionable errors. */
33
33
  const MAX_RECORD_CONCURRENCY = 8;
34
34
  import { evaluate, budgetFields } from "../assert.js";
35
- import { planMutations, applyMutation, explainNoMutations } from "./mutate.js";
35
+ import { planMutationsWithStats, summarizeMutationPlan, matchesAnyGlob, applyMutation, explainNoMutations, } from "./mutate.js";
36
36
  import { anyGlobMatches } from "../glob.js";
37
37
  import { extractComputerLinks } from "./computer-links.js";
38
38
  import { makeRenderer, renderFooter } from "./renderer.js";
@@ -787,6 +787,43 @@ function isCapabilityManifest(line) {
787
787
  * machine's own inventory. `cowork` is included because it resolves to container OR hostloop via a baseline
788
788
  * gate — the privacy scan fails closed rather than loading a baseline to find out. */
789
789
  const HOST_INHERITING_TIERS = new Set(["protocol", "hostloop", "cowork"]);
790
+ /** Plugin names the scenario mounted, harvested from anywhere in the event stream.
791
+ *
792
+ * `system/init` carries `plugins[]` beside `agents[]`, so that surface is self-sufficient — but the
793
+ * registry `control_response` lists agents with NO plugin array of its own. Collecting once over the
794
+ * whole stream and passing the result into every scan covers both, and keeps `scanHostInventory` a pure
795
+ * function of one payload plus explicit context. Tolerant by construction: a non-JSON line, a missing
796
+ * `plugins`, or a bare-string entry are all normal, not errors. */
797
+ export function collectDeclaredPlugins(events) {
798
+ const names = new Set();
799
+ const visit = (node) => {
800
+ if (Array.isArray(node)) {
801
+ for (const v of node)
802
+ visit(v);
803
+ return;
804
+ }
805
+ if (node === null || typeof node !== "object")
806
+ return;
807
+ const o = node;
808
+ if (Array.isArray(o.plugins))
809
+ for (const p of o.plugins) {
810
+ const n = typeof p === "string" ? p : p?.name;
811
+ if (typeof n === "string" && n)
812
+ names.add(n);
813
+ }
814
+ for (const v of Object.values(o))
815
+ visit(v);
816
+ };
817
+ for (const line of events ?? []) {
818
+ try {
819
+ visit(JSON.parse(line));
820
+ }
821
+ catch {
822
+ continue; // a non-JSON transcript line declares no plugins
823
+ }
824
+ }
825
+ return [...names];
826
+ }
790
827
  export function scanCassette(cassette, allow) {
791
828
  const findings = [];
792
829
  const FULL = DEFAULT_SCAN_PATTERNS; // email + currency + domain + path + machine-inventory
@@ -823,6 +860,11 @@ export function scanCassette(cassette, allow) {
823
860
  // identify as sealed (`container`, `microvm`) is exempt. Same fail-closed reasoning as `cowork`.
824
861
  const tier = (cassette.effectiveFidelity ?? cassette.scenario.fidelity);
825
862
  if (tier === undefined || HOST_INHERITING_TIERS.has(tier)) {
863
+ // Harvest the plugin names the SCENARIO mounted, once, across the whole event stream — then hand them
864
+ // to every scan. `system/init` carries `plugins[]` beside `agents[]` so it is self-sufficient, but the
865
+ // registry `control_response` lists agents with no plugin array of its own; without this pre-pass that
866
+ // surface keeps flagging the scenario's own plugin agents. See scanHostInventory's A4.
867
+ const declaredPlugins = collectDeclaredPlugins(cassette.events);
826
868
  const structural = (lines, key) => lines?.forEach((l, i) => {
827
869
  let decoded;
828
870
  try {
@@ -831,7 +873,7 @@ export function scanCassette(cassette, allow) {
831
873
  catch {
832
874
  return; // a non-JSON transcript line has no name fields to read
833
875
  }
834
- findings.push(...scanHostInventory(decoded, `${key}[${i}]`, allow));
876
+ findings.push(...scanHostInventory(decoded, `${key}[${i}]`, allow, declaredPlugins));
835
877
  });
836
878
  structural(cassette.events, "events");
837
879
  structural(cassette.controlOut, "controlOut");
@@ -3134,6 +3176,7 @@ function replayErrorResult(file) {
3134
3176
  effectiveFidelity: undefined,
3135
3177
  fidelityWarnings: undefined,
3136
3178
  staleness: undefined,
3179
+ mutation: undefined, // the protocol-error stub carries no mutation report
3137
3180
  skippedAssertions: undefined,
3138
3181
  toolResults: undefined,
3139
3182
  l0PluginDivergence: undefined,
@@ -3479,7 +3522,13 @@ export const REPLAY_BOOLEAN_FLAGS = [
3479
3522
  "--quiet",
3480
3523
  "--verbose",
3481
3524
  ];
3482
- export const REPLAY_VALUE_FLAGS = ["--output-format", "--assert-from"];
3525
+ export const REPLAY_VALUE_FLAGS = ["--output-format", "--assert-from", "--mutate-max-per-file", "--mutate-max-total"];
3526
+ // A SEPARATE axis from REPLAY_VALUE_FLAGS, for the same reason VERIFY_CASSETTES_REPEATED_FLAGS is one:
3527
+ // parseArgs collects every occurrence of a repeated flag into p.repeated[], while a value flag keeps only
3528
+ // the LAST. Scoping is inherently multi-pattern ("everything but handoff/ and tmp/"), so folding these in
3529
+ // would silently honour one pattern and drop the rest — a semantic regression, not a refactor. `replay`
3530
+ // had no repeated-flag axis at all before these.
3531
+ export const REPLAY_REPEATED_FLAGS = ["--mutate-include", "--mutate-exclude"];
3483
3532
  // Both inert on `replay` — parsed into p.flags, never read anywhere in cmdReplay below (verified by
3484
3533
  // reading every `p.flags["--quiet"]`/`p.flags["--verbose"]` site in this file: none exists outside
3485
3534
  // cmdRecord). Contrast RECORD_ALLOWLIST's --verbose entry: record's --quiet IS wired up there, so it's
@@ -3494,7 +3543,9 @@ export const REPLAY_ALLOWLIST = [
3494
3543
  // one; this one was missing --best-effort-future-cassette entirely, undiscoverable at the exact point
3495
3544
  // (`cassette format too new: …`) that tells a user to pass it — the P9 bug this generalization fixes).
3496
3545
  export const REPLAY_USAGE = "usage: replay <file.cassette.json | dir/> [--strict] [--fail-on-skill-drift] [--mutate] [--assert-from <scenario.yaml> | --reassert] [--write [--allow-failing]] [--explain] [--best-effort-future-cassette] [--output-format text|json]\n" +
3497
- " --mutate: perturb each recorded JSON artifact value, re-run the assertions, and report which perturbations NOTHING caught — those fields are unguarded. Reporting only; never changes the verdict or exit code.\n" +
3546
+ " --mutate: perturb recorded JSON artifact values, re-run the assertions, and report which perturbations NOTHING caught — those fields are unguarded. SAMPLES: max 10 values per file, 50 total (per-file applies first), so `N/N caught by nothing` is N of the sample, not of your corpus; when a cap binds the report names it and the eligible total. Reporting only; never changes the verdict or exit code.\n" +
3547
+ " --mutate-include <glob> / --mutate-exclude <glob>: scope which artifact paths are perturbed (repeatable; exclude wins). `*` stays inside a path segment, `**` crosses them — so `--mutate-exclude 'handoff/**'` drops per-run internals nobody should assert on, and the sample is spent on deliverables instead.\n" +
3548
+ " --mutate-max-per-file <n> / --mutate-max-total <n>: raise the sampling caps (default 10 / 50). Per-file is applied FIRST, so with a handful of artifacts raising only --mutate-max-total changes nothing. Cost is linear — one full assertion re-run per perturbation.\n" +
3498
3549
  " --explain: after the footer, print the evidence trail for each PASSING assert (which link resolved, which file matched, which value satisfied a bound) — text mode; json already carries assertions[].evidence.\n" +
3499
3550
  " by default the assertions FROZEN in the cassette drive the verdict (deterministic); a sibling scenario whose assert: differs only prints a notice.\n" +
3500
3551
  ` --assert-from <file> / --reassert: token-free re-check against the on-disk assert:/expect_denied: — recording-shaping drift (${RECORDING_SHAPING_FIELDS.join("/")}) and skill staleness HARD-FAIL.\n` +
@@ -3510,6 +3561,7 @@ export async function cmdReplay(args) {
3510
3561
  // fork this list back into a local literal (that's the drift P9 generalized P3's fix to prevent).
3511
3562
  booleans: [...REPLAY_BOOLEAN_FLAGS],
3512
3563
  values: [...REPLAY_VALUE_FLAGS],
3564
+ repeated: [...REPLAY_REPEATED_FLAGS],
3513
3565
  enums: { "--output-format": ["text", "json"] },
3514
3566
  aliases: { "-q": "--quiet" },
3515
3567
  });
@@ -3552,6 +3604,32 @@ export async function cmdReplay(args) {
3552
3604
  warn("::notice:: [replay] --allow-failing only affects --write's verdict gate; it is a no-op without --write\n");
3553
3605
  const failOnSkillDrift = (p.flags["--fail-on-skill-drift"] ?? false) || reassertMode; // narrower gate: only skill-source drift fails
3554
3606
  const mutate = p.flags["--mutate"] ?? false;
3607
+ // Scoping + cap overrides for --mutate. Repeated flags come back as arrays; exclude is applied after
3608
+ // include so "everything under outputs/, except the scratch subtree" reads the obvious way.
3609
+ const mutateInclude = p.repeated["--mutate-include"] ?? [];
3610
+ const mutateExclude = p.repeated["--mutate-exclude"] ?? [];
3611
+ const mutateCap = (flag) => {
3612
+ const raw = p.options[flag];
3613
+ if (raw === undefined)
3614
+ return undefined;
3615
+ const n = Number(raw);
3616
+ if (!Number.isInteger(n) || n < 1)
3617
+ return fail("replay", "usage", `${flag} must be a positive integer (got "${raw}")`, undefined, asJson);
3618
+ return n;
3619
+ };
3620
+ const mutateMaxPerFile = mutateCap("--mutate-max-per-file");
3621
+ const mutateMaxTotal = mutateCap("--mutate-max-total");
3622
+ // These only shape a --mutate pass; accepting them silently without it would look like scoping worked.
3623
+ if (!mutate) {
3624
+ const orphan = [
3625
+ mutateInclude.length ? "--mutate-include" : "",
3626
+ mutateExclude.length ? "--mutate-exclude" : "",
3627
+ mutateMaxPerFile !== undefined ? "--mutate-max-per-file" : "",
3628
+ mutateMaxTotal !== undefined ? "--mutate-max-total" : "",
3629
+ ].filter(Boolean);
3630
+ if (orphan.length)
3631
+ return fail("replay", "usage", `${orphan.join(", ")} require(s) --mutate (they only shape a mutation pass).`, undefined, asJson);
3632
+ }
3555
3633
  if (strict && p.flags["--fail-on-skill-drift"])
3556
3634
  warn("::notice:: [replay] --strict and --fail-on-skill-drift both passed — --strict is the superset (fails on every class), so --fail-on-skill-drift is redundant here\n");
3557
3635
  const resolved = resolveInputs(target, ".cassette.json");
@@ -3707,6 +3785,10 @@ export async function cmdReplay(args) {
3707
3785
  strict,
3708
3786
  failOnSkillDrift,
3709
3787
  mutate,
3788
+ mutateInclude,
3789
+ mutateExclude,
3790
+ mutateMaxPerFile,
3791
+ mutateMaxTotal,
3710
3792
  cassetteDir: dirname(f),
3711
3793
  bestEffortFutureCassette,
3712
3794
  notesSink: collectNotes,
@@ -3875,7 +3957,7 @@ export const USAGE_GUARD_REGISTRY = [
3875
3957
  command: "replay",
3876
3958
  booleanFlags: REPLAY_BOOLEAN_FLAGS,
3877
3959
  valueFlags: REPLAY_VALUE_FLAGS,
3878
- repeatedFlags: [],
3960
+ repeatedFlags: REPLAY_REPEATED_FLAGS,
3879
3961
  aliases: { "-q": "--quiet" },
3880
3962
  usage: REPLAY_USAGE,
3881
3963
  allowlist: REPLAY_ALLOWLIST,
@@ -4126,6 +4208,20 @@ export async function cmdVerifyCassettes(args) {
4126
4208
  log("⚠ cowork-harness: --skip-privacy: privacy scan was skipped");
4127
4209
  if (!doScenarioDrift)
4128
4210
  log("⚠ cowork-harness: --skip-scenario-drift: scenario prompt-drift check was skipped");
4211
+ // A per-class count, printed ONCE ahead of the per-file rows. A sweep that surfaces hundreds of
4212
+ // findings of a single class reads as hundreds of separate problems; a consumer piped the output
4213
+ // through `uniq -c` to discover 240 findings were one class with one cause. This is additive — every
4214
+ // per-file row below still prints, so the attribution the `notes` rationale protects is untouched.
4215
+ // It answers "what kind, and how many" before the reader starts scrolling, not instead of it.
4216
+ const byClass = new Map();
4217
+ for (const r of results)
4218
+ for (const f of r.findings)
4219
+ byClass.set(f.cls, (byClass.get(f.cls) ?? 0) + 1);
4220
+ if (byClass.size)
4221
+ log(`findings by class: ${[...byClass.entries()]
4222
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
4223
+ .map(([cls, n]) => `${cls} ${n}`)
4224
+ .join(" · ")}`);
4129
4225
  for (const r of results) {
4130
4226
  if (r.error)
4131
4227
  log(`✗ ${r.file}: [error] ${r.error}`);
@@ -4787,6 +4883,9 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
4787
4883
  // outside the try, since it doesn't depend on anything materializeManifest produced.
4788
4884
  const folderPrefixResolution = buildFolderPrefixMap(cassette);
4789
4885
  // Populated only under --mutate; surfaced after the verdict so it reads as coverage info, not a failure.
4886
+ // Surfaced on the RunResult so `--mutate --output-format json` is machine-readable. It was previously
4887
+ // assigned here and never read by anything, so every consumer had to scrape stderr — which is how the
4888
+ // capped denominator went unnoticed in the first place.
4790
4889
  let mutationReport;
4791
4890
  // materializeManifest created a temp dir (`replayWorkRoot`) above; everything below uses it and
4792
4891
  // then returns. Wrap the rest in try/finally so the temp dir is removed on every exit path (normal
@@ -4955,7 +5054,21 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
4955
5054
  const inlined = (cassette.artifacts ?? [])
4956
5055
  .filter((a) => typeof a.body === "string" && !a.truncated)
4957
5056
  .map((a) => ({ path: a.path, body: a.body }));
4958
- const plan = planMutations(inlined);
5057
+ // planMutationsWITHSTATS, not planMutations: the plan is CAPPED (10 per file, 50 total), so
5058
+ // reporting `uncaught/plan.length` alone reads as "N of your N fields are unguarded" when N is a
5059
+ // sample. A consumer aggregated twenty such lines into "1,020 perturbations, 0 caught" and came one
5060
+ // step from concluding their assertions verified nothing — the truth was their asserted paths were
5061
+ // never in the sample. The stats form exists precisely to make that discoverable; it was simply
5062
+ // never wired to its only caller.
5063
+ const include = opts.mutateInclude ?? [];
5064
+ const exclude = opts.mutateExclude ?? [];
5065
+ // Filter BEFORE planning so the caps bind on what survived, not on the whole corpus. An artifact
5066
+ // dropped here is absent from `eligible` too — the report describes the scope you asked for.
5067
+ const scoped = inlined.filter((a) => (include.length === 0 || matchesAnyGlob(a.path, include)) && !matchesAnyGlob(a.path, exclude));
5068
+ const planOpts = { maxPerFile: opts.mutateMaxPerFile, maxTotal: opts.mutateMaxTotal };
5069
+ const stats = planMutationsWithStats(scoped, planOpts);
5070
+ const plan = stats.mutations;
5071
+ const coverage = summarizeMutationPlan(stats, planOpts);
4959
5072
  const uncaught = [];
4960
5073
  const baselineFailed = new Set(assertions.map((a, i) => (a.pass ? -1 : i)).filter((i) => i >= 0));
4961
5074
  for (const m of plan) {
@@ -4980,16 +5093,36 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
4980
5093
  writeFileSync(abs, original); // restore unconditionally — a thrown assert must not leave a mutated tree
4981
5094
  }
4982
5095
  }
4983
- mutationReport = { planned: plan.length, uncaught };
5096
+ mutationReport = {
5097
+ sampled: plan.length,
5098
+ eligible: coverage.eligible,
5099
+ truncatedBy: coverage.truncatedBy,
5100
+ caps: coverage.caps,
5101
+ uncaught,
5102
+ };
5103
+ // Only when truncation actually happened: on a corpus under both caps the counts ARE the whole
5104
+ // truth and the parenthetical is noise. Naming the binding cap matters — the per-file cap is
5105
+ // checked first, so "raise --mutate-max-total" is inert advice whenever per-file bound.
5106
+ const scope = coverage.truncatedBy
5107
+ ? ` (sampled ${coverage.sampled} of ${coverage.eligible} eligible value(s)` +
5108
+ (coverage.truncatedBy === "per-file"
5109
+ ? `; per-file cap ${coverage.caps.perFile} reached on ${coverage.filesAtPerFileCap} file(s)`
5110
+ : `; total cap ${coverage.caps.total} reached`) +
5111
+ `)`
5112
+ : "";
4984
5113
  if (!plan.length)
4985
- log(`::notice:: [mutate] ${explainNoMutations(cassette.artifacts ?? [], inlined)}`);
5114
+ // Attribute an empty plan to the FILTERS when they are what emptied it — otherwise the generic
5115
+ // "no perturbable values" explanation sends the reader looking for a JSON deliverable they have.
5116
+ log(scoped.length === 0 && inlined.length > 0
5117
+ ? `::notice:: [mutate] --mutate-include/--mutate-exclude filtered out all ${inlined.length} inlined JSON artifact(s) — nothing left to perturb`
5118
+ : `::notice:: [mutate] ${explainNoMutations(cassette.artifacts ?? [], scoped)}`);
4986
5119
  else if (uncaught.length) {
4987
- log(`::warning:: [mutate] ${uncaught.length}/${plan.length} perturbation(s) CAUGHT BY NOTHING — these fields are unguarded:`);
5120
+ log(`::warning:: [mutate] ${uncaught.length}/${plan.length} sampled perturbation(s) CAUGHT BY NOTHING — these fields are unguarded${scope}:`);
4988
5121
  for (const u of uncaught)
4989
5122
  log(` ${u}`);
4990
5123
  }
4991
5124
  else
4992
- log(`::notice:: [mutate] all ${plan.length} perturbation(s) were caught — assertions cover every perturbed field`);
5125
+ log(`::notice:: [mutate] all ${plan.length} sampled perturbation(s) were caught — assertions cover every perturbed field${scope}`);
4993
5126
  }
4994
5127
  // under --strict, EVERY staleness finding becomes a failing assertion (non-zero exit), not just a
4995
5128
  // warning. --fail-on-skill-drift is the narrower gate: only the skill-source classes fail (incl.
@@ -5087,6 +5220,7 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
5087
5220
  return assembleRunResult({
5088
5221
  turn: undefined, // replay reconstructs one recorded run; no multi-turn attribution
5089
5222
  command: "replay", // #48
5223
+ mutation: mutationReport, // --mutate only; undefined otherwise
5090
5224
  // A replay is held to the lane the RECORDED scenario declared — the frozen contract, not the
5091
5225
  // replaying machine's. Absent on a cassette recorded before the axis existed ⇒ local.
5092
5226
  lane: cassette.scenario.lane,
@@ -143,6 +143,7 @@ export function buildChatResult(record, opts) {
143
143
  partial: undefined,
144
144
  unansweredGate: undefined,
145
145
  staleness: undefined,
146
+ mutation: undefined, // replay --mutate only
146
147
  skippedAssertions: undefined,
147
148
  outcome: undefined, // rollup of `verdict`; chat has none, so this is absent too
148
149
  verdict: undefined, // chat carries NO verdict (no assertions were evaluated) — left absent, never a vacuous {pass:true,...}
@@ -1351,6 +1351,7 @@ export async function executeScenario(scenario, opts = {}) {
1351
1351
  partial: undefined,
1352
1352
  unansweredGate: undefined,
1353
1353
  staleness: undefined,
1354
+ mutation: undefined, // replay --mutate only
1354
1355
  skippedAssertions: undefined,
1355
1356
  outcome: undefined, // stamped alongside the verdict just below (derived from it)
1356
1357
  verdict: undefined, // computed just below (after assertions are evaluated / the object is fully assembled) and stored — see the comment there
@@ -1717,6 +1718,7 @@ export function buildPartialResult(args) {
1717
1718
  l0PluginDivergence: undefined,
1718
1719
  missingCapabilityUse: undefined,
1719
1720
  staleness: undefined,
1721
+ mutation: undefined, // replay --mutate only
1720
1722
  skippedAssertions: undefined,
1721
1723
  outcome: undefined, // stamped alongside the verdict just below (derived from it)
1722
1724
  verdict: undefined, // computed just below (after every other field is assembled) and stored — see the comment there
@@ -126,6 +126,42 @@ export function planMutationsWithStats(artifacts, opts) {
126
126
  }
127
127
  return { mutations, eligibleLeafCounts, truncatedTotal };
128
128
  }
129
+ /** Glob matcher for `--mutate-include` / `--mutate-exclude`, over cassette-recorded artifact paths.
130
+ *
131
+ * Deliberately its own, rather than reusing `analyze-skill`'s: that one is module-private, EXPANDS the
132
+ * filesystem (`readdirSync`) rather than matching a string, accepts only `dir/*.ext`-shaped patterns,
133
+ * and is case-insensitive. Artifact paths are recorded strings — matching them is a pure predicate, and
134
+ * case is meaningful.
135
+ *
136
+ * `*` matches within one path segment; `**` crosses segments (so `handoff/**` scopes a whole subtree,
137
+ * which is the shape someone reaches for to exclude per-run internals). Every other character is
138
+ * literal, including regex metacharacters. */
139
+ export function matchesAnyGlob(path, patterns) {
140
+ return patterns.some((pattern) => globToRegExp(pattern).test(path));
141
+ }
142
+ function globToRegExp(pattern) {
143
+ // Split on the ** token first so it can be expanded to a cross-segment match; everything between is
144
+ // escaped literally, with single `*` limited to non-separator characters.
145
+ const body = pattern
146
+ .split("**")
147
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"))
148
+ .join(".*");
149
+ return new RegExp(`^${body}$`); // case-SENSITIVE by construction
150
+ }
151
+ export function summarizeMutationPlan(stats, opts) {
152
+ const perFile = opts?.maxPerFile ?? DEFAULT_MAX_PER_FILE;
153
+ const total = opts?.maxTotal ?? DEFAULT_MAX_TOTAL;
154
+ const eligible = Object.values(stats.eligibleLeafCounts).reduce((a, b) => a + b, 0);
155
+ const takenPerFile = new Map();
156
+ for (const m of stats.mutations)
157
+ takenPerFile.set(m.file, (takenPerFile.get(m.file) ?? 0) + 1);
158
+ let filesAtPerFileCap = 0;
159
+ for (const [file, count] of takenPerFile)
160
+ if (count >= perFile && (stats.eligibleLeafCounts[file] ?? 0) > count)
161
+ filesAtPerFileCap++;
162
+ const truncatedBy = stats.mutations.length >= total && eligible > stats.mutations.length ? "total" : filesAtPerFileCap > 0 ? "per-file" : null;
163
+ return { sampled: stats.mutations.length, eligible, truncatedBy, filesAtPerFileCap, caps: { perFile, total } };
164
+ }
129
165
  /** Pure planner: decides which leaves across a set of artifacts are worth perturbing, bounded by
130
166
  * maxPerFile/maxTotal (defaults 10/50 — a full assertion-suite re-run per mutation, so cost is linear
131
167
  * in the length of this list). Convenience wrapper over planMutationsWithStats for callers that don't
package/dist/scan.js CHANGED
@@ -91,6 +91,14 @@ export const KNOWN_BUILTIN_AGENTS = new Set([
91
91
  "Plan",
92
92
  "statusline-setup",
93
93
  ]);
94
+ /** Skills the AGENT itself ships, present in a clean recording regardless of what a scenario mounts.
95
+ * Observed in every committed fixture across the sealed `container` tier (where no host config dir is
96
+ * reachable) and `hostloop`, and confirmed in the staged agent ELF — so a bare name outside this set
97
+ * came from the operator's own config dir, not the product.
98
+ *
99
+ * Closed set, same contract as KNOWN_BUILTIN_AGENTS: extend it deliberately when the agent's built-in
100
+ * roster changes. A new built-in would surface on the first fresh recording after a `sync`. */
101
+ export const KNOWN_BUILTIN_SKILLS = new Set(["deep-research"]);
94
102
  /** `account` keys that identify the OPERATOR. A clean recording's account block is `{tokenSource,
95
103
  * apiProvider}` only. `email` is usually redacted upstream by the time it reaches here — the load-bearing
96
104
  * members are `organization` and `subscriptionType`, which no redaction rule touches. */
@@ -109,13 +117,17 @@ const ACCOUNT_IDENTITY_KEYS = ["email", "organization", "subscriptionType"];
109
117
  * necessarily one the scenario attached on purpose via `mcp.config` — a documented, supported feature —
110
118
  * and flagging it would red CI on a legitimate fixture.
111
119
  */
112
- export function scanHostInventory(decoded, where, allow) {
120
+ export function scanHostInventory(decoded, where, allow, declaredPlugins = []) {
113
121
  const out = [];
114
122
  const norm = allow.map(normAllow);
115
123
  const push = (sample, detail) => {
116
124
  if (!allowed(sample, HOST_INVENTORY_CLS, norm))
117
125
  out.push({ where: `${where} ${detail}`, cls: HOST_INVENTORY_CLS, sample });
118
126
  };
127
+ // Plugin names the SCENARIO mounted. `declaredPlugins` covers payloads with no sibling `plugins[]` —
128
+ // notably the registry `control_response`, whose agents arrive as `{name,description,model}` with no
129
+ // plugin list beside them; the caller harvests those names from the events array and passes them here.
130
+ const declared = new Set(declaredPlugins);
119
131
  const visit = (node) => {
120
132
  if (Array.isArray(node)) {
121
133
  for (const v of node)
@@ -151,13 +163,58 @@ export function scanHostInventory(decoded, where, allow) {
151
163
  if (acct[k] !== undefined)
152
164
  push(k, `account.${k}`);
153
165
  }
154
- // A4 — an agent outside the built-in roster.
155
- if (Array.isArray(o.agents)) {
156
- for (const a of o.agents) {
157
- const name = typeof a === "string" ? a : a?.name;
158
- if (typeof name === "string" && !KNOWN_BUILTIN_AGENTS.has(name))
166
+ // A4 — an agent outside the built-in roster, EXCEPT one the scenario itself mounted.
167
+ //
168
+ // A plugin contributes its agents to the roster, and at `hostloop` — the tier a fleet actually
169
+ // records at — that roster is the fixture, not the recording machine's inventory. This is the same
170
+ // carve-out A1 makes for an `mcp.config`-attached server, and without it every plugin-with-agents
171
+ // consumer hits a wall of false positives whose only documented remedy is inventing a regex.
172
+ //
173
+ // The provenance is in the payload already: `plugins[]` sits beside `agents[]` in the same init
174
+ // object, and a plugin's agents are namespaced `<plugin>:<agent>`. So the subtraction is derivable
175
+ // at scan time from an ALREADY-RECORDED cassette — no new cassette field, and no re-record.
176
+ //
177
+ // Deliberate residual: a host-leaked plugin's agents are namespaced too, so this trades a narrow
178
+ // false-negative for the false-positive. That is the right trade — this check exists to catch a
179
+ // FOREIGN machine's inventory, and an agent the scenario mounted is by construction not that.
180
+ //
181
+ // A5 — a SKILL outside the agent's built-ins, same two exemptions. This axis matters because at
182
+ // `protocol` with local OAuth the harness keeps the operator's REAL CLAUDE_CONFIG_DIR (a fresh one
183
+ // breaks OAuth — see src/runtime/protocol.ts), so the personal skills installed there are
184
+ // discoverable and would be frozen into a committed fixture. A skill name is inventory in exactly
185
+ // the way an MCP server name is: it says what the operator has installed.
186
+ if (Array.isArray(o.agents) || Array.isArray(o.skills)) {
187
+ // Plugins declared by THIS payload, plus any harvested from elsewhere in the stream.
188
+ const local = new Set(declared);
189
+ if (Array.isArray(o.plugins))
190
+ for (const p of o.plugins) {
191
+ const n = typeof p === "string" ? p : p?.name;
192
+ if (typeof n === "string")
193
+ local.add(n);
194
+ }
195
+ /** A `<plugin>:<name>` whose plugin the recording declares is the fixture, not the host's. */
196
+ const fromDeclaredPlugin = (name) => {
197
+ const sep = name.indexOf(":");
198
+ return sep > 0 && local.has(name.slice(0, sep));
199
+ };
200
+ if (Array.isArray(o.agents))
201
+ for (const a of o.agents) {
202
+ const name = typeof a === "string" ? a : a?.name;
203
+ if (typeof name !== "string" || KNOWN_BUILTIN_AGENTS.has(name))
204
+ continue;
205
+ if (fromDeclaredPlugin(name))
206
+ continue;
159
207
  push(name, "agents[]");
160
- }
208
+ }
209
+ if (Array.isArray(o.skills))
210
+ for (const s of o.skills) {
211
+ const name = typeof s === "string" ? s : s?.name;
212
+ if (typeof name !== "string" || KNOWN_BUILTIN_SKILLS.has(name))
213
+ continue;
214
+ if (fromDeclaredPlugin(name))
215
+ continue;
216
+ push(name, "skills[]");
217
+ }
161
218
  }
162
219
  for (const v of Object.values(o))
163
220
  visit(v);
package/docs/cassette.md CHANGED
@@ -541,11 +541,32 @@ scenario's `assert:` block; on the live path it would fail as an empty assertion
541
541
 
542
542
  ### Mutation coverage — `replay --mutate`
543
543
 
544
- `replay --mutate` perturbs each recorded, inlined JSON artifact value in turn, re-runs the scenario's
544
+ `replay --mutate` perturbs recorded, inlined JSON artifact values one at a time, re-runs the scenario's
545
545
  assertions against the perturbed tree, and reports which perturbations nothing catches — those are the
546
546
  fields your `assert:` block leaves unguarded. Each perturbation is applied, evaluated, and restored
547
547
  before the next one runs, so the materialized tree ends the pass unchanged.
548
548
 
549
+ > **It samples. Read the ratio accordingly.** The plan is capped at **10 values per file and 50 in
550
+ > total** — the per-file cap is applied first, so a corpus of N JSON artifacts yields at most `10 × N`
551
+ > perturbations no matter how much total budget is left. A line reading `50/50 … CAUGHT BY NOTHING` means
552
+ > 50 of the *sampled* values, not 50 of your fields, and aggregating such lines across many cassettes
553
+ > produces a number that describes the sample rather than the corpus. Whenever a cap binds, the report
554
+ > appends the eligible total and names the cap that bound — `(sampled 30 of 120 eligible value(s);
555
+ > per-file cap 10 reached on 3 file(s))` — and omits that note entirely when nothing was truncated, so
556
+ > its absence means the counts are the whole truth. `--output-format json` carries the same facts under
557
+ > `mutation` (`sampled` / `eligible` / `truncatedBy` / `caps` / `uncaught`), which is the right surface to
558
+ > aggregate over.
559
+
560
+ **Scope it, and raise the cap that binds.** `--mutate-include <glob>` / `--mutate-exclude <glob>` (both
561
+ repeatable, exclude applied last) restrict which artifact paths are perturbed — `*` stays inside a path
562
+ segment, `**` crosses them, so `--mutate-exclude 'handoff/**'` drops per-run internals nobody should
563
+ assert on and spends the sample on deliverables instead. Filtering happens before planning, so an
564
+ out-of-scope artifact is absent from `eligible` too: the report describes the scope you asked for rather
565
+ than counting what you deliberately excluded as missed. `--mutate-max-per-file <n>` / `--mutate-max-total
566
+ <n>` raise the caps; reach for the per-file one first, since it is applied first and with a handful of
567
+ artifacts the total is never the binding constraint. Cost is linear — one full assertion re-run per
568
+ perturbation.
569
+
549
570
  This is coverage reporting, not verdict reporting: an uncaught perturbation is a gap in the scenario's
550
571
  assertions, not a failure of the run, so `--mutate` never changes replay's verdict or exit code — a green
551
572
  replay stays green regardless of what it finds.
@@ -800,9 +821,25 @@ counts) — committed PII surface. Two layers, distinct from secret-scrub (which
800
821
  token is ever written and `grep mcp__` over the cassette reads clean. The inventory lives in **name
801
822
  fields**. So this check reads specific name fields of the decoded events and flags: an `mcp_servers[].name`
802
823
  outside the harness's own servers, a `mcp__<server>__…` tool naming a foreign server, `account.email` /
803
- `.organization` / `.subscriptionType`, and an `agents[]` entry outside the built-in roster. Suppress with
824
+ `.organization` / `.subscriptionType`, an `agents[]` entry outside the built-in roster, and a `skills[]`
825
+ entry outside the agent's own built-ins. The skills axis is not theoretical: at `protocol` with local
826
+ OAuth the harness keeps the operator's **real `CLAUDE_CONFIG_DIR`** (a fresh one breaks OAuth), so the
827
+ personal skills installed there are discoverable, and a skill name says what you have installed exactly
828
+ as an MCP server name does. Suppress with
804
829
  `--allow-host-inventory <regex>`; if the flagged name is a genuine Cowork server, add it to
805
830
  `KNOWN_COWORK_SERVERS` instead of allowing it.
831
+ **A plugin the scenario mounted is not host inventory.** Its agents join the roster — at `hostloop`,
832
+ that roster *is* the fixture — so an agent namespaced `<plugin>:<agent>` whose plugin the same recording
833
+ declares in `plugins[]` is exempt, exactly as an `mcp.config`-attached server is. The provenance comes
834
+ from the cassette itself, so this applies to recordings you already have; no re-record, and no allow
835
+ regex to invent. A foreign agent, or one namespaced to a plugin the run never mounted, still flags. The
836
+ same exemption covers `skills[]`.
837
+ **`plugins[].name` is deliberately NOT an axis.** That array is the harness's own declaration channel —
838
+ every entry arrives from a `--plugin-dir` the harness passed, and the settings it writes carry an
839
+ explicit `enabledPlugins` allowlist. Across every committed fixture, including `protocol` and `hostloop`
840
+ recordings made on a machine with many host plugins installed, it holds only the scenario's own. A field
841
+ that carries nothing but declarations would produce false positives and catch nothing — the failure this
842
+ class has already had once.
806
843
  **`record` carries its own preflight for the same risk.** Recording at a host-inheriting tier
807
844
  (`protocol`, `hostloop`, or `cowork` resolving to hostloop) into a repo-visible cassette path is
808
845
  refused by default — that recording would freeze this machine's MCP servers, agents, and account
@@ -874,6 +911,13 @@ leak/drift":
874
911
  |---|---|---|
875
912
  | `0` | clean | none |
876
913
  | `1` | **verification RAN and found a real problem** | `findings[]` (a PII/privacy match), `staleness[]` (a genuine, non-`unverifiable-*` drift finding), and/or `scenarioDrift[]` |
914
+
915
+ > **Triage first, then read the rows.** Text output opens with a per-class count —
916
+ > `findings by class: host-inventory 240` — before the per-file listing. A sweep that surfaces hundreds
917
+ > of findings of one class reads as hundreds of separate problems until you know that; the header says
918
+ > what kind and how many in one line. It is additive: every per-file row still prints, because which
919
+ > file carries which finding is the answer a per-file audit exists to give. JSON consumers already have
920
+ > `findings[].cls` and need no rollup.
877
921
  | `2` | usage error (e.g. `--skip-privacy`+`--skip-staleness` together, zero cassettes under a dir) | n/a |
878
922
  | `3` | **verification could NOT complete** | `unverifiable[]` (an `unverifiable-*`-class staleness finding), `version[]` (the cassette is from a newer harness than this one understands), and/or `error` (a malformed/unreadable cassette, or a per-file crash) |
879
923
 
package/docs/debugging.md CHANGED
@@ -19,7 +19,7 @@ Docker, no re-record.
19
19
  | Situation | Symptom | Reach for (in order) |
20
20
  |---|---|---|
21
21
  | **The skill misbehaved** | wrong output, an unexpected gate, a denied tool, an opaque crash | `inspect` — what did it produce? · `trace <run-dir> --view <view>` — what did it actually do (tools, gates, sub-agent tree)? · `verify-run` — re-assert cheaply when only an assertion is wrong · `diff <old-run> <new-run>` — what changed since it worked · `chat` — reproduce it by hand |
22
- | **A green you don't trust** | an assert that may have tested nothing, a stale cassette, an auto-answered or decided gate | `replay --explain` — the evidence trail behind each *passing* assert · `replay --mutate` — perturbs each recorded JSON artifact value and reports which perturbations NOTHING caught (reporting only; never moves the verdict/exit code) · `lint` — assertions on the wrong CI lane / mixed-class keys · `verify-cassettes` — privacy + staleness over committed cassettes · the Gotchas landmine catalog — how a check passes vacuously · `run --repeat N` / `skill --repeat N` — did it pass, or pass once? · `stats` — flaky or expensive over time |
22
+ | **A green you don't trust** | an assert that may have tested nothing, a stale cassette, an auto-answered or decided gate | `replay --explain` — the evidence trail behind each *passing* assert · `replay --mutate` — perturbs a CAPPED SAMPLE of recorded JSON values (10/file, 50 total) and reports which perturbations NOTHING caught; the report names the sample size, so read it as a sample not a total (reporting only; never moves the verdict/exit code) · `lint` — assertions on the wrong CI lane / mixed-class keys · `verify-cassettes` — privacy + staleness over committed cassettes · the Gotchas landmine catalog — how a check passes vacuously · `run --repeat N` / `skill --repeat N` — did it pass, or pass once? · `stats` — flaky or expensive over time |
23
23
 
24
24
  A failed run also records `errorSource` (where the failure originated) and `stderrLogPath` (the captured
25
25
  agent stderr) — read those before re-running; a re-record rarely tells you more than the captured stderr
@@ -16,7 +16,7 @@ DOES exercise a real gate exchange, see `example-multiselect-gate.cassette.json`
16
16
 
17
17
  Run it with:
18
18
 
19
- > Assumes the `cowork-harness` CLI is available — from a source checkout run `npm ci && npm run build && npm link` first, or `npm i -g "cowork-harness@>=1.18.0"`. (`replay` itself needs nothing else — no token, no Docker.)
19
+ > Assumes the `cowork-harness` CLI is available — from a source checkout run `npm ci && npm run build && npm link` first, or `npm i -g "cowork-harness@>=1.19.0"`. (`replay` itself needs nothing else — no token, no Docker.)
20
20
 
21
21
  ```sh
22
22
  cowork-harness replay examples/replays/example-pdf-skill.cassette.json
package/llms.txt CHANGED
@@ -30,7 +30,7 @@ It is a fidelity fixture, not the Desktop runtime. The CLI binary is `cowork-har
30
30
  - [docs/discovery.md](docs/discovery.md): how marketplaces / plugins / skills / MCP are discovered
31
31
  - [docs/plugin-root.md](docs/plugin-root.md): `${CLAUDE_PLUGIN_ROOT}` resolves differently host-side vs in-VM bash — the single most common Cowork authoring footgun
32
32
  - [docs/cassette.md](docs/cassette.md): record/replay cassettes and the token-free PR gate
33
- - `replay --mutate` perturbs each recorded JSON artifact value, re-runs the assertions, and reports which perturbations NOTHING caught — i.e. which fields your assertions leave unguarded. Reporting only: it never moves the verdict or the exit code, so it is safe to add to a gate.
33
+ - `replay --mutate` perturbs recorded JSON artifact values, re-runs the assertions, and reports which perturbations NOTHING caught — i.e. which fields your assertions leave unguarded. **It samples: at most 10 values per file and 50 in total**, so `N/N caught by nothing` is N of the SAMPLE, never of your whole corpus. When a cap binds, the report says which one and how many values were eligible; `--output-format json` carries the same under `mutation`. Reporting only: it never moves the verdict or the exit code, so it is safe to add to a gate.
34
34
  - Host-inventory consent — recording a host-inheriting tier (`protocol`, `hostloop`, or `cowork` resolving to hostloop) into a repo-visible path freezes the recording machine's own MCP servers / agents / account into the fixture, so `record` refuses without `--allow-host-inventory-fixture`; `verify-cassettes --allow-host-inventory <regex>` suppresses a single such finding on an already-committed cassette. The two are distinct flags on distinct commands and do not interchange.
35
35
  - [docs/decider-dir.md](docs/decider-dir.md): in-band gate answering by a driving agent
36
36
  - [docs/maintenance.md](docs/maintenance.md): keeping parity across Claude Desktop releases (`sync`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-harness",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Scriptable, CI-friendly harness for Claude Cowork's runtime contract for testing skills across scenarios — same agent, mounts, egress allowlist, permission protocol, and sandbox limitations.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1406,6 +1406,46 @@
1406
1406
  }
1407
1407
  }
1408
1408
  }
1409
+ },
1410
+ "mutation": {
1411
+ "type": "object",
1412
+ "description": "Replay lane only, and only under --mutate: which recorded values were perturbed and which perturbations no assertion caught. `sampled` is post-cap; `eligible` is the pre-cap total, so a consumer can distinguish 'N unguarded fields' from 'N unguarded OUT OF eligible'. `truncatedBy` names the cap that bound \u2014 raising the other one would not change the sample.",
1413
+ "properties": {
1414
+ "sampled": {
1415
+ "type": "number"
1416
+ },
1417
+ "eligible": {
1418
+ "type": "number"
1419
+ },
1420
+ "truncatedBy": {
1421
+ "type": [
1422
+ "string",
1423
+ "null"
1424
+ ],
1425
+ "enum": [
1426
+ "per-file",
1427
+ "total",
1428
+ null
1429
+ ]
1430
+ },
1431
+ "caps": {
1432
+ "type": "object",
1433
+ "properties": {
1434
+ "perFile": {
1435
+ "type": "number"
1436
+ },
1437
+ "total": {
1438
+ "type": "number"
1439
+ }
1440
+ }
1441
+ },
1442
+ "uncaught": {
1443
+ "type": "array",
1444
+ "items": {
1445
+ "type": "string"
1446
+ }
1447
+ }
1448
+ }
1409
1449
  }
1410
1450
  }
1411
1451
  }