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.
- package/.claude/skills/cowork-harness/SKILL.md +5 -5
- package/.claude/skills/cowork-harness/references/ci-recipe.md +5 -5
- package/.claude/skills/cowork-harness/references/critique.md +1 -1
- package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
- package/.claude/skills/cowork-harness/references/scenario-schema.md +1 -1
- package/.claude/skills/cowork-harness/references/task-recipes.md +1 -1
- package/CHANGELOG.md +60 -0
- package/README.md +5 -5
- package/dist/run/cassette.js +144 -10
- package/dist/run/chat-result.js +1 -0
- package/dist/run/execute.js +2 -0
- package/dist/run/mutate.js +36 -0
- package/dist/scan.js +64 -7
- package/docs/cassette.md +46 -2
- package/docs/debugging.md +1 -1
- package/examples/replays/README.md +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/schema/run-result.json +40 -0
|
@@ -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.
|
|
7
|
-
tracks-harness: cowork-harness 1.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
package/dist/run/cassette.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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,
|
package/dist/run/chat-result.js
CHANGED
|
@@ -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,...}
|
package/dist/run/execute.js
CHANGED
|
@@ -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
|
package/dist/run/mutate.js
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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`,
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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",
|
package/schema/run-result.json
CHANGED
|
@@ -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
|
}
|