cowork-harness 0.21.0 → 0.23.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 (92) hide show
  1. package/.claude/skills/cowork-harness/SKILL.md +45 -29
  2. package/.claude/skills/cowork-harness/references/ci-recipe.md +64 -9
  3. package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +4 -3
  4. package/.claude/skills/cowork-harness/references/scenario-schema.md +37 -15
  5. package/.claude/skills/cowork-harness/scripts/assertion-keys.json +7 -0
  6. package/.claude/skills/cowork-harness/scripts/scenario.py +25 -7
  7. package/AGENTS.md +2 -2
  8. package/CHANGELOG.md +392 -2
  9. package/DESIGN.md +5 -3
  10. package/README.md +83 -20
  11. package/SPEC.md +88 -18
  12. package/baselines/desktop-1.11847.5.json +2 -0
  13. package/baselines/desktop-1.12603.1.json +2 -0
  14. package/baselines/desktop-1.13576.1.json +3 -1
  15. package/baselines/desktop-1.14271.0.json +3 -1
  16. package/baselines/desktop-1.15200.0.json +3 -1
  17. package/baselines/desktop-1.15962.0.json +3 -1
  18. package/baselines/desktop-1.15962.1.json +3 -1
  19. package/baselines/desktop-1.17377.1.json +4 -1
  20. package/baselines/desktop-1.17377.2.json +5 -1
  21. package/baselines/desktop-1.18286.0.json +286 -0
  22. package/baselines/prompts/desktop-1.18286.0/system-prompt-append.md +214 -0
  23. package/dist/agent/session.js +7 -2
  24. package/dist/assert.js +88 -0
  25. package/dist/baseline.js +45 -3
  26. package/dist/cli-args.js +1 -1
  27. package/dist/cli.js +981 -73
  28. package/dist/decide/llm-transport.js +19 -4
  29. package/dist/errors.js +12 -0
  30. package/dist/loop-decision.js +0 -3
  31. package/dist/prompt.js +31 -8
  32. package/dist/run/cassette.js +177 -47
  33. package/dist/run/chat.js +42 -5
  34. package/dist/run/computer-links.js +198 -0
  35. package/dist/run/diff.js +178 -0
  36. package/dist/run/display-translate.js +226 -0
  37. package/dist/run/doctor.js +42 -3
  38. package/dist/run/envelope.js +32 -9
  39. package/dist/run/execute.js +89 -8
  40. package/dist/run/matrix.js +131 -0
  41. package/dist/run/renderer.js +27 -7
  42. package/dist/run/repeat.js +89 -0
  43. package/dist/run/run-index.js +253 -0
  44. package/dist/run/run.js +15 -2
  45. package/dist/run/trace-view.js +76 -14
  46. package/dist/run/verdict.js +8 -6
  47. package/dist/run/vm-path-ctx-file.js +123 -0
  48. package/dist/runtime/argv.js +13 -0
  49. package/dist/runtime/hostloop.js +3 -0
  50. package/dist/scan.js +31 -9
  51. package/dist/session.js +36 -0
  52. package/dist/sync/baseline-diff.js +138 -0
  53. package/dist/sync/cowork-sync.js +567 -11
  54. package/dist/types.js +51 -20
  55. package/dist/vm-paths.js +345 -0
  56. package/docs/README.md +4 -1
  57. package/docs/cassette.md +46 -28
  58. package/docs/chat.md +4 -3
  59. package/docs/cowork-spawn-contract-1.12603.1.md +1 -1
  60. package/docs/debugging.md +29 -0
  61. package/docs/discovery.md +6 -0
  62. package/docs/fidelity-gaps.md +61 -0
  63. package/docs/invariants.md +1 -1
  64. package/docs/maintenance.md +26 -1
  65. package/docs/protocol.md +136 -0
  66. package/docs/run-status.md +33 -0
  67. package/docs/scenario.md +104 -11
  68. package/docs/session.md +2 -0
  69. package/docs/stats.md +74 -0
  70. package/examples/README.md +1 -0
  71. package/examples/replays/README.md +10 -1
  72. package/examples/replays/example-multiselect-gate.cassette.json +1 -1
  73. package/examples/replays/example-pdf-skill-ci-selftest-failing.yaml +30 -0
  74. package/examples/replays/example-pdf-skill.cassette.json +1 -1
  75. package/examples/replays/hostloop-computer-links.cassette.json +77 -0
  76. package/fixtures/protocol/v1/allow-response.json +13 -0
  77. package/fixtures/protocol/v1/deny-response.json +11 -0
  78. package/fixtures/protocol/v1/hook-callback.json +27 -0
  79. package/fixtures/protocol/v1/initialize.json +18 -0
  80. package/fixtures/protocol/v1/mcp-message.json +42 -0
  81. package/fixtures/protocol/v1/permission-request.json +13 -0
  82. package/fixtures/protocol/v1/question-answer-response.json +36 -0
  83. package/fixtures/protocol/v1/question-request.json +33 -0
  84. package/llms.txt +4 -1
  85. package/package.json +3 -3
  86. package/python/test_scenario_lint.py +21 -0
  87. package/schema/protocol.v1.json +261 -0
  88. package/schema/run-result.json +24 -2
  89. package/schema/scenario.schema.json +37 -1
  90. package/schema/session.schema.json +3 -0
  91. package/scripts/check-versions.ts +80 -3
  92. package/scripts/gen-schema.ts +4 -3
@@ -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.21.0
7
- tracks-harness: cowork-harness 0.21.0 (baseline desktop-1.17377.2)
6
+ version: 0.23.0
7
+ tracks-harness: cowork-harness 0.23.0 (baseline desktop-1.18286.0)
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.21.0` (baseline
26
- > `desktop-1.17377.2`). If your checkout is newer, prefer the live `--help`, `SPEC.md`, and
25
+ > **Version note:** the facts and `file:line` pointers here track `cowork-harness 0.23.0` (baseline
26
+ > `desktop-1.18286.0`). If your checkout is newer, prefer the live `--help`, `SPEC.md`, and
27
27
  > `docs/*.md` over this snapshot, and re-run the bundled linter.
28
28
 
29
29
  ## 0. 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.21.0**. If it's missing or older, prefix every command with the version floor `npx cowork-harness@>=0.21.0 <cmd>` (Node ≥ 20), or install once with `npm i -g cowork-harness@>=0.21.0`. **Pin `@>=0.21.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.21.0 is what gates the commands/assertions this skill teaches: `assertions --list`, `scaffold <run-id>`, `trace --view dispatches`, `artifact_json` incl. the `in:` operator, `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-file` (`path` — local absolute filesystem paths — is the scanner's 4th class, new in 0.21.0), scenario `skills:` staleness scoping with `COWORK_HARNESS_AGENT_SCOPE=skill`, `chat --plugin`, `/help` in the REPL, and `hostloop`'s native host/VM process split with its `allow_host_writes:` consent field.)
41
+ - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 0.23.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=0.23.0" <cmd>` (Node ≥ 20), or install once with `npm i -g "cowork-harness@>=0.23.0"`. **Pin `@>=0.23.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.23.0 is what gates the commands/assertions this skill teaches: `assertions --list`, `scaffold <run-id>`, `trace --view dispatches`, `artifact_json` incl. the `in:` operator, `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-file` (`path` — local absolute filesystem paths — is the scanner's 4th class, new in 0.21.0), 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, and `computer_links_resolve` (new in 0.22.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`).
@@ -54,8 +54,13 @@ Before the first command, confirm the CLI is reachable and **fail loud** (never
54
54
  debugging map).
55
55
 
56
56
  Full command set: `skill · run · chat · record · replay · verify-cassettes · rehash · prune · lint ·
57
- verify-run · trace · inspect · decide · gates · answer · scaffold · assertions --list · sync · list ·
58
- boundary-check · vm <init|status|delete|prune> · doctor`. Always check `cowork-harness <cmd> --help`.
57
+ verify-run · trace · inspect · diff · stats · decide · gates · answer · scaffold · assertions --list · sync ·
58
+ list · boundary-check · status · vm <init|status|delete|prune> · doctor`. Always check `cowork-harness <cmd> --help`.
59
+
60
+ **Two different `scaffold` tools — don't confuse them.** The native `cowork-harness scaffold <run-id>`
61
+ above turns an already-*recorded* run into a scenario (needs a run to exist first). The bundled
62
+ `scripts/scenario.py scaffold --name … --skill …` in §9 builds a scenario from flags alone, no run
63
+ required. Passing §9's flag set to the native command fails with `unknown flag: --name` (exit 2).
59
64
 
60
65
  ## 2. Two files: session vs scenario
61
66
 
@@ -94,7 +99,8 @@ the folder basename (collision-resolved); there is no `to:` override. See `refer
94
99
  | `hostloop` / `cowork` | Production split-exec: the agent loop is a **native process on the host** (no container around the file tools — matching production), with native Bash/WebFetch disabled and routed host-side via the workspace SDK-MCP server into a Docker VM sidecar | Highest-fidelity / parity runs. A writable connected folder needs `allow_host_writes: true` (see scenario-schema.md). |
95
100
 
96
101
  Set the tier in the **scenario's `fidelity:` field**, not a flag — `run` rejects `--fidelity`
97
- (it's a `skill`-only flag). See `references/fidelity-and-answers.md`.
102
+ (it's a `skill`/`chat` flag; `run` takes fidelity only from the scenario). See
103
+ `references/fidelity-and-answers.md`.
98
104
 
99
105
  ## 5. Choose an answer path (gates: AskUserQuestion + tool-permission)
100
106
 
@@ -265,7 +271,7 @@ known-good skeleton (right tier, scripted `answers:` + `on_unanswered: fail`, co
265
271
  separated from live-only ones, one concern per item) and **self-lints its own output**:
266
272
 
267
273
  ```bash
268
- S="${CLAUDE_PLUGIN_ROOT}/skills/cowork-harness/scripts/scenario.py"
274
+ S="${CLAUDE_PLUGIN_ROOT}/scripts/scenario.py"
269
275
  # Working from a repo checkout instead of an installed plugin?
270
276
  # S=".claude/skills/cowork-harness/scripts/scenario.py"
271
277
  python3 "$S" scaffold --name report-check --skill ./skills/report-gen \
@@ -288,7 +294,9 @@ keys). Exit code is non-zero on errors (CI-friendly). `scaffold` auto-upgrades t
288
294
  for egress on `protocol`, so it never emits a scenario `lint` would reject.
289
295
 
290
296
  CI placement: a **token-free `replay` PR gate** (content/structure only) + a **nightly live `run`**
291
- (filesystem/egress). See `references/ci-recipe.md` for the four-stage pipeline.
297
+ (filesystem/egress). Fastest setup: `uses: yaniv-golan/cowork-harness@v1` (a packaged GitHub Action with a
298
+ PR job-summary reporter). See `references/ci-recipe.md` for the Action, the manual step-by-step form, and
299
+ the four-stage pipeline.
292
300
 
293
301
  ## 10. Debugging with `chat`
294
302
 
@@ -326,7 +334,8 @@ are the ones that bite hardest.
326
334
  `replay` skips filesystem/egress keys (`file_exists`, `user_visible_artifact`, `artifact_json`,
327
335
  `egress_*`, `no_delete_in_outputs`, `self_heal_ran`, `transcript_no_host_path`); a *mixed* item like
328
336
  `{result, egress_denied}` greens on `result` while its `egress_denied` half is dropped. (`record`
329
- snapshots an `artifacts` manifest, which makes `file_exists`/`user_visible_artifact`/`artifact_json`
337
+ snapshots an `artifacts` manifest, which makes
338
+ `file_exists`/`user_visible_artifact`/`artifact_json`/`computer_links_resolve`
330
339
  replay-checkable — but the live-only egress keys stay skipped.) *Fix:* put egress/live-only checks on
331
340
  a live gate; keep one concern per `assert:` item; run the linter. The harness warns loudly on skip.
332
341
 
@@ -389,23 +398,29 @@ are the ones that bite hardest.
389
398
  prompt. *Fix:* treat `prompt_asset_missing` as a blocking error in CI by checking the signals
390
399
  array.
391
400
  13. **`result: success` means the agent didn't error, NOT that the task completed — always assert on
392
- artifacts/content.** A turn that ends on a plain-text re-ask ("which file did you mean?") still
393
- reports `result: success`. The harness catches a run that ends on a question and did **no productive
394
- work after its last gate** — both the no-gate case ("which file?" with no tool calls) AND the
395
- *answered-gate-then-re-ask* case (the agent answers an `AskUserQuestion`, then asks again in plain text
396
- and stops) — with a **`stalled`** verdict signal (suppress with `allow_stall: true` if ending on a
397
- question is intended). The signal is a **tool-position heuristic**, not deliverable detection, so it is
398
- imprecise both ways: (a) a post-gate tool *call* clears the flag whether it **succeeded or errored** (false
399
- negative — an agent that ran a tool after the gate and still stalled is not caught); and (b) a deliverable
400
- written *before* a final confirmation gate does **not** clear it, so a write-then-confirm-then-question run
401
- is flagged (false positive — use `allow_stall: true` for a deliberate confirm-terminal skill). The broad
402
- guard is therefore YOUR assertions — assert the deliverable
403
- (`file_exists` / `artifact_json` / `transcript_matches`), never just `result: success`. Note:
404
- `on_unanswered` governs **unanswered** `AskUserQuestion` gates; the `stalled` signal covers stalling
405
- *after* one is answered. A "type-it-in-notes" option has **no scripted deterministic answer** today (the
406
- `OTHER:` directive works only on the LLM-decider path, not scripted `choose:`; on an options-bearing gate
407
- a bare out-of-set LLM answer fails loud (exit 2) — see the LLM-decider free-text note in
408
- `references/fidelity-and-answers.md`).
401
+ artifacts/content.**
402
+ - A turn that ends on a plain-text re-ask ("which file did you mean?") still reports
403
+ `result: success`.
404
+ - The harness catches this with a **`stalled`** verdict signal: a run that ends on a question and
405
+ did **no productive work after its last gate** — both the no-gate case ("which file?" with no
406
+ tool calls) AND the *answered-gate-then-re-ask* case (the agent answers an `AskUserQuestion`,
407
+ then asks again in plain text and stops). Suppress with `allow_stall: true` if ending on a
408
+ question is intended.
409
+ - The signal is a **tool-position heuristic**, not deliverable detection, so it is imprecise both
410
+ ways:
411
+ - **False negative:** a post-gate tool *call* clears the flag whether it **succeeded or
412
+ errored** — an agent that ran a tool after the gate and still stalled is not caught.
413
+ - **False positive:** a deliverable written *before* a final confirmation gate does **not**
414
+ clear it, so a write-then-confirm-then-question run is flagged — use `allow_stall: true` for a
415
+ deliberate confirm-terminal skill.
416
+ - The broad guard is therefore YOUR assertions — assert the deliverable (`file_exists` /
417
+ `artifact_json` / `transcript_matches`), never just `result: success`.
418
+ - `on_unanswered` governs **unanswered** `AskUserQuestion` gates; the `stalled` signal covers
419
+ stalling *after* one is answered — two different failure modes.
420
+ - **Free-text aside:** a "type-it-in-notes" option has **no scripted deterministic answer** today
421
+ (the `OTHER:` directive works only on the LLM-decider path, not scripted `choose:`; on an
422
+ options-bearing gate a bare out-of-set LLM answer fails loud (exit 2) — see the LLM-decider
423
+ free-text note in `references/fidelity-and-answers.md`).
409
424
  14. **A positional `choose` (`first` / index) is order-dependent.** `choose: "2"` survives label drift
410
425
  but NOT option *re-ordering* — if the gate presents its options in a different order run-to-run, the
411
426
  index lands on a different option (a silent re-record flake). Prefer an exact label when order is
@@ -444,7 +459,8 @@ and the CI recipe, read the files in `references/`.
444
459
  - `references/scenario-schema.md` — scenario/session YAML schema, full assertion catalog (with each
445
460
  key's replay class), the web_fetch model, and the complete gotcha list.
446
461
  - `references/fidelity-and-answers.md` — fidelity tiers, answer paths, the determinism contract.
447
- - `references/ci-recipe.md` — replay-vs-live lane split and the four-stage GitHub Actions pipeline.
462
+ - `references/ci-recipe.md` — the packaged GitHub Action, replay-vs-live lane split, and the four-stage
463
+ GitHub Actions pipeline.
448
464
  - `scripts/scenario.py` — `scaffold` a valid scenario skeleton and `lint` scenarios for the
449
465
  no-silent-false-green invariants (both usable as CI steps).
450
466
  - `../../../docs/run-status.md` — checking a background run's status without `ps aux`.
@@ -1,12 +1,59 @@
1
1
  # CI recipe — replay vs live lanes
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 0.21.0` (baseline `desktop-1.17377.2`).
3
+ Self-contained reference. Tracks `cowork-harness 0.23.0` (baseline `desktop-1.18286.0`).
4
4
 
5
- **Minimal token-free PR gate** — the smallest thing worth committing; runs on stock GitHub-hosted runners,
6
- no token/Docker/agent:
5
+ **Fastest path: the packaged Action.** One step gets you `replay`/`lint`/`verify-cassettes` plus a PR
6
+ job-summary reporter (verdict table, staleness findings, cost/turns when available):
7
7
 
8
8
  ```yaml
9
- - run: npm i -g cowork-harness@>=0.21.0
9
+ - uses: yaniv-golan/cowork-harness@v1
10
+ with:
11
+ command: replay
12
+ path: cassettes/
13
+ ```
14
+
15
+ Reach for the manual multi-step form below only when you need per-step control the Action's inputs don't
16
+ cover (a custom flag combination, a different runner matrix per step, or `lint`/`verify-cassettes` gated
17
+ independently instead of as one action run per command).
18
+
19
+ **Live lane (`command: run`) with the packaged Action — self-hosted runner, you stage the binary.** The
20
+ Action never downloads or provisions the agent ELF itself — by design, not an oversight (see "the
21
+ realistic CI shape" below for why). Stage it yourself as an explicit step in *your own* workflow, then
22
+ point the Action at it:
23
+
24
+ ```yaml
25
+ jobs:
26
+ live:
27
+ runs-on: [self-hosted, linux, arm64] # needs Docker + this staged ELF; not a stock GitHub-hosted runner
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - name: Stage the agent binary (official channel, sha256-verified — see docs/maintenance.md)
31
+ run: |
32
+ V=2.1.197 # match your scenario's pinned baseline's agentVersion
33
+ curl -fSL "https://downloads.claude.ai/claude-code-releases/$V/linux-arm64/claude" -o "$RUNNER_TEMP/claude-$V"
34
+ chmod +x "$RUNNER_TEMP/claude-$V"
35
+ # verify against the committed baseline's sha256 (baselines/desktop-*.json → agentBinary.sha256)
36
+ # before trusting it — see docs/maintenance.md's "Agent-binary provenance" section.
37
+ echo "COWORK_AGENT_BINARY=$RUNNER_TEMP/claude-$V" >> "$GITHUB_ENV"
38
+ - uses: yaniv-golan/cowork-harness@v1
39
+ with:
40
+ command: run
41
+ path: scenarios/
42
+ anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
43
+ ```
44
+
45
+ Why this is a step *you* write, not an Action input the harness provides for you: pulling Anthropic's
46
+ binary is a call about your own relationship with their distribution terms — keeping it in your own
47
+ version-controlled workflow keeps that decision and its execution yours, auditable, and outside any
48
+ third-party action's code. See the [agent-binary provenance runbook](../../../../docs/maintenance.md) for
49
+ the full recovery/verification story (including why `COWORK_AGENT_BINARY` substitutions are
50
+ sha256-*checked* but not hard-blocking on mismatch — it's advisory for an intentional substitution).
51
+
52
+ **Minimal token-free PR gate (manual form)** — the smallest thing worth committing; runs on stock
53
+ GitHub-hosted runners, no token/Docker/agent:
54
+
55
+ ```yaml
56
+ - run: npm i -g "cowork-harness@>=0.23.0"
10
57
  - run: cowork-harness lint scenarios/*.yaml # no silent false-greens
11
58
  - run: cowork-harness verify-cassettes cassettes/ # privacy + staleness
12
59
  - run: cowork-harness replay cassettes/ # token-free content/structure
@@ -22,7 +69,8 @@ The split is not just about tokens — it decides **where each lane can run**:
22
69
  - **`replay` / `verify-cassettes` (token-free, agent-free).** Replays a recorded cassette
23
70
  (`events.jsonl` + `control-out.jsonl`) and lints the committed cassettes. **No model tokens, no
24
71
  Docker, no agent binary** — runs on a stock GitHub Actions runner. Evaluates **content** assertions
25
- only (`transcript_*`, `tool_*`, `subagent_*`, `dispatch_count_max`, `result`, and the verdict
72
+ only (`transcript_*`, `tool_*`, `subagent_*`, `dispatch_count_max`, `skill_triggered`,
73
+ `no_skill_triggered`, `max_cost_usd`, `max_tokens`, `tool_calls_max`, `result`, and the verdict
26
74
  modifiers `allow_permissive_auto_allow` / `allow_missing_capability` / `allow_l0_plugin_divergence` /
27
75
  `allow_stall` (no-op passes); plus the gate keys `question_asked` / `questions_count_max` /
28
76
  `gate_answers_delivered` **if** the cassette has `controlOut`). Filesystem/egress assertions are
@@ -80,8 +128,9 @@ dollar figures). In a skill repo these cassettes get **committed**. So:
80
128
  `_KEYS`); empty by default. Redaction is **verdict-preserving** — `record` refuses to write if
81
129
  redaction would flip an assertion (a manufactured green). `--no-redact` skips it for known-synthetic
82
130
  inputs.
83
- - **Always-on scan gate** — `verify-cassettes` flags email / currency / bare-domain / local-path matches
84
- it finds in the committed cassettes and **exits non-zero**, so "no leak" is a gate, not discipline.
131
+ - **Always-on scan gate** — `verify-cassettes` flags email / currency / bare-domain / local-path /
132
+ machine-inventory matches it finds in the committed cassettes and **exits non-zero**, so "no leak" is
133
+ a gate, not discipline.
85
134
  Suppress synthetic / public reference names (NVCA, Cooley GO, …) with `--allow <regex>`. (Multi-word
86
135
  proper names are NOT a default class — too noisy to gate on; add a pattern via config if your corpus
87
136
  needs it.)
@@ -110,6 +159,12 @@ A typical skill repo runs four stages, fastest/cheapest first:
110
159
 
111
160
  ## GitHub Actions sketch
112
161
 
162
+ The PR gate below is the manual, step-by-step version of what `uses: yaniv-golan/cowork-harness@v1` does
163
+ in one step (see the top of this doc) — reach for this form when you need independent per-command
164
+ gating/annotations rather than one action run per command. The nightly live job has no packaged-Action
165
+ equivalent yet (the Action's `command: run` mode needs a self-hosted runner with Docker + the agent binary
166
+ already provisioned, same as the manual form below).
167
+
113
168
  PR gate (token-free — runs on every push):
114
169
 
115
170
  ```yaml
@@ -124,7 +179,7 @@ jobs:
124
179
  with: { node-version: '20' }
125
180
  - uses: actions/setup-python@v5
126
181
  with: { python-version: '3.x' } # python3 only — PyYAML is bundled with the linter
127
- - run: npm i -g cowork-harness@>=0.21.0
182
+ - run: npm i -g "cowork-harness@>=0.23.0"
128
183
  - run: cowork-harness lint scenarios/*.yaml # no-silent-false-green (needs python3; PyYAML bundled)
129
184
  - run: cowork-harness verify-cassettes cassettes/ --output-format json # privacy + staleness gate
130
185
  - run: cowork-harness replay cassettes/ --output-format json # token-free content/structure
@@ -153,7 +208,7 @@ jobs:
153
208
  echo "live=true" >> "$GITHUB_OUTPUT"
154
209
  fi
155
210
  - if: steps.guard.outputs.live == 'true'
156
- run: npm i -g cowork-harness@>=0.21.0
211
+ run: npm i -g "cowork-harness@>=0.23.0"
157
212
  - if: steps.guard.outputs.live == 'true'
158
213
  run: cowork-harness run scenarios/ --output-format json
159
214
  env:
@@ -1,6 +1,6 @@
1
1
  # Fidelity tiers & answer paths
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 0.21.0` (baseline `desktop-1.17377.2`).
3
+ Self-contained reference. Tracks `cowork-harness 0.23.0` (baseline `desktop-1.18286.0`).
4
4
 
5
5
  ## Fidelity tiers (`fidelity:` in the scenario)
6
6
 
@@ -187,5 +187,6 @@ and `scaffold` refuse to treat a partial run's half-finished output as a passing
187
187
  redact from logs (beyond the auth tokens + `ANTHROPIC_CUSTOM_HEADERS`).
188
188
  - `COWORK_HARNESS_SOFT_MISSING` — downgrade a missing mount source from hard-error to warn-and-skip.
189
189
  - `COWORK_VM_GATEWAY` / `COWORK_VM_PROXY_PORT` / `COWORK_LIMA_INSTANCE` — L2 (microVM) knobs.
190
- - `COWORK_LOCKDOWN` — default `on`: **aborts loudly** if the L2 guest firewall fails to apply (no
191
- silent unprotected run). Set `=off` to opt out and run without isolation deliberately.
190
+ - `COWORK_LOCKDOWN` — default `on`: gates sandbox hardening on every isolated tier — **aborts loudly**
191
+ if the L2 guest firewall fails to apply (no silent unprotected run), and also gates the
192
+ `container`/`hostloop` Docker hardening. Set `=off` to opt out and run without isolation deliberately.
@@ -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.21.0`
4
- (baseline `desktop-1.17377.2`). If your checkout is newer, prefer the live `docs/scenario.md`,
3
+ Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 0.23.0`
4
+ (baseline `desktop-1.18286.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:
@@ -61,6 +61,12 @@ skills: [report-gen] # OPTIONAL — scope the cassette-staleness
61
61
  # `skills/<name>` dir under a mounted plugin-root) + the plugin's shared
62
62
  # roots. Fail-closed to whole-tree on an unknown name. Omit = whole tree.
63
63
 
64
+ requires_capabilities: [ocr] # OPTIONAL — declare a capability the skill needs (e.g. office_convert,
65
+ # ocr, pdf_tables, opencv). If the running agent image provably omits
66
+ # one, the harness ABORTS before the paid run (exit 3) — unless the
67
+ # scenario also asserts `allow_missing_capability: true`, which downgrades
68
+ # the abort to a notice and proceeds. Live tiers only.
69
+
64
70
  allow_host_writes: true # OPTIONAL — required to run `hostloop` fidelity with a WRITABLE
65
71
  # connected folder (session `mode: rw`/`rwd`): with no container
66
72
  # around hostloop's native file tools, that combination gives the
@@ -80,8 +86,10 @@ session = your setup.**
80
86
  ```yaml
81
87
  # model & reasoning
82
88
  model: claude-opus-4-8 # omit for the agent default
89
+ account_name: my-account # OPTIONAL — which configured account/credential set to run as
83
90
  effort: high # low | medium | high | xhigh
84
91
  max_thinking_tokens: 31999 # positive int, or per-model map {default, <model>: <n>}; default 31999
92
+ extended_thinking: true # INERT — not a real Cowork toggle; use max_thinking_tokens instead
85
93
  permission_mode: default # default | acceptEdits | plan | bypassPermissions
86
94
  permission_parity: cowork # cowork (unscripted tool calls allowed) | strict (deny unscripted)
87
95
 
@@ -97,8 +105,9 @@ plugins:
97
105
  marketplaces: [] # plugin_marketplaces (git URLs or local paths)
98
106
  local_marketplaces: [] # local marketplace dirs (each has a marketplace.json)
99
107
  enabled: [my-skill@local] # enabledPlugins (name@marketplace)
100
- local_plugins: [./skills/my-skill] # host plugin dirs → mnt/.local-plugins/marketplaces/<marketplace>/<plugin>
101
- # (≥1.14271.0; older baselines use mnt/.local-plugins/cache)
108
+ local_plugins: [./skills/my-skill] # host plugin dirs → mnt/.local-plugins/marketplaces/local-desktop-app-uploads/<plugin>
109
+ # (the marketplace segment is that fixed synthetic name; ≥1.14271.0 —
110
+ # older baselines use mnt/.local-plugins/cache)
102
111
  remote_plugins: []
103
112
  skills:
104
113
  local: [] # extra host skill dirs
@@ -220,6 +229,12 @@ passes only if every key passes. Keep one concern per item unless you mean conju
220
229
  | `subagent_dispatched: <regex>` | a sub-agent whose `agentType` **or dispatch description** matches |
221
230
  | `subagent_declared_but_unused: <Tool>` | a sub-agent declared the tool but never used **that** tool (even if it used others) |
222
231
  | `dispatch_count_max: <N>` | at most N sub-agents dispatched (records only — does NOT enforce a cap) |
232
+ | `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 |
233
+ | `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 |
234
+ | `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` |
235
+ | `max_tokens: <N>` | `usage.input_tokens + usage.output_tokens` ≤ N (cache tokens excluded) — same replay caveat as `max_cost_usd` |
236
+ | `tool_calls_max: <N>` | total top-level tool calls (sum of `toolCounts`) ≤ N — meaningfully replay-checkable (re-drive recomputes `toolCounts` deterministically) |
237
+ | `max_turns: <N>` | the SDK-reported (or fallback-counted) turn count ≤ N — meaningfully replay-checkable (re-drive recounts turns deterministically, same as `tool_calls_max`) |
223
238
  | `question_asked: <regex>` | the agent asked an AskUserQuestion whose text matches |
224
239
  | `questions_count_max: <N>` | the agent asked at most N questions |
225
240
  | `gate_answers_delivered: true` | every answered gate's answer reached the model (observed `tool_result`; unobserved = fail) |
@@ -228,10 +243,11 @@ passes only if every key passes. Keep one concern per item unless you mean conju
228
243
  | `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 |
229
244
  | `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 |
230
245
  | `allow_stall: true` | verdict modifier — suppresses the `stalled` default-fail when a run ends on a question having done no productive tool work after its last gate (the agent asked for input and stopped — incl. re-asking in plain text after answering an `AskUserQuestion`); assert only when ending on a question is intended, else script the answer (`answer:` / `--answer` / a decider) |
231
- | `transcript_no_host_path: true` | no host path (`/Users/`, `/opt/cowork/`, `/home/`, `/root/`) leaked into model-visible text — **incompatible with `hostloop`**: its native file tools legitimately expose real host paths, so this fails BY DESIGN there (the harness warns at run start if asserted anyway); use `container`/`microvm` for this check |
246
+ | `transcript_no_host_path: true` | no host path (`/Users/`, `/opt/cowork/`, `/home/`, `/root/`) leaked into model-visible text — **incompatible with `hostloop` AND `protocol`**: hostloop's native file tools legitimately expose real host paths, and protocol (L0) runs the agent's file tools on the real host cwd with no sealed filesystem, so this fails BY DESIGN on both (the harness warns at run start if asserted anyway); use `container`/`microvm` for this check |
232
247
  | `egress_denied: <host>` | the host was blocked by the egress proxy |
233
248
  | `egress_allowed: <host>` | the host was allowed through |
234
249
  | `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) |
250
+ | `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). Zero links in the transcript **passes** (presence-gated separately — pair with `transcript_contains` if you also need a link to show up). **Only `true` is valid** (`false` is rejected by the schema) |
235
251
 
236
252
  `expect_denied: [host, …]` adds one `egress_denied` per host. Run `cowork-harness assertions --list` for this
237
253
  table from the live schema. Example: `artifact_json: { artifact: outputs/cap.json, path: me.run_id, equals: "r1" }`.
@@ -270,21 +286,26 @@ sourcing ≠ evaluation (replay warns when you edit one). `verify-run` is the on
270
286
  *run dir*; `--assert-from` is the equivalent for a *cassette*.
271
287
 
272
288
  **Evaluated on replay (content):** `transcript_*`, `tool_*`, `subagent_*`, `dispatch_count_max`,
273
- `result`. The verdict modifiers `allow_permissive_auto_allow` / `allow_missing_capability` /
274
- `allow_l0_plugin_divergence` / `allow_stall` are also kept on replay, evaluated as no-op passes.
289
+ `skill_triggered`, `no_skill_triggered`, `max_cost_usd`, `max_tokens`, `tool_calls_max`, `max_turns`, `result`
290
+ (`max_cost_usd`/`max_tokens` assert the frozen recording's spend on replay, not fresh spend). The verdict
291
+ modifiers `allow_permissive_auto_allow` / `allow_missing_capability` / `allow_l0_plugin_divergence` /
292
+ `allow_stall` are also kept on replay, evaluated as no-op passes.
275
293
 
276
294
  **Gate keys — replay only with a `controlOut` cassette:** `question_asked`, `questions_count_max`,
277
295
  `gate_answers_delivered`. With `controlOut` present they evaluate; on an old cassette without it, a
278
296
  **loud warning** fires and they are **excluded** (not vacuously passed). Re-record to enable them.
279
297
 
280
298
  **Filesystem — replay-checkable WITH an artifact manifest:** `file_exists`, `user_visible_artifact`,
281
- `artifact_json` run on replay when the cassette carries an `artifacts` snapshot (`record` captures
282
- `outputs/` + connected folders; `replay` materializes it). `artifact_json` needs the small-file JSON `body`
283
- inlined; a hash-only entry still satisfies `file_exists`. Without a manifest (older cassettes) they're
284
- skipped. A green replay re-confirms *record-time* artifacts, not that the current skill still produces them
285
- — `replay --strict` fails when the staleness `fingerprint` shows ANY skill/baseline drift, or
286
- `replay --fail-on-skill-drift` only on skill-source drift; every replay result also reports it class-tagged
287
- in `staleness[]` for a JSON gate.
299
+ `artifact_json`, `computer_links_resolve` run on replay when the cassette carries an `artifacts` snapshot
300
+ (`record` captures `outputs/` + connected folders; `replay` materializes it). `artifact_json` needs the
301
+ small-file JSON `body` inlined; a hash-only entry still satisfies `file_exists`. `computer_links_resolve`
302
+ resolves a `/sessions/…/mnt/…`-shaped link directly against the manifest, and a host-shaped (hostloop) link
303
+ by first normalizing it to a mount-relative path (recorded connected-folder prefixes + the outputs/uploads
304
+ mounts) — replay has no live filesystem to check a host path against directly (that only happens on a live
305
+ `run`/`verify-run`). Without a manifest (older cassettes) all four are skipped. A green replay re-confirms
306
+ *record-time* artifacts, not that the current skill still produces them — `replay --strict` fails when the
307
+ staleness `fingerprint` shows ANY skill/baseline drift, or `replay --fail-on-skill-drift` only on
308
+ skill-source drift; every replay result also reports it class-tagged in `staleness[]` for a JSON gate.
288
309
 
289
310
  **Egress + other filesystem — still skipped on replay (live-only):** `no_delete_in_outputs`,
290
311
  `self_heal_ran`, `transcript_no_host_path`, `egress_*` / `expect_denied`. These run only on a live `run`/`record`.
@@ -360,7 +381,8 @@ at the top of this file.
360
381
  the stochastic path flags the run `nonDeterministic`. The LLM decider is one mechanism, two
361
382
  spellings: `on_unanswered: llm` (YAML) and `--decider-llm` (CLI). The bare `--on-unanswered llm`
362
383
  is rejected (use `--decider-llm`). `agent` is **retired** — `on_unanswered: agent` is rejected by
363
- the schema. (`src/types.ts:276`; `src/cli.ts:652`.)
384
+ the schema. (`src/types.ts:365` — the `on_unanswered` enum; `src/cli.ts:899` — the CLI-side
385
+ `--on-unanswered` value check.)
364
386
 
365
387
  4. **`--on-unanswered first` is non-deterministic too** — it picks option 1 and is flagged
366
388
  `nonDeterministic`; not a deterministic substitute for scripted answers.
@@ -6,22 +6,29 @@
6
6
  "allow_permissive_auto_allow",
7
7
  "allow_stall",
8
8
  "artifact_json",
9
+ "computer_links_resolve",
9
10
  "dispatch_count_max",
10
11
  "egress_allowed",
11
12
  "egress_denied",
12
13
  "file_exists",
13
14
  "gate_answers_delivered",
15
+ "max_cost_usd",
16
+ "max_tokens",
17
+ "max_turns",
14
18
  "no_delete_in_outputs",
19
+ "no_skill_triggered",
15
20
  "question_asked",
16
21
  "questions_count_max",
17
22
  "replay_protocol_fidelity",
18
23
  "result",
19
24
  "self_heal_ran",
25
+ "skill_triggered",
20
26
  "subagent_declared_but_unused",
21
27
  "subagent_dispatched",
22
28
  "subagent_tool_absent",
23
29
  "subagent_tool_used",
24
30
  "tool_called",
31
+ "tool_calls_max",
25
32
  "tool_not_called",
26
33
  "tool_result_contains",
27
34
  "tool_result_not_contains",
@@ -59,12 +59,20 @@ CONTENT_KEYS = {
59
59
  "subagent_dispatched",
60
60
  "subagent_declared_but_unused",
61
61
  "dispatch_count_max",
62
+ "skill_triggered",
63
+ "no_skill_triggered",
64
+ "max_cost_usd",
65
+ "max_tokens",
66
+ "tool_calls_max",
67
+ "max_turns",
62
68
  }
63
69
  # content keys, but only evaluated on replay when the cassette carries controlOut
64
70
  GATE_KEYS = {"question_asked", "questions_count_max", "gate_answers_delivered"}
65
71
  # manifest-backed: replay-checkable when the cassette carries an `artifacts` manifest (record snapshots one);
66
72
  # a manifest-less cassette skips them. Since the 0.3.0 artifact-manifest these are NOT always live-only.
67
- MANIFEST_KEYS = {"file_exists", "user_visible_artifact", "artifact_json"}
73
+ # computer_links_resolve joins this bucket (not CONTENT_KEYS): resolving a non-empty link set needs
74
+ # either a live filesystem or the cassette's artifacts manifest — see cassette.ts's manifestKeys comment.
75
+ MANIFEST_KEYS = {"file_exists", "user_visible_artifact", "artifact_json", "computer_links_resolve"}
68
76
  # live-only: ALWAYS skipped on replay, with a loud warning (no filesystem, no network on the token-free lane)
69
77
  LIVE_ONLY_KEYS = {
70
78
  "egress_denied",
@@ -118,8 +126,9 @@ if UNCLASSIFIED_KEYS:
118
126
  )
119
127
  # Embedded fallback for the top-level scenario keys — kept EQUAL to the generated `topLevelKeys`
120
128
  # (test-enforced, like _CLASSIFIED_KEYS for assert keys) so a missing assertion-keys.json can't silently
121
- # reintroduce key drift. `profile`/`assertions` are NOT here — they are deprecated aliases handled by the
122
- # special-cases in the unknown-key check below (`k != "assertions" and k != "profile"`).
129
+ # reintroduce key drift. `assertions` is NOT here — it's a hard error handled by its own special-case
130
+ # in the unknown-key check below (`k != "assertions"`). `profile` is retired vocabulary and now falls
131
+ # through to that same unknown-key check like any other typo — no special-case for it.
123
132
  _EMBEDDED_TOP_LEVEL_KEYS = {
124
133
  "name",
125
134
  "baseline",
@@ -252,7 +261,7 @@ def lint_doc(doc, path, raw_lines):
252
261
 
253
262
  # W: unknown top-level keys (typo or hallucinated schema)
254
263
  for k in doc:
255
- if k not in TOP_LEVEL_KEYS and k != "assertions" and k != "profile":
264
+ if k not in TOP_LEVEL_KEYS and k != "assertions":
256
265
  findings.append(
257
266
  Finding(
258
267
  "WARN",
@@ -418,19 +427,28 @@ def lint_doc(doc, path, raw_lines):
418
427
  _DQ_REGEX_LINE = re.compile(
419
428
  r'^\s*-?\s*(' + "|".join(sorted(REGEX_KEYS)) + r')\s*:\s*"([^"]*\\[^"]*)"'
420
429
  )
430
+ # A run of an EVEN number of consecutive backslashes in a double-quoted YAML scalar is a properly
431
+ # paired escape (`\\` -> a literal `\`), so e.g. "\\d+ items" decodes to the valid regex `\d+ items` —
432
+ # not a mistake. An ODD run leaves one backslash unpaired, which is the actual footgun (YAML either
433
+ # eats it or errors, depending on what follows). Only flag the odd case.
434
+ _ODD_BACKSLASH_RUN = re.compile(r"\\+")
435
+
436
+
437
+ def _has_unpaired_backslash(s):
438
+ return any(len(m.group(0)) % 2 == 1 for m in _ODD_BACKSLASH_RUN.finditer(s))
421
439
 
422
440
 
423
441
  def _lint_regex_quoting(path, raw_lines):
424
442
  out = []
425
443
  for i, line in enumerate(raw_lines, start=1):
426
444
  m = _DQ_REGEX_LINE.match(line)
427
- if m:
445
+ if m and _has_unpaired_backslash(m.group(2)):
428
446
  out.append(
429
447
  Finding(
430
448
  "WARN",
431
449
  "regex-double-quoted",
432
- f"`{m.group(1)}` uses a DOUBLE-quoted regex containing a backslash "
433
- f'("{m.group(2)}") — YAML strips the backslash, so the regex is wrong.',
450
+ f"`{m.group(1)}` uses a DOUBLE-quoted regex containing an unescaped backslash "
451
+ f'("{m.group(2)}") — YAML strips it, so the regex is wrong.',
434
452
  "Single-quote the regex (e.g. '\\d+') or use a block scalar. Use [\\s\\S] not . to span turns.",
435
453
  path,
436
454
  i,
package/AGENTS.md CHANGED
@@ -39,8 +39,8 @@ protocol layer or run-loop bookkeeping in the CLI.
39
39
  gate-steering silently no-ops (the O7 bug). ELF-verified; a regression test pins it.
40
40
  - **"profile" is retired vocabulary.** Synced release ground truth = **`PlatformBaseline`** (`baseline:` /
41
41
  `baselines/`); authored setup = **`SessionConfig`** (`session:` / `sessions/`). The `profile:`
42
- scenario key is retired vocabulary — still accepted as an alias for `baseline:` with a warning — do not
43
- reintroduce the term.
42
+ scenario key is retired vocabulary — the alias is gone and it is now rejected as an unknown key — do
43
+ not reintroduce the term.
44
44
  - **A new assertion must pick its replay class.** *Content* assertions (read only `ctx.transcript` / the
45
45
  record) go in `src/run/cassette.ts` `contentKeys` so they run on the token-free `replay` PR gate;
46
46
  *filesystem / egress* assertions do NOT — they are excluded **by omission** from `contentKeys`, so they