cowork-harness 0.28.0 → 0.30.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.
Files changed (98) hide show
  1. package/.claude/skills/cowork-harness/SKILL.md +41 -17
  2. package/.claude/skills/cowork-harness/references/ci-recipe.md +4 -4
  3. package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
  4. package/.claude/skills/cowork-harness/references/scenario-schema.md +43 -22
  5. package/.claude/skills/cowork-harness/references/task-recipes.md +3 -3
  6. package/.claude/skills/cowork-harness/scripts/assertion-keys.json +6 -1
  7. package/.claude/skills/cowork-harness/scripts/scenario.py +51 -2
  8. package/AGENTS.md +5 -4
  9. package/CHANGELOG.md +307 -0
  10. package/CONTRIBUTING.md +1 -1
  11. package/DESIGN.md +2 -2
  12. package/README.md +36 -36
  13. package/RELEASING.md +27 -0
  14. package/SPEC.md +27 -15
  15. package/baselines/desktop-1.18286.2.json +1 -0
  16. package/baselines/desktop-1.19367.0.json +1 -0
  17. package/baselines/desktop-1.20186.0.json +374 -0
  18. package/baselines/desktop-1.20186.1.json +380 -0
  19. package/baselines/prompts/cowork-system-prompt-fingerprints.json +20 -0
  20. package/baselines/prompts/desktop-1.18286.2/subagent-append-hl.md +16 -0
  21. package/dist/agent/session.js +182 -27
  22. package/dist/agent/timeline.js +18 -9
  23. package/dist/assert.js +185 -30
  24. package/dist/baseline.js +1 -1
  25. package/dist/cli.js +74 -13
  26. package/dist/decide/decider.js +1 -1
  27. package/dist/decide/llm-transport.js +26 -3
  28. package/dist/decide/semantic-judge.js +15 -5
  29. package/dist/egress/sidecar.js +6 -7
  30. package/dist/hostloop/canusetool-gate.js +63 -0
  31. package/dist/hostloop/pretooluse-path-hook.js +62 -22
  32. package/dist/prompt.js +49 -10
  33. package/dist/run/artifacts.js +186 -47
  34. package/dist/run/cassette.js +340 -118
  35. package/dist/run/chat-result.js +14 -2
  36. package/dist/run/chat.js +51 -12
  37. package/dist/run/execute.js +710 -635
  38. package/dist/run/matrix.js +1 -1
  39. package/dist/run/pre-run-manifest.js +72 -13
  40. package/dist/run/renderer.js +10 -2
  41. package/dist/run/run-index.js +10 -1
  42. package/dist/run/run-status.js +49 -6
  43. package/dist/run/run.js +340 -52
  44. package/dist/run/scenario-tool.js +4 -8
  45. package/dist/run/trace-view.js +36 -8
  46. package/dist/run/verdict.js +26 -3
  47. package/dist/runtime/argv.js +10 -3
  48. package/dist/runtime/container.js +7 -2
  49. package/dist/runtime/hostloop-prompt.js +5 -5
  50. package/dist/runtime/hostloop.js +51 -13
  51. package/dist/runtime/microvm.js +3 -1
  52. package/dist/runtime/protocol.js +17 -1
  53. package/dist/runtime/resource-sampler.js +78 -21
  54. package/dist/session.js +42 -0
  55. package/dist/sync/cowork-sync.js +510 -24
  56. package/dist/types.js +64 -12
  57. package/dist/usage-limit.js +40 -0
  58. package/docker/Dockerfile.agent +1 -1
  59. package/docs/README.md +2 -1
  60. package/docs/boundary.md +26 -2
  61. package/docs/cassette.md +71 -27
  62. package/docs/chat.md +8 -0
  63. package/docs/cowork-spawn-contract-1.12603.1.md +3 -2
  64. package/docs/fidelity-gaps.md +85 -0
  65. package/docs/gotchas.md +0 -3
  66. package/docs/invariants.md +7 -1
  67. package/docs/maintenance.md +30 -0
  68. package/docs/plugin-root.md +16 -7
  69. package/docs/protocol.md +24 -7
  70. package/docs/run-status.md +8 -2
  71. package/docs/scenario.md +46 -19
  72. package/docs/session.md +37 -0
  73. package/docs/stats.md +7 -0
  74. package/docs/subagents.md +154 -0
  75. package/examples/replays/README.md +1 -1
  76. package/examples/replays/example-multiselect-gate.cassette.json +1 -1
  77. package/examples/replays/example-pdf-skill.cassette.json +1 -1
  78. package/examples/replays/hostloop-computer-links.cassette.json +1 -1
  79. package/package.json +3 -1
  80. package/schema/cassette.v10.json +4 -0
  81. package/schema/protocol.v1.json +1 -0
  82. package/schema/run-result.json +94 -9
  83. package/schema/scenario.schema.json +95 -13
  84. package/schema/session.schema.json +20 -0
  85. package/scripts/check-versions.ts +8 -15
  86. package/dist/canary/grants.js +0 -37
  87. package/dist/critique/evaluator.js +0 -199
  88. package/dist/critique/evidence.js +0 -60
  89. package/dist/critique/package-evidence.js +0 -141
  90. package/docs/assets/banner.png +0 -0
  91. package/fixtures/subagent-grants.json +0 -5
  92. package/schema/cassette.v2.json +0 -19
  93. package/schema/cassette.v3.json +0 -30
  94. package/schema/cassette.v5.json +0 -107
  95. package/schema/cassette.v6.json +0 -131
  96. package/schema/cassette.v7.json +0 -156
  97. package/schema/cassette.v8.json +0 -166
  98. package/scripts/boot-rootfs-vz.ts +0 -99
@@ -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. 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: 0.28.0
7
- tracks-harness: cowork-harness 0.28.0 (baseline desktop-1.19367.0)
6
+ version: 0.30.0
7
+ tracks-harness: cowork-harness 0.30.0 (baseline desktop-1.20186.1)
8
8
  ---
9
9
 
10
10
  # cowork-harness
@@ -22,8 +22,8 @@ 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 0.28.0` (baseline
26
- > `desktop-1.19367.0`). If your checkout is newer, prefer the live `--help` and — in a repo checkout —
25
+ > **Version note:** the facts and `file:line` pointers here track `cowork-harness 0.30.0` (baseline
26
+ > `desktop-1.20186.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
 
29
29
  ## Preflight — make sure the harness can actually run
@@ -38,7 +38,7 @@ cowork-harness skill ./my-skill "do X" # run the skill once against the sta
38
38
  Before the first command, confirm the CLI is reachable and **fail loud** (never fake a pass) when a tier's dependencies are missing:
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
- - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 0.28.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=0.28.0" <cmd>` (Node ≥ 20), or install once with `npm i -g "cowork-harness@>=0.28.0"`. **Pin `@>=0.28.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. (≥ 0.28.0 is what gates the commands/assertions this skill teaches: `assertions --list`, `scaffold <run-id>`, `trace --view dispatches`, `artifact_json` incl. the `in:` operator (passes when the resolved value deep-equals one of the listed members — value ∈ your list, not the reverse), `verify-cassettes`, batch `record <dir>`/`--rerecord-stale`, `record --concurrency <N>`, record-time redaction, multiSelect/`answer:`, `verify-run` answer-coverage, `record --max-artifact-bytes`, live record-time deciders, `verify-cassettes --allow-domain`/`--allow-email`/`--allow-path`/`--allow-patterns-file` (`path` — local absolute filesystem paths — is the scanner's 4th class, new in 0.21.0; `--allow-patterns-file <path>` is a FILE of patterns, one regex per line — not a path to allow, unlike `--allow <regex>`), scenario `skills:` staleness scoping with `COWORK_HARNESS_AGENT_SCOPE=skill`, `chat --plugin`, `/help` in the REPL, `hostloop`'s native host/VM process split with its `allow_host_writes:` consent field, `computer_links_resolve` (new in 0.22.0), and `semantic_matches` (an LLM judge grades a fixed `rubric` of claims against the run's answer — **live-only**, so it is evidence-unavailable / skipped-loud on replay, never a vacuous pass; new in 0.27.0), and **glob-matched** `tool_called`/`tool_not_called`/`subagent_tool_used`/`subagent_tool_absent` (a pattern like `mcp__workspace__*` matches any tool in the family; exact names match exactly; new in 0.28.0).)
41
+ - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 0.30.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=0.30.0" <cmd>` (Node ≥ 20), or install once with `npm i -g "cowork-harness@>=0.30.0"`. **Pin `@>=0.30.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. (≥ 0.30.0 is what gates the commands/assertions this skill teaches: `assertions --list`, `scaffold <run-id>`, `trace --view dispatches`, `artifact_json` incl. the `in:` operator (passes when the resolved value deep-equals one of the listed members — value ∈ your list, not the reverse), `verify-cassettes`, batch `record <dir>`/`--rerecord-stale`, `record --concurrency <N>`, record-time redaction, multiSelect/`answer:`, `verify-run` answer-coverage, `record --max-artifact-bytes`, live record-time deciders, `verify-cassettes --allow-domain`/`--allow-email`/`--allow-path`/`--allow-patterns-file` (`path` — local absolute filesystem paths — is the scanner's 4th class, new in 0.21.0; `--allow-patterns-file <path>` is a FILE of patterns, one regex per line — not a path to allow, unlike `--allow <regex>`), scenario `skills:` staleness scoping with `COWORK_HARNESS_AGENT_SCOPE=skill`, `chat --plugin`, `/help` in the REPL, `hostloop`'s native host/VM process split with its `allow_host_writes:` consent field, `computer_links_resolve` (new in 0.22.0), `semantic_matches` (an LLM judge grades a fixed `rubric` of claims against the run's answer — **live-only**, so it is evidence-unavailable / skipped-loud on replay, never a vacuous pass; new in 0.27.0), **glob-matched** `tool_called`/`tool_not_called`/`subagent_tool_used`/`subagent_tool_absent` (a pattern like `mcp__workspace__*` matches any tool in the family; exact names match exactly; new in 0.28.0), the five path-gate assertion keys `no_vm_path_file_op`/`vm_path_denied`/`path_denied`/`no_path_denied`/`subagent_file_write`, the session-level `agent_env` knob, and resolved sub-agent identity on dispatch records (`resolvedAgentType`/`resolvedModel`; new in 0.30.0).)
42
42
  - **Agent binary (sandboxed live tiers — `container`/`microvm`/`hostloop`/`cowork`).** The staged Claude Code agent is **bind-mounted** from a local Claude Desktop install, or point `COWORK_AGENT_BINARY` at a `claude-code-vm/<ver>/claude` ELF. Nothing is bundled. `protocol` (L0) and `replay` need no staged agent; for the sandboxed tiers, no agent → no run; report that, don't skip silently.
43
43
  - **Docker / Lima.** Only `--fidelity protocol` (L0) runs without them. `container` / `microvm` / `hostloop` / `cowork` need Docker (Lima for L2). If they're absent, drop to `--fidelity protocol` and **say so** — a green that never exercised the sandbox is not a sandbox pass.
44
44
  - **Auth.** `CLAUDE_CODE_OAUTH_TOKEN` (preferred) or `ANTHROPIC_API_KEY`, via env or `.env`. Minting an OAuth token needs the **`claude` CLI** (`npm i -g @anthropic-ai/claude-code`, then `claude setup-token`).
@@ -63,7 +63,7 @@ reproducible regression (Part II), and **debug** a run that misbehaved or greene
63
63
  the path, `trace <run-id>` finds it). **Localize the failure post-hoc** from that evidence:
64
64
  `cowork-harness trace <run-dir>`'s views + the emitted `result.json` to see what the run actually did,
65
65
  then `verify-run` to re-check a suspect assertion — all token-free, no Docker, no re-record. This is
66
- the loop 0.28.0's observability is built for; the *Triage* and *Inspecting a run's observability
66
+ the loop 0.30.0's observability is built for; the *Triage* and *Inspecting a run's observability
67
67
  output* sections in **Part III — Debug** are the detail (the fuller human-facing map lives in
68
68
  `docs/debugging.md` — repo-only, not shipped with the installed skill).
69
69
  - **Multi-turn / interactive reproduction** → `cowork-harness chat` (interactive; gates answered at the
@@ -206,7 +206,7 @@ them by what you're trying to prove:
206
206
  | a skill actually **ran** (or must NOT) | `skill_triggered: <regex>`, `no_skill_triggered: <regex>` |
207
207
  | a tool ran **inside** a skill's scope | `skill_tool_used: {skill, tool}` |
208
208
  | a sub-agent did the work | `subagent_output_contains: {contains}`, `subagent_dispatched: <regex>`, `dispatch_count_max: <N>` |
209
- | a pre-existing input wasn't mutated | `input_unmodified: [<glob>]` (live/verify-run; not microvm) |
209
+ | a pre-existing input wasn't mutated (incl. `uploads/**`) | `input_unmodified: <glob>` or `[<glob>, …]` (live/verify-run; not microvm) |
210
210
  | a resource ceiling held | `max_peak_rss_bytes: <N>` (**live-only**) |
211
211
  | a hook blocked / didn't block a tool | `hook_blocked: <regex>`, `no_hook_blocked: true` (replay needs a `controlOut` cassette) |
212
212
  | every MCP round-trip succeeded | `no_mcp_error: true` (**live-only**) |
@@ -292,7 +292,9 @@ scenario's gates or their labels: `--keep` ONE run, then `cowork-harness trace <
292
292
  your `answers:` against that kept run, then record once. **But the kept run is a snapshot:** if you change the
293
293
  skill's gate phrasing afterward, re-`--keep` — verify-run's answer-coverage *refuses* (exit 2, "predates the
294
294
  current skill") rather than vouch against stale labels, but the trace/inspect path can't warn you, so re-keep
295
- deliberately. (A token-free probe of "which gates fire" isn't possible — gates are model-decided per run.)
295
+ deliberately. (Same fail-closed family: corrupt gate evidence — unparseable `events.jsonl` lines, or fewer
296
+ gates than `trace.json` recorded questions — and a structurally invalid `result.json` also refuse rather
297
+ than certify.) (A token-free probe of "which gates fire" isn't possible — gates are model-decided per run.)
296
298
 
297
299
  Run artifacts are written to `~/.cowork-harness/runs/…` by default — **outside any working tree**, so a run
298
300
  launched from a repo root never drops sensitive skill inputs/outputs into it. Pass `--run-dir <path>` (or set
@@ -420,16 +422,26 @@ decide which assertions from *Assertions: two orthogonal axes* are worth adding)
420
422
  - **`cowork-harness stats [--metric <m>]`** — aggregate across the run index: `cost`, `duration`,
421
423
  `tokens`, `cache-tokens`, `model-cost`, `turns`, `pass-rate`.
422
424
  - **`result.json` carries the raw fields** the assertions read: `toolDurations`, `models`, `toolErrors`,
423
- `redundantToolCalls`, `modelUsage`, `thinking`, `skillActivity`, `subagents[]` (prompt/model/output/
424
- `attributedSkillId`), `context` (tools/mcpServers/availableSkills), `tasks`, `workspaceFiles`,
425
- `presentedFiles`, `hookEvents`, `mcpErrors`, `contextEvents`, `resources`. (Full per-field semantics:
426
- the README's "Observability fields" section — repo-only; `schema/run-result.json` is the machine
427
- source.)
425
+ `redundantToolCalls`, `modelUsage`, `thinking`, `skillActivity`, `subagents[]` (prompt/`dispatchModel`/
426
+ `resolvedModel`/output/`attributedSkillId`, `outputTruncated`), `context` (tools/mcpServers/availableSkills), `tasks`,
427
+ `workspaceFiles`, `presentedFiles`, `hookEvents`, `mcpErrors`, `contextEvents`, `resources`
428
+ (`probeFailures` distinguishes a failed sample from a tier that was never sampleable). Provenance/
429
+ evidence-health fields: `command` (`run`/`skill`/`record`/`chat`/`replay` — finer than `mode`),
430
+ `gateProvenance` (per-gate `scripted`/`decided(llm|external)`/`first-option`/`prompt` with a
431
+ `bySource` histogram), `evidenceErrors` (dropped/malformed telemetry lines per stream, incl.
432
+ `egressParse`), `fingerprint.frozen` (replay only — marks the shown staleness fingerprint as the
433
+ cassette's record-time value, not a fresh recompute), and `assertTextTruncated` (companion to
434
+ `outputTruncated` on a matched tool result). (Full per-field semantics: the README's "Observability
435
+ fields" section — repo-only; `schema/run-result.json` is the machine source.)
428
436
  - **Opaque failure?** A failed run also records **`errorSource`** (where the failure originated) and
429
437
  **`stderrLogPath`** (the captured agent stderr) — read those and `trace <run-dir>` *before* re-running;
430
- a re-record rarely tells you more than the captured stderr already does.
438
+ a re-record rarely tells you more than the captured stderr already does. Also check
439
+ **`resultErrorKind`** (`"transport" | "agent" | "usage_limit"`) before spending another paid run: a
440
+ `"usage_limit"` failure is a quota exhaustion, not a skill bug — retry after the limit resets rather
441
+ than debugging; `"transport"`/`"agent"` means something actually broke, worth localizing before
442
+ re-running.
431
443
  - **Attributing cost to sub-agent work.** `subagents[]` gives the dispatch tree — each sub-agent's
432
- `model`, `toolsUsed`, `prompt`/`output`, and `attributedSkillId` — but **not** its own token/cost;
444
+ `dispatchModel`/`resolvedModel`, `toolsUsed`, `prompt`/`output`, and `attributedSkillId` — but **not** its own token/cost;
433
445
  aggregate cost is per-**model** in `modelUsage` (and `trace --view usage`), not per-sub-agent. So a
434
446
  cost spike from fan-out reads as `trace --view dispatches` (how many, which agent) against that model's
435
447
  per-model usage — the harness doesn't line-item each sub-agent's tokens.
@@ -504,8 +516,20 @@ repeats the assertion/replay-relevant ones alongside the schema (a scoped subset
504
516
  to prove an attempt; it proves the tool actually ran.
505
517
 
506
518
  5. **`subagent_declared_but_unused` fires on declared-but-didn't-use-THAT-tool**, even if the
507
- sub-agent used other tools. And `subagent_dispatched` matches by dispatch **description** too —
508
- skills often dispatch with no `subagent_type` (`agentType:"unknown"`), so match the description.
519
+ sub-agent used other tools. `subagent_dispatched` / `subagent_output_contains` match on dispatch
520
+ type (`dispatchAgentType`), the binary-*resolved* type (`resolvedAgentType`), *or* the dispatch
521
+ **description** — so a type-less dispatch that resolved to e.g. `general-purpose` is still
522
+ selectable, by either the resolved type or the description. A `Task` dispatch that carries NO
523
+ `subagent_type` at all falls back to the built-in `general-purpose` agent with a **wildcard tool
524
+ surface** (`tools:["*"]`, including workspace bash) — faithful production behavior, and it fires
525
+ routinely. The harness warns loudly on this fallback and records `subagents[].dispatchTypeOmitted`;
526
+ an *explicit* `subagent_type: "general-purpose"` is a deliberate author choice and does not warn.
527
+ Implication: `subagent_tool_absent` on a type-less dispatch is weaker evidence (wildcard surface) —
528
+ pin `subagent_type` explicitly when you need a tight tool-absence guarantee.
529
+
530
+ **Cross-tier "no shell" caveat.** On `hostloop`, native `Bash` calls route through the
531
+ `mcp__workspace__bash` alias, so a "sub-agent used no shell" check must glob **both** `Bash` and
532
+ `mcp__workspace__*` to hold across every tier.
509
533
 
510
534
  6. **`dispatch_count_max` is an author-chosen budget, not a production cap.** It's a post-hoc count
511
535
  assertion: passing means "happened to dispatch ≤N this run," nothing more. Cowork imposes **no**
@@ -1,6 +1,6 @@
1
1
  # CI recipe — replay vs live lanes
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 0.28.0` (baseline `desktop-1.19367.0`).
3
+ Self-contained reference. Tracks `cowork-harness 0.30.0` (baseline `desktop-1.20186.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):
@@ -54,7 +54,7 @@ sha256-*checked* but not hard-blocking on mismatch — it's advisory for an inte
54
54
  GitHub-hosted runners, no token/Docker/agent:
55
55
 
56
56
  ```yaml
57
- - run: npm i -g "cowork-harness@>=0.28.0"
57
+ - run: npm i -g "cowork-harness@>=0.30.0"
58
58
  - run: cowork-harness lint scenarios/*.yaml # no silent false-greens
59
59
  - run: cowork-harness verify-cassettes cassettes/ # privacy + staleness
60
60
  - run: cowork-harness replay cassettes/ # token-free content/structure
@@ -188,7 +188,7 @@ jobs:
188
188
  with: { node-version: '20' }
189
189
  - uses: actions/setup-python@v5
190
190
  with: { python-version: '3.x' } # python3 only — PyYAML is bundled with the linter
191
- - run: npm i -g "cowork-harness@>=0.28.0"
191
+ - run: npm i -g "cowork-harness@>=0.30.0"
192
192
  - run: cowork-harness lint scenarios/*.yaml # no-silent-false-green (needs python3; PyYAML bundled)
193
193
  - run: cowork-harness verify-cassettes cassettes/ --output-format json # privacy + staleness gate
194
194
  - run: cowork-harness replay cassettes/ --output-format json # token-free content/structure
@@ -217,7 +217,7 @@ jobs:
217
217
  echo "live=true" >> "$GITHUB_OUTPUT"
218
218
  fi
219
219
  - if: steps.guard.outputs.live == 'true'
220
- run: npm i -g "cowork-harness@>=0.28.0"
220
+ run: npm i -g "cowork-harness@>=0.30.0"
221
221
  - if: steps.guard.outputs.live == 'true'
222
222
  run: cowork-harness run scenarios/ --output-format json
223
223
  env:
@@ -1,6 +1,6 @@
1
1
  # Fidelity tiers & answer paths
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 0.28.0` (baseline `desktop-1.19367.0`).
3
+ Self-contained reference. Tracks `cowork-harness 0.30.0` (baseline `desktop-1.20186.0`).
4
4
 
5
5
  ## Fidelity tiers (`fidelity:` in the scenario)
6
6
 
@@ -1,7 +1,7 @@
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 0.28.0`
4
- (baseline `desktop-1.19367.0`). If your checkout is newer, prefer the live `docs/scenario.md`,
3
+ Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 0.30.0`
4
+ (baseline `desktop-1.20186.0`). If your checkout is newer, prefer the live `docs/scenario.md`,
5
5
  `docs/session.md`, and `SPEC.md`.
6
6
 
7
7
  **Minimal scenario** — `prompt` is the only required field:
@@ -105,6 +105,12 @@ agent_max_turns: 500 # optional turn ceiling -> agent --max-turns;
105
105
  permission_mode: default # default | acceptEdits | plan | bypassPermissions
106
106
  permission_parity: cowork # cowork (unscripted tool calls allowed) | strict (deny unscripted)
107
107
 
108
+ # sub-agent / tool-search env knob (tier-uniform; maps to agent env vars)
109
+ agent_env:
110
+ subagent_model: claude-opus-4-8 # -> CLAUDE_CODE_SUBAGENT_MODEL
111
+ tool_search: auto # auto | off -> ENABLE_TOOL_SEARCH; omit = binary default (ToolSearch ON)
112
+ disable_experimental_betas: false # true -> CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (also disables ToolSearch)
113
+
108
114
  # fenced debug escape hatch (NOT reachable via Cowork's UI)
109
115
  debug:
110
116
  max_thinking_tokens: 50000 # overrides --max-thinking-tokens directly; bypasses extended_thinking's
@@ -245,18 +251,20 @@ same set live from the schema.
245
251
  | `file_exists: <path>` | the path exists under the run's `work/` (anchored at `mnt/`, e.g. `outputs/x.md`). For a user-facing deliverable prefer `user_visible_artifact` — with a connected folder the file lands in `mnt/<folder>` (= `{{workspaceFolder}}`), not `mnt/outputs`, so `file_exists: outputs/x.md` misses it |
246
252
  | `user_visible_artifact: <path>` | exists **and** under a user-visible root (`outputs/` + each connected folder's mount name) — the right primitive for a workspace deliverable when a folder is connected |
247
253
  | `no_delete_in_outputs: true` | no delete op touched `mnt/outputs` — **only `true` is valid**; `false` is rejected (omit to allow deletes) |
248
- | `no_unexpected_files: [<glob>, …]` | every **newly created** file under a user-visible root matches ≥1 glob (workRoot-relative paths; `**` = whole path segment for any depth — use `outputs/handoff/**` for per-run subdirs); `[]` = no new files; **new-files-only** — overwriting a pre-existing file in place is invisible (use content-level producer stamping); live/verify-run without a pre-run manifest ⇒ evidence-unavailable hard-fail (live runs capture the baseline only when this key is asserted; recordings always capture); **microvm cannot capture** (use container/hostloop); replay needs `cassette.preRunPaths` (≥0.24 container/hostloop recordings) — cassettes without it **exclude** the key with a loud warning |
249
- | `input_unmodified: [<glob>, …]` | every **pre-existing** file whose workRoot-relative path matches ≥1 glob keeps an unchanged content hash after the run — the in-place-mutation companion to `no_unexpected_files`'s new-files check (`[]` is rejected by the schema — list at least one glob); a matched file that was deleted counts as a content change (fails); live/verify-run without a pre-run hash manifest ⇒ evidence-unavailable hard-fail; **microvm cannot capture**; replay needs `cassette.preRunHashes` — cassettes without it **exclude** the key with a loud warning; on replay it compares against the manifest's recorded `sha256`, never a re-hash of the materialized tree |
254
+ | `no_unexpected_files: [<glob>, …]` | every **newly created** file under a user-visible root matches ≥1 glob (workRoot-relative paths; `**` = whole path segment for any depth — use `outputs/handoff/**` for per-run subdirs); `[]` = no new files; **new-files-only** — overwriting a pre-existing file in place is invisible (use content-level producer stamping); live/verify-run without a pre-run manifest ⇒ evidence-unavailable hard-fail (live runs capture the baseline only when this key is asserted; recordings always capture); an **incomplete post-run filesystem walk** (an unreadable subtree — a permission/I-O error) also fails evidence-unavailable rather than reporting "no strays" over a partial tree — distinct from the missing-manifest/microvm case, which fails for a different reason; **microvm cannot capture** (use container/hostloop); replay needs `cassette.preRunPaths` (≥0.24 container/hostloop recordings) — cassettes without it **exclude** the key with a loud warning |
255
+ | `input_unmodified: <glob>` or `[<glob>, …]` | a single glob or a list; every **pre-existing** file (incl. uploaded files under `uploads/**`) whose workRoot-relative path matches ≥1 glob keeps an unchanged content hash after the run — the in-place-mutation companion to `no_unexpected_files`'s new-files check (`[]` is rejected by the schema — list at least one glob); a matched file that was deleted counts as a content change (fails); live/verify-run without a pre-run hash manifest ⇒ evidence-unavailable hard-fail; **microvm cannot capture**; replay needs `cassette.preRunHashes` — cassettes without it **exclude** the key with a loud warning; on replay it compares against the manifest's recorded `sha256`, never a re-hash of the materialized tree |
250
256
  | `self_heal_ran: <bool>` | a plugin-root self-heal script was (not) invoked |
251
- | `tool_called: <glob>` | a tool the agent ran matched this **glob** — `*` = any run, `?` = one char, exact when literal, anchored + case-sensitive. Exact name (`Write`) matches only that tool; `mcp__workspace__*` matches any workspace tool. GLOB, not regex (`.` is literal; `.*`/`\|` match nothing → warned) |
252
- | `tool_not_called: <glob>` | NO tool the agent ran matched this glob (`mcp__*` = "no MCP tool ran"). Same glob semantics as `tool_called` |
257
+ | `tool_called: <glob>` | a tool the agent ran matched this **glob** — `*` = any run, `?` = one char, exact when literal, anchored + case-sensitive. Exact name (`Write`) matches only that tool; `mcp__workspace__*` matches any workspace tool. GLOB, not regex (`.` is literal) — an empty glob, or one containing a regex/brace-expansion metacharacter (`.*`, `.+`, `\|`, `()`, `[]`, `+`, `^`, `$`, `{}`, `\d`/`\w`/`\s`/`\b`), is **rejected at load** (a hard schema error, not a runtime warning) — it would match no real tool name and pass a `_not_`/`_absent` assert vacuously. Applies whether the glob comes from an authored scenario or a recorded cassette's frozen assert. The bundled `scenario.py lint` does NOT perform this check — only the harness enforces it, at actual load (`run`/`skill`/`record`) |
258
+ | `tool_not_called: <glob>` | NO tool the agent ran matched this glob (`mcp__*` = "no MCP tool ran"). Same glob semantics as `tool_called`, including the empty/regex-ish rejection |
253
259
  | `tool_result_contains: <str>` | a tool result includes the literal string (content / replay-checkable — substring match) |
254
260
  | `tool_result_not_contains: <str>` | no tool result includes the literal string (content / replay-checkable; fails loud when tool results are absent) |
255
- | `subagent_tool_used: <glob>` | a sub-agent used a tool matching this glob (same `*`/`?`, anchored, case-sensitive semantics as `tool_called`) |
256
- | `subagent_tool_absent: <glob>` | no sub-agent used a tool matching this glob |
257
- | `subagent_dispatched: <regex>` | a sub-agent whose `agentType` **or dispatch description** matches |
261
+ | `subagent_tool_used: <glob>` | a sub-agent used a tool matching this glob (same `*`/`?`, anchored, case-sensitive semantics as `tool_called`, including the empty/regex-ish rejection) |
262
+ | `subagent_tool_absent: <glob>` | no sub-agent used a tool matching this glob (same rejection) |
263
+ | `no_vm_path_file_op: true` | **`fidelity: hostloop` only** — NO gated file tool attempted a `/sessions`(-prefixed) path (`RunResult.fileToolAttempts`) — content-class, replay-checkable without `controlOut`; any other tier FAILS "cannot verify" (`/sessions/...` is valid there). **Only `true` is valid** |
264
+ | `subagent_file_write: {path?, path_suffix?, tool?}` | a sub-agent-origin write attempt whose raw path equals `path` (exact) or ends with `path_suffix` has a paired non-error tool_result — the causal half of a delivery probe; requires one of `path`/`path_suffix`; `tool` defaults to Write/Edit/MultiEdit; content-class; tier-agnostic |
265
+ | `subagent_dispatched: <regex>` | a sub-agent whose `dispatchAgentType`, binary-*resolved* `resolvedAgentType`, **or dispatch description** matches |
258
266
  | `subagent_declared_but_unused: <Tool>` | a sub-agent declared the tool but never used **that** tool (even if it used others) |
259
- | `subagent_output_contains: {match?, contains}` | a dispatched sub-agent's own output contains the substring `contains` — `match` (optional regex over `agentType`/`description`) narrows to specific dispatch(es); omitted, checks whether ANY dispatch's output contains it (existence check, not "all") |
267
+ | `subagent_output_contains: {match?, contains}` | a dispatched sub-agent's own output contains the substring `contains` — `match` (optional regex over `dispatchAgentType`/`resolvedAgentType`/`description`) narrows to specific dispatch(es); omitted, checks whether ANY dispatch's output contains it (existence check, not "all"); a miss against an output that was **truncated at the assert cap** reports evidence-unavailable instead of a proven absence — the substring could lie past the cut |
260
268
  | `dispatch_count_max: <N>` | at most N sub-agents dispatched — an author-chosen budget (Cowork imposes no in-conversation Task-dispatch cap; records only, enforces nothing — see gotcha 12) |
261
269
  | `skill_triggered: <regex>` | a skill matching the regex (invoked id, e.g. `"plugin:skill"`) was invoked via the `Skill` tool — evidence-unavailable (not a normal fail) if the agent's init tools have no `Skill` tool |
262
270
  | `no_skill_triggered: <regex>` | no invoked skill id matched — the negative-control / description-collision catcher; evidence-unavailable (never a vacuous pass) if invocation data is absent or the `Skill` tool is unobservable |
@@ -268,7 +276,7 @@ same set live from the schema.
268
276
  `subagent_tool_used`, `subagent_tool_absent` are **glob** (anchored, case-sensitive, `*`/`?`). `tool_available`,
269
277
  `skill_triggered`/`no_skill_triggered`, `skill_available`, `connector_available`, `skill_tool_used`,
270
278
  `subagent_type` are **regex** (unanchored, case-insensitive). So `tool_called: mcp__workspace__*` (glob) but
271
- `tool_available: mcp__workspace__.*` (regex) — a `.*` in a `tool_called` glob matches nothing and is warned.
279
+ `tool_available: mcp__workspace__.*` (regex) — a `.*` in a `tool_called` glob is a load-time schema error, not silently-matches-nothing.
272
280
  | `skill_tool_used: {skill, tool}` | a tool whose name matches `tool` ran inside a skill-activation window whose `skillId` matches `skill` (`RunResult.skillActivity`) — evidence-unavailable if skill-activity telemetry is absent; heuristic for inline skills (a sticky, sequential window matching the agent's `activeSkill` scope, not an exact per-tool boundary) |
273
281
  | `max_cost_usd: <N>` | the run's SDK-reported cost is ≤ N USD — evidence-unavailable if cost telemetry is absent. **Replay asserts the frozen recording's cost, not fresh spend** — a real regression needs a live `run` |
274
282
  | `max_tokens: <N>` | `usage.input_tokens + usage.output_tokens` ≤ N (cache tokens excluded) — same replay caveat as `max_cost_usd` |
@@ -281,7 +289,7 @@ same set live from the schema.
281
289
  | `compaction_occurred: true` | a context-compaction boundary occurred (a `compact_boundary` system event was recorded) — lives in the stdout stream, so meaningfully replay-checkable; evidence-unavailable if context-event telemetry is absent. **Only `true` is valid** — omit to not require it |
282
290
  | `all_tasks_completed: true` | every task in `RunResult.tasks[]` reached status `"completed"` — **requires ≥1 task** (a zero-task run fails; assert `task_count_min` for presence); evidence-unavailable if tasks telemetry is absent |
283
291
  | `task_count_min: <N>` | at least N tasks were created (`RunResult.tasks.length >= N`) — the presence companion for task assertions |
284
- | `task_status: {match, status}` | a task whose `subject` OR `id` matches the regex `match` reached `status` — evidence-unavailable if tasks telemetry is absent |
292
+ | `task_status: {match, status}` | a task whose `subject` OR `id` matches the regex `match` reached `status` — evidence-unavailable if tasks telemetry is absent; also fails **malformed** when a TaskCreate result was unparseable (corrupt task telemetry), mirroring the guard `all_tasks_completed`/`task_count_min` already had |
285
293
  | `no_scratchpad_leak: true` | every file presented via `present_files` that was in the scratchpad was successfully promoted to `mnt/outputs` (none left behind) — vacuously passes if nothing was presented (pair with a presence check to require a delivery); content-class: both the `present_files` tool_use and its own tool_result live in the ordinary events stream, so `RunResult.presentedFiles` re-derives identically on replay (meaningfully replay-checkable, same as `skill_triggered`); evidence-unavailable if `presentedFiles` telemetry is absent (an older run predating the feature). **`fidelity: container` only** — `present_files` is not served on hostloop/microvm, so a scratchpad-delivered file is neither promoted to `mnt/outputs` nor detected there (a skill that delivers via write-to-cwd→`present_files` will false-red `user_visible_artifact` on those tiers; use `container`, or write directly to `outputs/`). **Only `true` is valid** |
286
294
  | `present_files_called: true` | at least one file was actually delivered via the `present_files` tool (`RunResult.presentedFiles` is non-empty) — the presence companion to `no_scratchpad_leak` (which passes vacuously when nothing was presented). Pair them to require a delivery **and** require it not to leak. Content-class (re-derives identically on replay). **`fidelity: container` only** — `present_files` is not served on hostloop/microvm. **Only `true` is valid** |
287
295
  | `question_asked: <regex>` | the agent asked an AskUserQuestion whose text matches |
@@ -291,6 +299,9 @@ same set live from the schema.
291
299
  | `gate_answer_count_min: <N>` | at least N AskUserQuestion gates fired AND were delivered non-error — presence companion to `gate_answers_delivered`'s vacuous-pass |
292
300
  | `hook_blocked: <regex>` | a PreToolUse hook blocked a tool whose name matches the regex (`RunResult.hookEvents`) — evidence-unavailable if hook telemetry is absent. Replay: needs a `controlOut` cassette (a custom hook's decision lives only there, not the recorded stream) |
293
301
  | `no_hook_blocked: true` | no tool was hook-blocked during the run (distinguishes a real tool crash from an intentional hook block) — evidence-unavailable if hook telemetry is absent. Replay: needs a `controlOut` cassette. **Only `true` is valid** |
302
+ | `vm_path_denied: true` | **`fidelity: hostloop` only** — at least one recorded path denial (`RunResult.pathDenials`, any source) targeted a `/sessions` VM path — evidence-unavailable if path-denial telemetry is absent. Replay: needs a `controlOut` cassette. Any other tier FAILS "cannot verify". **Only `true` is valid** |
303
+ | `path_denied: {tool?, path_matches?, source?, agent_scope?}` | **`fidelity: hostloop` only** — a path denial matching ALL given matchers (`tool` glob, `path_matches` regex, `source` ∈ pretooluse/can_use_tool/permission_denied, `agent_scope` ∈ main/subagent/any) was recorded. Replay: needs a `controlOut` cassette. Any other tier FAILS "cannot verify" |
304
+ | `no_path_denied: true` | **`fidelity: hostloop` only** — NO path denial was recorded at all (the channel is already path-scoped, unlike `no_hook_blocked`'s indiscriminate reject). Replay: needs a `controlOut` cassette. Any other tier FAILS "cannot verify". **Only `true` is valid** |
294
305
  | `allow_permissive_auto_allow: true` | verdict modifier — suppresses the default-fail when the run recorded a cowork-parity permissive auto-allow; for tests that deliberately assert Cowork's permissive behavior |
295
306
  | `allow_missing_capability: true` | verdict modifier — suppresses the default-fail when the (partial "core") agent image omits a capability the skill used but real Cowork ships (OCR/LibreOffice/markitdown/opencv/PDF-tables). Assert only when the skill's fallback is genuinely equivalent; otherwise rebuild full parity (`--build-arg COWORK_FULL_PARITY=1`). Also opts out of the `requires_capabilities` declared-need check. Live tiers only |
296
307
  | `allow_l0_plugin_divergence: true` | verdict modifier — opt into L0/protocol plugin divergence: suppresses the default-fail when a plugin behaves differently at `protocol` (L0) fidelity than under a sandboxed tier. Live tiers only |
@@ -300,7 +311,7 @@ same set live from the schema.
300
311
  | `egress_allowed: <host>` | the host was allowed through |
301
312
  | `no_mcp_error: true` | no MCP round-trip failed (`RunResult.mcpErrors` is empty — no unhandled server, no handler throw) — live-only: MCP round-trips are harness-computed, not in the SDK stdout stream, so evidence-unavailable on replay (never a vacuous pass). **Only `true` is valid** |
302
313
  | `max_peak_rss_bytes: <N>` | peak sampled RSS of the agent sandbox ≤ N bytes (`RunResult.resources.peakRssBytes`) — live-only: replay never spawns a sandbox to sample, so evidence-unavailable on replay/protocol (never a vacuous pass); also evidence-unavailable when sampling captured no RSS value |
303
- | `semantic_matches: {rubric: [...], min_pass?, judge_model?}` | a pinned LLM judge grades each fixed `rubric` claim against the run's answer — the **union of the agent's final result text (`RunResult.finalMessage`), the transcript, and the final on-disk content of any files the agent authored during the run** — so a claim about content the skill led the agent to *write to a file* grades as reliably as one about inlined prose (authored-file evidence is unavailable on the **microvm** tier — no pre-run manifest to diff against, so no files are captured; `container`/`hostloop` do capture them). The assert passes iff ≥ `min_pass` claims pass (default: all — avoid for a gating scenario). Results align by claim index and are recorded per-claim in `RunResult.assertions[].semanticClaims` (`[{index, claim, pass}]`, so a consumer can diff the per-claim profile across runs); a rep whose grade can't be parsed (after one retry) is marked `RunResult.assertions[].judgeInvalid` and **never silently dropped** — it is excluded from the pass denominator, and the guard against a misleading score from that exclusion is the gate's minimum-valid-rep floor (`MIN_VALID` ≥ 4) plus this visibility, not a claim that denominator-shrinking inflation is impossible. Within a rep, a grade that's still unparseable after the retry **fails that assert outright** (evidence-unavailable, not a vacuous pass) — a persistently-flaky judge reds the run rather than silently passing. `judge_model` pins the grader (a dated id keeps a before/after comparison reproducible). Live-only: the judge is a live model call, so evidence-unavailable / skipped-loud on replay (never a vacuous pass) |
314
+ | `semantic_matches: {rubric: [...], min_pass?, judge_model?}` | a pinned LLM judge grades each fixed `rubric` claim against the run's answer — the **union of the agent's final result text (`RunResult.finalMessage`), the transcript, and the final on-disk content of any files the agent authored during the run** — so a claim about content the skill led the agent to *write to a file* grades as reliably as one about inlined prose (authored-file evidence is unavailable on the **microvm** tier — no pre-run manifest to diff against, so no files are captured; `container`/`hostloop` do capture them). Beyond the microvm case, when the authored-file evidence backing the judged document is **incomplete** — a file dropped at the capture-size cap, unreadable at read-back, or (on `--resume`) the scratchpad walk skipped — the assert fails evidence-unavailable rather than trusting a judge grade over a partial document; this is separate from the malformed-grade `judgeInvalid` path below. The assert passes iff ≥ `min_pass` claims pass (default: all — avoid for a gating scenario). Results align by claim index and are recorded per-claim in `RunResult.assertions[].semanticClaims` (`[{index, claim, pass}]`, so a consumer can diff the per-claim profile across runs); a rep whose grade can't be parsed (after one retry) is marked `RunResult.assertions[].judgeInvalid` and **never silently dropped** — it is excluded from the pass denominator, and the guard against a misleading score from that exclusion is the gate's minimum-valid-rep floor (`MIN_VALID` ≥ 4) plus this visibility, not a claim that denominator-shrinking inflation is impossible. Within a rep, a grade that's still unparseable after the retry **fails that assert outright** (evidence-unavailable, not a vacuous pass) — a persistently-flaky judge reds the run rather than silently passing. `judge_model` pins the grader (a dated id keeps a before/after comparison reproducible). Live-only: the judge is a live model call, so evidence-unavailable / skipped-loud on replay (never a vacuous pass) |
304
315
  | `artifact_json: {artifact, path, …}` | assert a JSON artifact's contents — `equals`/`gt`/`in`/`exists`/`absent`/`is_null` over a dotted `path` (`in` = membership in a list, for a stochastic/LLM value; `absent` ≠ `is_null`; an unresolved intermediate fails loud) |
305
316
  | `computer_links_resolve: true` | every `computer://` link in the model-visible transcript resolves to an artifact that exists in the run's collected outputs/mounts — a dangling link fails, naming which target was checked (a live host path, the collected work tree, or the replay manifest). **Requires ≥1 link** (zero links fails — use `computer_links_resolve_if_present` for the presence-free variant). **Only `true` is valid** (`false` is rejected by the schema) |
306
317
  | `computer_links_resolve_if_present: true` | like `computer_links_resolve` but passes vacuously when the transcript has zero `computer://` links — the presence-free variant. **Only `true` is valid** |
@@ -319,13 +330,14 @@ dotted path.
319
330
 
320
331
  **VerdictSignals in `result.signals`:** `computeVerdict` pushes signals into `result.signals`; most
321
332
  are **fail**-severity (they flip the run's pass/exit code even though `result.result` itself stays
322
- `"success"`) and only two are **warn**-severity (informational, never flip pass/fail). Current signal
333
+ `"success"`) and only three are **warn**-severity (informational, never flip pass/fail). Current signal
323
334
  codes (`VerdictSignal["code"]` in `src/run/verdict.ts`):
324
335
 
325
336
  | Code | Severity | Meaning |
326
337
  |---|---|---|
327
338
  | `assertion` | fail | An authored `assert:` item failed |
328
339
  | `result_error` | fail | The run's SDK result was `"error"` |
340
+ | `usage_limit` | fail | Usage/quota limit hit (not a skill failure) — retry after the limit resets. Emitted when `RunResult.resultErrorKind === "usage_limit"` |
329
341
  | `transport_error` | fail | The connection dropped mid/after-run |
330
342
  | `permissive_auto_allow` | fail | A cowork-parity auto-allow real Cowork would block (opt out: `allow_permissive_auto_allow`) |
331
343
  | `outputs_delete` | fail | An unauthorized delete touched `mnt/outputs` (opt out: author `no_delete_in_outputs`) |
@@ -336,15 +348,16 @@ codes (`VerdictSignal["code"]` in `src/run/verdict.ts`):
336
348
  | `stalled` | fail | The run ended on an unanswered question (opt out: `allow_stall`) |
337
349
  | `non_deterministic` | warn | The run was LLM/external/human-decided — not reproducible |
338
350
  | `prompt_asset_missing` | warn | The run proceeded with a missing prompt asset (`COWORK_HARNESS_ALLOW_MISSING_PROMPT=1`); fidelity is degraded |
351
+ | `scan_unavailable` | warn | Post-run scan evidence unavailable (`RunResult.scan` undefined) — the host-path and outputs-delete guards did not run this run |
339
352
 
340
353
  A **fail**-severity signal does not change `result.result` (still `"success"`), but it DOES fail the
341
354
  overall run verdict and exit code — `assert result: success` alone won't catch it; check
342
- `result.signals[].severity` or the run's exit code. Only the two **warn** codes are truly benign.
355
+ `result.signals[].severity` or the run's exit code. Only the three **warn** codes are truly benign.
343
356
 
344
357
  ## Replay class
345
358
 
346
359
  A cassette (`record`/`replay`) has **no filesystem and no network**. `replay` re-evaluates only the
347
- **content** assertions. The authoritative list is `contentKeys` in `src/run/cassette.ts`.
360
+ **content** assertions. The authoritative list is `ALWAYS_CONTENT_KEYS`/`QUESTION_GATE_KEYS`/`MANIFEST_KEYS` (composed) in `src/run/cassette.ts`.
348
361
 
349
362
  **Assertion source — frozen by default, on-disk by opt-in.** A plain `replay` evaluates the `assert:` block
350
363
  **frozen in the cassette** (byte-deterministic, ignores the working tree); editing `scenarios/<name>.yaml` does
@@ -355,7 +368,8 @@ on recording-shaping drift (`prompt`/`answers`/`baseline`/`skills`) or skill-con
355
368
  sourcing ≠ evaluation (replay warns when you edit one). `verify-run` is the on-disk-`assert:` path for a kept
356
369
  *run dir*; `--assert-from` is the equivalent for a *cassette*.
357
370
 
358
- **Evaluated on replay (content):** `transcript_*`, `tool_*`, `subagent_*`, `dispatch_count_max`,
371
+ **Evaluated on replay (content):** `transcript_*`, `tool_*`, `subagent_*`, `subagent_file_write`,
372
+ `no_vm_path_file_op`, `dispatch_count_max`,
359
373
  `skill_triggered`, `no_skill_triggered`, `skill_available`, `connector_available`, `tool_available`,
360
374
  `skill_tool_used`, `max_cost_usd`, `max_tokens`, `tool_calls_max`, `tool_no_error`,
361
375
  `max_tool_errors`, `max_redundant_tool_calls`, `max_turns`, `compaction_occurred`, `all_tasks_completed`, `task_status`, `task_count_min`, `no_scratchpad_leak`, `present_files_called`, `result`
@@ -364,7 +378,9 @@ modifiers `allow_permissive_auto_allow` / `allow_missing_capability` / `allow_l0
364
378
  `allow_stall` are also kept on replay, evaluated as no-op passes.
365
379
 
366
380
  **Gate keys — replay only with a `controlOut` cassette:** `question_asked`, `questions_count_max`,
367
- `gate_answers_delivered`, `gate_answer_count_min`, `hook_blocked`, `no_hook_blocked`. With `controlOut` present they evaluate; on an old
381
+ `gate_answers_delivered`, `gate_answer_count_min`, `hook_blocked`, `no_hook_blocked`, `vm_path_denied`,
382
+ `path_denied`, `no_path_denied` (the latter three are also `fidelity: hostloop`-only — see the assertion
383
+ table). With `controlOut` present they evaluate; on an old
368
384
  cassette without it, a **loud warning** fires and they are **excluded** (not vacuously passed). Re-record to enable them.
369
385
  `questions_count_max` counts sub-questions, not gates/tool-calls — see the catalog row above and
370
386
  `trace --view questions`. `hook_blocked`/`no_hook_blocked` need `controlOut` for a different reason than
@@ -458,7 +474,7 @@ reference omits). Neither list is a strict superset of the other — reach for t
458
474
  are replay-checkable **when the cassette carries an `artifacts` manifest**; without one they are
459
475
  skipped too.) *Partial skip:* a mixed `{result, egress_denied}` greens on `result` while
460
476
  `egress_denied` is dropped. Both now warn loudly. → put egress/live-only checks on a live gate; one
461
- concern per item; run the linter. (`contentKeys` in `src/run/cassette.ts`.)
477
+ concern per item; run the linter. (`LIVE_ONLY_KEYS`/`MANIFEST_KEYS` in `src/run/cassette.ts`.)
462
478
 
463
479
  2. **Gate keys need a `controlOut` cassette.** `question_asked`, `questions_count_max`,
464
480
  `gate_answers_delivered`, `gate_answer_count_min`, `hook_blocked`, `no_hook_blocked` only evaluate on
@@ -468,7 +484,7 @@ reference omits). Neither list is a strict superset of the other — reach for t
468
484
  vacuously when zero gates fired**; use `gate_answer_count_min: 1` to also require a gate to have
469
485
  fired. A **header-only gate** (empty `question`, only `header`) can never be keyed and is rejected
470
486
  loudly — every gate needs a non-empty `question`.
471
- (`contentKeys` in `src/run/cassette.ts`; `src/assert.ts`.)
487
+ (`QUESTION_GATE_KEYS` in `src/run/cassette.ts`; `src/assert.ts`.)
472
488
 
473
489
  3. **The LLM-decider's two spellings.** Scripted answers + `on_unanswered: fail` is deterministic;
474
490
  the stochastic path flags the run `nonDeterministic`. The LLM decider is one mechanism, two
@@ -497,8 +513,13 @@ reference omits). Neither list is a strict superset of the other — reach for t
497
513
  `artifact_json` (dotted `path` + operator); use the pytest lane (`assert_artifact_json` with a real
498
514
  predicate) only for checks too complex for a dotted path. Find field paths via `--keep`.
499
515
 
500
- 10. **`subagent_dispatched` matches by `description` too** — skills often dispatch with no
501
- `subagent_type` (`agentType:"unknown"`), so match the dispatch description.
516
+ 10. **`subagent_dispatched` matches by `resolvedAgentType` or `description` too** — a `Task` dispatch
517
+ with no `subagent_type` at all falls back to the built-in `general-purpose` agent (a WILDCARD tool
518
+ surface, `tools:["*"]`, incl. workspace bash) rather than leaving the type unresolved; the harness
519
+ warns loudly on this fallback and records `subagents[].dispatchTypeOmitted`, so match on the
520
+ resolved type or the dispatch description. `subagent_tool_absent` on a type-less dispatch is
521
+ correspondingly weaker evidence (wildcard surface) — pin `subagent_type` explicitly when you need a
522
+ tight guarantee.
502
523
 
503
524
  11. **`subagent_declared_but_unused` fires on declared-but-didn't-use-THAT-tool**, even if the
504
525
  sub-agent used other tools.
@@ -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. Facts track the harness version in SKILL.md's
5
- front-matter (currently 0.28.0). Recipe 2's `resolved-tier`/`unverifiable-tier` staleness classes and
5
+ front-matter (currently 0.29.0). 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
 
@@ -64,8 +64,8 @@ Top-level fields of a `*.cassette.json` (schema `schema/cassette.v10.json`):
64
64
  | `preRunHashes` | Pre-run per-path sha256 baseline for `input_unmodified` (added 0.27.0, no cassetteVersion bump); a `null` value marks a path whose recorded artifact body was secret-scrubbed (evidence-unavailable, never a false "modified") |
65
65
  | `scenarioSource` | Relative path to the authored YAML this was recorded from |
66
66
  | `authoring` | Present iff a live decider answered ≥1 gate during recording (`nonDeterministic: true`) |
67
- | `sessionFingerprint` | v9+: hash of the session's content-relevant SHAPE (folders/plugins/skills/mcp/egress). Checked ONLY by `verify-cassettes`, never the default replay verdict; absent on a pre-v9 cassette → not checked |
68
- | `folderPrefixMap` | v9+: the record-time connected-folder host-path → mount-name map. Replay's `computer_links_resolve` uses THIS (never the current session file) for a v9+ cassette; a pre-v9 cassette keeps reconstructing from the current session |
67
+ | `sessionFingerprint` | Optional even on v9+ (the minimum readable version): hash of the session's content-relevant SHAPE (folders/plugins/skills/mcp/egress). Checked ONLY by `verify-cassettes`, never the default replay verdict; absent → not checked |
68
+ | `folderPrefixMap` | Optional even on v9+: the record-time connected-folder host-path → mount-name map. Replay's `computer_links_resolve` uses THIS (never the current session file); absent → the link is treated as evidence-unavailable, never reconstructed from the current session |
69
69
  | `timeline`, `timelineHeader` | The recorded per-event timeline (harness-observation timestamps for tool_use/tool_result/subagent_dispatch/thinking/decision/result, in total order) plus its header (`startedAtWall`/`startedAtMono` anchors); informational only — never affects the replay verdict. Absent on a cassette recorded before this field existed |
70
70
  | `environment` | Recording provenance: `location` (`"local"` on every cassette this harness produces), plus the resolved `tier` and `agentBinaryFormat` |
71
71
 
@@ -28,9 +28,12 @@
28
28
  "no_delete_in_outputs",
29
29
  "no_hook_blocked",
30
30
  "no_mcp_error",
31
+ "no_path_denied",
31
32
  "no_scratchpad_leak",
32
33
  "no_skill_triggered",
33
34
  "no_unexpected_files",
35
+ "no_vm_path_file_op",
36
+ "path_denied",
34
37
  "present_files_called",
35
38
  "question_asked",
36
39
  "questions_count_max",
@@ -43,6 +46,7 @@
43
46
  "skill_triggered",
44
47
  "subagent_declared_but_unused",
45
48
  "subagent_dispatched",
49
+ "subagent_file_write",
46
50
  "subagent_output_contains",
47
51
  "subagent_tool_absent",
48
52
  "subagent_tool_used",
@@ -61,7 +65,8 @@
61
65
  "transcript_no_host_path",
62
66
  "transcript_not_contains",
63
67
  "transcript_not_matches",
64
- "user_visible_artifact"
68
+ "user_visible_artifact",
69
+ "vm_path_denied"
65
70
  ],
66
71
  "topLevelKeys": [
67
72
  "allow_host_writes",
@@ -45,7 +45,7 @@ import sys
45
45
  from pathlib import Path
46
46
 
47
47
  # --- the replay-class taxonomy ---
48
- # NB: this is NOT a 1:1 mirror of `contentKeys` in src/run/cassette.ts. cassette.ts keeps the verdict
48
+ # NB: this is NOT a 1:1 mirror of the ALWAYS_CONTENT_KEYS/QUESTION_GATE_KEYS/MANIFEST_KEYS buckets in src/run/cassette.ts. cassette.ts keeps the verdict
49
49
  # modifiers (VERDICT_MODIFIER_KEYS) in its content set so they replay as no-op passes; the linter
50
50
  # deliberately keeps them OUT of CONTENT_KEYS so a modifier-only scenario still trips the `replay-noop`
51
51
  # warning below (a no-op pass verifies nothing real — exactly what that warning is for).
@@ -64,6 +64,8 @@ CONTENT_KEYS = {
64
64
  "subagent_dispatched",
65
65
  "subagent_declared_but_unused",
66
66
  "subagent_output_contains",
67
+ "no_vm_path_file_op",
68
+ "subagent_file_write",
67
69
  "dispatch_count_max",
68
70
  "skill_triggered",
69
71
  "no_skill_triggered",
@@ -94,6 +96,9 @@ GATE_KEYS = {
94
96
  "gate_answer_count_min",
95
97
  "hook_blocked",
96
98
  "no_hook_blocked",
99
+ "vm_path_denied",
100
+ "path_denied",
101
+ "no_path_denied",
97
102
  }
98
103
  # manifest-backed: replay-checkable when the cassette carries an `artifacts` manifest (record snapshots one);
99
104
  # a manifest-less cassette skips them. Since the 0.3.0 artifact-manifest these are NOT always live-only.
@@ -688,6 +693,12 @@ def cmd_lint(args):
688
693
  # v1 declines to do.
689
694
 
690
695
  _PLUGIN_ROOT_TOKEN = re.compile(r"\$\{?CLAUDE_PLUGIN_ROOT\}?")
696
+ # A runtime SELF-HEAL for a dead ${CLAUDE_PLUGIN_ROOT}: discovering the real mount under /sessions at run
697
+ # time (the prescribed pattern — e.g. `[ -d "$X" ] || X=$(find /sessions ... -name ...)`, or an inline
698
+ # `|| python3 "$(find /sessions ...)"`). When a bash block that uses the token ALSO contains a `find` over
699
+ # /sessions, the token is dead but the block rescues it → downgrade the WARN to INFO (Item 4). Conservative:
700
+ # we do NOT verify the find pattern actually matches the plugin's layout (hence the INFO's "not validated").
701
+ _SELF_HEAL = re.compile(r"\bfind\b[^\n]*/sessions")
691
702
  # Opening/closing fence: ``` or ~~~ (>=3), optional info string (language).
692
703
  _FENCE = re.compile(r"^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)\s*$")
693
704
  # A hooks-config command string: `"command": "<value>"` (value may contain escaped quotes).
@@ -717,6 +728,19 @@ def _finding_plugin_root(path, line, ctx_label):
717
728
  )
718
729
 
719
730
 
731
+ def _finding_plugin_root_guarded(path, line, ctx_label):
732
+ return Finding(
733
+ "INFO",
734
+ "plugin-root-guarded",
735
+ f"`${{CLAUDE_PLUGIN_ROOT}}` used in an in-VM bash context ({ctx_label}), but the same block "
736
+ "self-heals it (a runtime `find` under /sessions) — the dead token is harmless here.",
737
+ "Guard not validated: the linter does not check the `find` pattern actually matches the plugin's "
738
+ "layout. Prefer resolving the mount from the script's own location over a find-fallback.",
739
+ path,
740
+ line,
741
+ )
742
+
743
+
720
744
  def _finding_hook_host_write(path, line, what):
721
745
  return Finding(
722
746
  "WARN",
@@ -748,6 +772,24 @@ def _lint_skill_text(path, raw_lines, force_json=False):
748
772
  fence_char = ""
749
773
  fence_len = 0
750
774
  fence_lang = ""
775
+ # Per-bash-fence buffer (Item 4): plugin-root token hit line numbers + the whole block's text, so a
776
+ # ${CLAUDE_PLUGIN_ROOT} use that is self-healed elsewhere IN THE SAME BLOCK downgrades WARN -> INFO.
777
+ # Emission is deferred to fence close (or EOF) but original 1-based line numbers are preserved.
778
+ bash_token_lines = []
779
+ bash_block_text = []
780
+
781
+ def flush_bash():
782
+ if bash_token_lines:
783
+ healed = _SELF_HEAL.search("\n".join(bash_block_text)) is not None
784
+ for ln in bash_token_lines:
785
+ findings.append(
786
+ _finding_plugin_root_guarded(path, ln, "```bash block")
787
+ if healed
788
+ else _finding_plugin_root(path, ln, "```bash block")
789
+ )
790
+ bash_token_lines.clear()
791
+ bash_block_text.clear()
792
+
751
793
  for i, line in enumerate(raw_lines, start=1):
752
794
  m = _FENCE.match(line)
753
795
  if m:
@@ -757,6 +799,8 @@ def _lint_skill_text(path, raw_lines, force_json=False):
757
799
  continue
758
800
  # A closing fence uses the same char, is at least as long, and carries no language.
759
801
  if marker[0] == fence_char and len(marker) >= fence_len and not lang:
802
+ if fence_lang in _BASH_FENCE_LANGS:
803
+ flush_bash() # decide WARN vs INFO now that the whole block is known
760
804
  in_fence = fence_char = ""
761
805
  fence_len = 0
762
806
  fence_lang = ""
@@ -775,8 +819,9 @@ def _lint_skill_text(path, raw_lines, force_json=False):
775
819
  ctx = "prose"
776
820
 
777
821
  if ctx == "bash":
822
+ bash_block_text.append(line) # buffer the block; token hits emit on flush (self-heal aware)
778
823
  if _PLUGIN_ROOT_TOKEN.search(line):
779
- findings.append(_finding_plugin_root(path, i, "```bash block"))
824
+ bash_token_lines.append(i)
780
825
  elif ctx == "json":
781
826
  for cm in _HOOK_CMD.finditer(line):
782
827
  _check_hook_command(path, i, cm.group(1), findings)
@@ -786,6 +831,10 @@ def _lint_skill_text(path, raw_lines, force_json=False):
786
831
  for bm in _BASH_DIRECTIVE.finditer(line):
787
832
  if _PLUGIN_ROOT_TOKEN.search(bm.group(1)):
788
833
  findings.append(_finding_plugin_root(path, i, "Bash() directive"))
834
+ # A bash fence left unclosed at EOF still has buffered token hits — flush them (else a real WARN/INFO
835
+ # would be silently dropped).
836
+ if in_fence and fence_lang in _BASH_FENCE_LANGS:
837
+ flush_bash()
789
838
  return findings
790
839
 
791
840
 
package/AGENTS.md CHANGED
@@ -42,10 +42,11 @@ protocol layer or run-loop bookkeeping in the CLI.
42
42
  scenario key is retired vocabulary — the alias is gone and it is now rejected as an unknown key — do
43
43
  not reintroduce the term.
44
44
  - **A new assertion must pick its replay class.** *Content* assertions (read only `ctx.transcript` / the
45
- record) go in `src/run/cassette.ts` `contentKeys` so they run on the token-free `replay` PR gate;
46
- *filesystem / egress* assertions do NOT — they are excluded **by omission** from `contentKeys`, so they
47
- only run on live (non-replay) gates. `contentKeys` is the single source of truth; the README's "what
48
- replay checks" prose just describes it. The wrong choice is a **silent no-op in CI**.
45
+ record) go in one of `src/run/cassette.ts`'s exported buckets — `ALWAYS_CONTENT_KEYS`, `QUESTION_GATE_KEYS`,
46
+ or `MANIFEST_KEYS` — so they run on the token-free `replay` PR gate; *filesystem / egress* assertions go
47
+ in `LIVE_ONLY_KEYS` instead, so they only run on live (non-replay) gates. These four exported constants
48
+ are the single source of truth; the README's "what replay checks" prose just describes them. The wrong
49
+ bucket is a **silent no-op in CI**.
49
50
  - **`replay` consumes `controlOut` and re-serializes via `serializeDecision` to guard the AskUserQuestion
50
51
  answer shape (O7) on the token-free lane. A new decision *kind* must extend BOTH `serializeDecision`
51
52
  AND `deserializeDecision` (declared inverses in `src/agent/session.ts`) — they must not drift.**