cowork-harness 1.25.0 → 2.0.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 (37) hide show
  1. package/.claude/skills/cowork-harness/SKILL.md +14 -8
  2. package/.claude/skills/cowork-harness/references/ci-recipe.md +50 -14
  3. package/.claude/skills/cowork-harness/references/critique.md +1 -1
  4. package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
  5. package/.claude/skills/cowork-harness/references/scenario-schema.md +3 -3
  6. package/.claude/skills/cowork-harness/references/task-recipes.md +1 -1
  7. package/CHANGELOG.md +296 -0
  8. package/CONTRIBUTING.md +12 -0
  9. package/DESIGN.md +2 -2
  10. package/README.md +9 -9
  11. package/SPEC.md +34 -15
  12. package/baselines/desktop-1.32885.1.json +1 -0
  13. package/baselines/desktop-1.34493.1.json +791 -0
  14. package/dist/cli.js +25 -5
  15. package/dist/run/cassette.js +930 -115
  16. package/dist/run/jcs.js +81 -0
  17. package/dist/run/skill-hash.js +182 -45
  18. package/dist/sync/baseline-diff.js +9 -0
  19. package/dist/sync/cowork-sync.js +172 -15
  20. package/dist/types.js +4 -1
  21. package/docs/cassette.md +172 -32
  22. package/docs/fidelity-gaps.md +22 -3
  23. package/docs/invariants.md +2 -1
  24. package/docs/maintenance.md +62 -8
  25. package/docs/scenario.md +1 -1
  26. package/docs/session.md +3 -2
  27. package/examples/replays/README.md +1 -1
  28. package/examples/replays/example-multiselect-gate.cassette.json +49 -44
  29. package/examples/replays/example-pdf-skill.cassette.json +84 -83
  30. package/examples/replays/hostloop-computer-links.cassette.json +70 -63
  31. package/package.json +1 -1
  32. package/schema/cassette.v10.json +1 -1
  33. package/schema/cassette.v11.json +1 -1
  34. package/schema/cassette.v12.json +318 -0
  35. package/schema/cassette.v9.json +21 -19
  36. package/schema/run-result.json +68 -284
  37. package/schema/verify-cassettes.json +6 -2
@@ -3,8 +3,8 @@ name: cowork-harness
3
3
  description: Test or debug a Claude Code skill/plugin under Claude Cowork's runtime — sandboxed agent, default-deny egress, the can_use_tool permission/question protocol — using the cowork-harness CLI. Use when validating or regression-testing a skill, authoring or debugging a scenario YAML (prompt + scripted answers + assert:), choosing a fidelity tier, scripting AskUserQuestion / tool-permission answers, or asserting artifacts, egress, or sub-agent dispatch. Especially when a harness run no-ops an assertion, fails on an unanswered gate, false-greens, a steered answer never reaches the model, or a web_fetch is unexpectedly denied or gated. Also when iterating or hardening a skill across fixes, or grounding a skill's self-critique against its own run evidence — including a document-analysis skill (cap table, deck, financial model, transcript) that needs an uploaded file attached to be critiqued at all. NOT for generic unit testing (pytest/vitest of your own scripts) or non-Cowork CI. Covers the skill / run / chat / record / replay / trace / decide / assertions / scaffold commands and the session-vs-scenario split.
4
4
  metadata:
5
5
  author: cowork-harness
6
- version: 1.25.0
7
- tracks-harness: cowork-harness 1.25.0 (baseline desktop-1.32885.1)
6
+ version: 2.0.0
7
+ tracks-harness: cowork-harness 2.0.0 (baseline desktop-1.34493.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 1.25.0` (baseline
26
- > `desktop-1.32885.1`). 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 2.0.0` (baseline
26
+ > `desktop-1.34493.1`). 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
@@ -39,7 +39,7 @@ Before the first command, confirm the CLI is reachable and **fail loud** (never
39
39
 
40
40
  - **One-shot check.** Run `cowork-harness doctor [--tier <tier>]` first — a read-only prerequisite check that inspects Docker, the staged agent, the token, and the baseline in one pass. The bullets below explain each thing it checks (and how to fix it).
41
41
  - **Replay-only? Skip `doctor`.** Replaying committed cassettes needs no Docker, no staged agent, and no token — and every tier's `doctor` validates the auth token (the live tiers also Docker + the staged agent), so a ✗ there is expected, not a blocker. Go straight to `cowork-harness replay <cassette>`.
42
- - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 1.25.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=1.25.0" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@>=1.25.0"`. **Pin `@>=1.25.0`, never `@latest`** — `@latest` can silently fetch an older CLI and the new commands fail as "unknown command", whereas the floor **fails loud** if no compatible version is published.
42
+ - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 2.0.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=2.0.0" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@>=2.0.0"`. **Pin `@>=2.0.0`, never `@latest`** — `@latest` can silently fetch an older CLI and the new commands fail as "unknown command", whereas the floor **fails loud** if no compatible version is published.
43
43
 
44
44
  This skill documents the CURRENT surface, not release history. If `cowork-harness --version` is
45
45
  OLDER than the floor, the per-release record of what you are missing is [CHANGELOG.md](https://github.com/yaniv-golan/cowork-harness/blob/main/CHANGELOG.md)
@@ -407,7 +407,7 @@ That choice is permanent: the cassette rewrites `scenario.session` and `scenario
407
407
  its own directory** at record time, so moving the file later — a different `--out`, a `git mv`, a copy
408
408
  into another repo — leaves those unresolvable and
409
409
  `verify-cassettes` reports `unverifiable-skill` ("can't verify ⇒ not green", exit 3) until you
410
- re-record at the new location. **`record` now says so BEFORE it spends:** a pre-flight — at the same
410
+ re-record at the new location — or point `replay`/`verify-cassettes` at the session with `--session <file>`, which resolves it without a re-record. Since 2.0.0 a bare `replay` FAILS on this class rather than warning. **`record` now says so BEFORE it spends:** a pre-flight — at the same
411
411
  pre-spend point as the host-inventory refusal, and in `record --dry-run`, so the rehearsal is free —
412
412
  warns when the cassette would be written outside the scenario's tree, or when `session:` itself lives
413
413
  outside it (an absolute or `~` path: the mirror case, invisible to a check that only looks at where the
@@ -1028,8 +1028,14 @@ repeats the assertion/replay-relevant ones alongside the schema (a scoped subset
1028
1028
  25. **Two distinct host-inventory consent flags — a record-time one and a verify-time one.** `record
1029
1029
  --allow-host-inventory-fixture` is the boolean consent to proceed recording a host-inheriting
1030
1030
  (`protocol`/`hostloop`/`cowork`-resolving-to-hostloop) cassette into a repo-visible path — otherwise
1031
- `record` refuses (freezing this machine's MCP servers/agents/account into a committed fixture is the
1032
- risk). `verify-cassettes --allow-host-inventory <regex>` is unrelated: a per-finding suppressor for the
1031
+ `record` refuses before it spends (freezing this machine's MCP servers/agents/account into a committed
1032
+ fixture is the risk). That pre-spend check **warns rather than refuses when the cassette already
1033
+ exists** — refusing would fire on every `--rerecord-stale` pass — and it reads the tier and the
1034
+ destination path, never the bytes. So `record` also scans the FINISHED recording, after redaction and
1035
+ before the write: a `host-inventory`/`machine-inventory` finding on a repo-visible path is
1036
+ **quarantined** to `<runs-root>/quarantine/` with a `.findings.txt` naming what leaked, and the command
1037
+ fails without writing the path you asked for (the recording is not discarded — you paid for it).
1038
+ `verify-cassettes --allow-host-inventory <regex>` is unrelated: a per-finding suppressor for the
1033
1039
  scanner's `host-inventory` class on an already-committed cassette. Passing one where the other command
1034
1040
  wants it fails as an unrecognized flag — they don't interchange. Depth: `references/ci-recipe.md`.
1035
1041
 
@@ -1,6 +1,6 @@
1
1
  # CI recipe — replay vs live lanes
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 1.25.0` (baseline `desktop-1.32885.1`).
3
+ Self-contained reference. Tracks `cowork-harness 2.0.0` (baseline `desktop-1.34493.1`).
4
4
 
5
5
  **Fastest path: the packaged Action.** One step gets you `replay`/`lint`/`verify-cassettes` plus a PR
6
6
  job-summary reporter (verdict table, staleness findings, cost/turns when available):
@@ -13,7 +13,7 @@ job-summary reporter (verdict table, staleness findings, cost/turns when availab
13
13
  ```
14
14
 
15
15
  The Action's `version` input defaults to `latest` — intentional so a copy-pasted recipe tracks the current
16
- release; pin an exact version (e.g. `version: "1.25.0"`) for reproducible CI.
16
+ release; pin an exact version (e.g. `version: "2.0.0"`) for reproducible CI.
17
17
 
18
18
  Reach for the manual multi-step form below only when you need per-step control the Action's inputs don't
19
19
  cover (a custom flag combination, a different runner matrix per step, or `lint`/`verify-cassettes` gated
@@ -32,7 +32,7 @@ jobs:
32
32
  - uses: actions/checkout@v4
33
33
  - name: Stage the agent binary (official channel, sha256-verified — see https://github.com/yaniv-golan/cowork-harness/blob/main/docs/maintenance.md)
34
34
  run: |
35
- V=2.1.234 # match your scenario's pinned baseline's agentVersion
35
+ V=2.1.237 # match your scenario's pinned baseline's agentVersion
36
36
  curl -fSL "https://downloads.claude.ai/claude-code-releases/$V/linux-arm64/claude" -o "$RUNNER_TEMP/claude-$V"
37
37
  chmod +x "$RUNNER_TEMP/claude-$V"
38
38
  # verify against the committed baseline's sha256 (baselines/desktop-*.json → agentBinary.sha256)
@@ -58,7 +58,7 @@ sha256-*checked* but not hard-blocking on mismatch — it's advisory for an inte
58
58
  GitHub-hosted runners, no token/Docker/agent:
59
59
 
60
60
  ```yaml
61
- - run: npm i -g "cowork-harness@>=1.25.0"
61
+ - run: npm i -g "cowork-harness@>=2.0.0"
62
62
  - run: cowork-harness lint scenarios/*.yaml --strict --min-severity WARN
63
63
  # no silent false-greens. WITHOUT --strict this
64
64
  # step cannot fail on a WARN-class rule (e.g.
@@ -77,7 +77,20 @@ GitHub-hosted runners, no token/Docker/agent:
77
77
  - run: cowork-harness replay cassettes/ # token-free content/structure
78
78
  ```
79
79
 
80
- Both lines matter: `replay` alone **warns** on a stale recording and exits 0, so dropping the
80
+ If a cassette has MOVED (a `git mv`, a repo reorg, a copy between projects), staleness becomes
81
+ unverifiable — `verify-cassettes` exits 3 and says the skill dirs are not resolvable. Recover with
82
+ `--session <file>` on either command rather than re-recording:
83
+
84
+ ```yaml
85
+ - run: cowork-harness verify-cassettes cassettes/moved.cassette.json --session sessions/default.yaml
86
+ ```
87
+
88
+ It takes a session (not skill dirs) so `staleness.hash_ignore` survives, refuses a directory target,
89
+ and echoes the dirs it resolved. Full contract:
90
+ [docs/cassette.md](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/cassette.md).
91
+
92
+ Both lines matter: for the CONTENT-drift classes (`skill`, `shared-root`) `replay` alone **warns** and
93
+ exits 0, so dropping the
81
94
  `verify-cassettes` step means a skill edit silently stops being tested. (One command instead of two:
82
95
  `replay --fail-on-skill-drift`.)
83
96
 
@@ -210,9 +223,23 @@ dollar figures). In a skill repo these cassettes get **committed**. So:
210
223
  - **Host-inheriting record refused by default — `--allow-host-inventory-fixture` is the consent.** A
211
224
  `protocol`/`hostloop`/`cowork`-resolving-to-hostloop record into a repo-visible cassette path would
212
225
  freeze THIS machine's MCP server names, agents, and account metadata into a committed fixture, so
213
- `record` refuses outright rather than warn. Pass `--allow-host-inventory-fixture` only when the
226
+ `record` refuses **before the paid spawn**. Pass `--allow-host-inventory-fixture` only when the
214
227
  recording session genuinely has no personal MCP servers or plugins to leak — it is a per-record
215
228
  boolean consent, not a pattern.
229
+
230
+ Two details that matter for a re-record loop. The pre-spend check **warns rather than refuses when the
231
+ cassette already exists**, deliberately: refusing there would fire on every `--rerecord-stale` pass and
232
+ make the escape flag reflexive. And it is a **prediction** — it reads the tier and the destination path,
233
+ never the resulting bytes, so it can be wrong in both directions.
234
+
235
+ So `record` also checks the **evidence**. After redaction and before the write, the finished cassette is
236
+ scanned; a `host-inventory` or `machine-inventory` finding on a repo-visible path is **quarantined** —
237
+ written to `<runs-root>/quarantine/` (honouring `--run-dir`/`COWORK_HARNESS_RUNS_DIR`) with a
238
+ `.findings.txt` sibling naming what leaked, and the command fails without writing the path you asked for.
239
+ The recording is not discarded; you paid for it. Only the machine-identity classes trigger this —
240
+ `email`/`currency`/`domain`/`path` are frequently legitimate scenario content, and a gate that fires on
241
+ those just teaches you to pass the escape flag. Outside a git repo it warns instead: nothing there
242
+ publishes the file by accident.
216
243
  - **Always-on scan gate** — `verify-cassettes` flags email / currency / bare-domain / local-path /
217
244
  machine-inventory matches it finds in the committed cassettes and **exits non-zero**, so "no leak" is
218
245
  a gate, not discipline. Non-zero is not one thing, though: exit `1` means verification RAN and found a
@@ -222,6 +249,13 @@ dollar figures). In a skill repo these cassettes get **committed**. So:
222
249
  $? -ne 0 ]` tripwire treats both the same — if you need to tell "the gate caught something" apart from
223
250
  "the gate couldn't run", branch on the exit code (or parse `--output-format json`'s per-file
224
251
  `findings`/`staleness` vs `unverifiable`/`version`/`error` buckets).
252
+
253
+ **Do not read `error` as "this file was never scanned".** The privacy scan needs a readable *transcript*
254
+ (an `events` array of strings), not a *valid* cassette — so a file that fails shape validation is still
255
+ scanned, and reports its findings **and** its `error`. Each result carries **`privacyScanned`**, which
256
+ answers that question directly. A gate that must not treat "could not verify" as "verified clean" should
257
+ key on `privacyScanned === false`, where `findings: []` is an absence of evidence rather than evidence of
258
+ absence. `--skip-privacy` also reports `false`, for the same reason.
225
259
  Suppress synthetic / public reference names (NVCA, Cooley GO, …) with `--allow <regex>`. (Multi-word
226
260
  proper names are NOT a default class — too noisy to gate on; add a pattern via config if your corpus
227
261
  needs it.)
@@ -289,7 +323,7 @@ jobs:
289
323
  with: { node-version: '24' }
290
324
  - uses: actions/setup-python@v5
291
325
  with: { python-version: '3.x' } # python3 only — PyYAML is bundled with the linter
292
- - run: npm i -g "cowork-harness@>=1.25.0"
326
+ - run: npm i -g "cowork-harness@>=2.0.0"
293
327
  - run: cowork-harness lint scenarios/*.yaml # no-silent-false-green (needs python3; PyYAML bundled)
294
328
  - run: cowork-harness verify-cassettes cassettes/ --output-format json # privacy + staleness gate
295
329
  - run: cowork-harness replay cassettes/ --output-format json # token-free content/structure
@@ -318,7 +352,7 @@ jobs:
318
352
  echo "live=true" >> "$GITHUB_OUTPUT"
319
353
  fi
320
354
  - if: steps.guard.outputs.live == 'true'
321
- run: npm i -g "cowork-harness@>=1.25.0"
355
+ run: npm i -g "cowork-harness@>=2.0.0"
322
356
  - if: steps.guard.outputs.live == 'true'
323
357
  run: cowork-harness run scenarios/ --output-format json
324
358
  env:
@@ -406,19 +440,21 @@ evaluation), not present-and-passing. A CI script that counts assertions will se
406
440
  on replay vs live — compare by assertion identity / pass-fail, not by total count. The count of
407
441
  skipped live-only assertions is reported on each replay result as `skippedAssertions: {full, partial}`.
408
442
 
409
- ## Staleness does NOT fail a replay by default — read it from the JSON
443
+ ## Staleness mostly does NOT fail a replay — read it from the JSON
410
444
 
411
- A plain `replay` **warns** on a stale cassette (skill/baseline drift) but stays `ok:true` — a green replay
412
- does **not** imply the recording is still valid. Each replay result carries `staleness[]`, an array of
445
+ A plain `replay` **warns** on a DRIFTED cassette (skill/baseline drift) but stays `ok:true` — a green
446
+ replay does **not** imply the recording is still valid. **Since 2.0.0 there is one exception:**
447
+ `unverifiable-skill` — staleness that could not be checked at all, most often a cassette that moved —
448
+ FAILS a bare `replay`. Recover with `--session <file>` rather than re-recording. Each replay result carries `staleness[]`, an array of
413
449
  `{class, message}`, so a token-free gate can act on it without `ok` being the whole story:
414
450
 
415
451
  | `class` | meaning | concern |
416
452
  |---|---|---|
417
453
  | `baseline` | platform baseline moved since record | low (format-compatible) |
418
454
  | `skill` / `shared-root` | the skill source the assertions validate drifted | **high** (assertions may validate dead code) |
419
- | `format` | recorded under an older hash format | re-record once |
455
+ | `format` | the git/raw file-set mode or agent-scope differs from the recording | re-record under the same setting (waivable) |
420
456
  | `unverifiable-baseline` | the latest baseline couldn't be loaded | couldn't verify (env, not skill) |
421
- | `unverifiable-skill` | skill dirs unresolvable — skill staleness couldn't be checked | couldn't verify the skill |
457
+ | `unverifiable-skill` | skill dirs unresolvable, **or** the cassette predates the hash-format epoch (v12) so its digest is not comparable | couldn't verify the skill. **Fails a bare `replay`.** For the epoch case try `rehash` first — it migrates without a re-record where it can prove the content unchanged (`rehash <file> --session <s.yaml>` if the cassette moved) |
422
458
  | `resolved-tier` | a `fidelity: cowork` cassette's recorded `effectiveFidelity` no longer matches what the baseline resolves to today (the host-loop gate flipped) | **high** (the recording exercises the wrong tier) |
423
459
  | `unverifiable-tier` | tier check couldn't run for a `fidelity: cowork` cassette (no recorded `effectiveFidelity`, or its pinned baseline failed to load) | couldn't verify the tier — re-record |
424
460
 
@@ -433,7 +469,7 @@ fail it either way.)
433
469
  To gate in CI, pick the severity you want:
434
470
 
435
471
  - `replay --strict` — fail (exit 1) on **any** staleness class.
436
- - `replay --fail-on-skill-drift` — fail only on skill-source drift (`skill` / `shared-root` / `unverifiable-skill`);
472
+ - `replay --fail-on-skill-drift` — fail on the skill-source DRIFT classes (`skill` / `shared-root`); `unverifiable-skill` needs no flag, it fails the default verdict since 2.0.0;
437
473
  baseline / format / `unverifiable-baseline` stay non-failing warnings.
438
474
  Note `--allow-failing` waives this gate wholesale, including the copy `--assert-from` turns on for you:
439
475
  `replay --assert-from … --write --allow-failing` will persist an assert block validated against a
@@ -1,6 +1,6 @@
1
1
  # Critique — the facts a plugin install can't otherwise reach
2
2
 
3
- Tracks `cowork-harness 1.25.0` (baseline `desktop-1.32885.1`). This is **not** a trim of the full
3
+ Tracks `cowork-harness 2.0.0` (baseline `desktop-1.34493.1`). This is **not** a trim of the full
4
4
  [`docs/critique.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/critique.md) (repo-only —
5
5
  flags, cost, reproduction discipline, known limitations all live there). This file covers exactly what a
6
6
  plugin install cannot otherwise discover: the run-dir artifact a harvester actually reads, the report's
@@ -1,6 +1,6 @@
1
1
  # Fidelity tiers & answer paths
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 1.25.0` (baseline `desktop-1.32885.1`).
3
+ Self-contained reference. Tracks `cowork-harness 2.0.0` (baseline `desktop-1.34493.1`).
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 1.25.0`
4
- (baseline `desktop-1.32885.1`). If your checkout is newer, prefer the live [`docs/scenario.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/scenario.md),
3
+ Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 2.0.0`
4
+ (baseline `desktop-1.34493.1`). If your checkout is newer, prefer the live [`docs/scenario.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/scenario.md),
5
5
  [`docs/session.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/session.md), and `SPEC.md`.
6
6
 
7
7
  **Minimal scenario** — `prompt` is the only required field:
@@ -107,7 +107,7 @@ form a relocatable bundle. `~` expands to home.
107
107
  > references relative to **its own** directory at record time (`scenario.session` and
108
108
  > `scenarioSource`), so moving it afterwards — a different `--out`, a
109
109
  > `git mv`, a copy into another repo — leaves them unresolvable and `verify-cassettes` reports
110
- > `unverifiable-skill` ("can't verify ⇒ not green", exit 3) until you re-record at the new location.
110
+ > `unverifiable-skill` ("can't verify ⇒ not green", exit 3) until you re-record at the new location, or pass `--session <file>`.
111
111
  > Decide where a cassette will live *before* you record it.
112
112
 
113
113
  ## Session YAML
@@ -2,7 +2,7 @@
2
2
 
3
3
  Each recipe composes facts that live scattered across SKILL.md and the other references into one
4
4
  decision path. Every one answers a question a real fleet owner had to work out the hard way.
5
- Tracks `cowork-harness 1.25.0` (baseline `desktop-1.32885.1`), same as SKILL.md's front-matter. Recipe 2's `resolved-tier`/`unverifiable-tier` staleness classes and
5
+ Tracks `cowork-harness 2.0.0` (baseline `desktop-1.34493.1`), same as SKILL.md's front-matter. Recipe 2's `resolved-tier`/`unverifiable-tier` staleness classes and
6
6
  Recipe 3's `init-redact` shipped in 0.24.0 and are part of the current feature set — no version gate
7
7
  needed if your CLI meets SKILL.md's version floor.
8
8
 
package/CHANGELOG.md CHANGED
@@ -6,6 +6,302 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.0.0] — 2026-08-21
10
+
11
+ ### Changed — BREAKING (requires a major bump; see [SPEC.md §12](./SPEC.md#12-versioning--the-10-compatibility-contract))
12
+
13
+ - **HASH-FORMAT EPOCH — `cassetteVersion` 12. Every cassette carrying a `skillHash` fails a bare `replay`
14
+ until it is migrated.** Read that sentence literally: this is not a warning you can defer.
15
+
16
+ **Migrate with one command.** `cowork-harness rehash <dir/>` proves each cassette's content unchanged and
17
+ relabels it in place — no re-record, no model calls, no cost. For a cassette that has MOVED (a `git mv`, a
18
+ repo reorg, a copy into another project) its skill sources cannot be resolved from its own directory, so
19
+ use `cowork-harness rehash <file.cassette.json> --session <session.yaml>`. Anything `rehash` cannot prove
20
+ is refused rather than migrated, and does need a real re-record.
21
+
22
+ **After a successful `rehash`, the epoch is cleared and you are done with it.** Migration does not touch
23
+ the other staleness classes, and it does not need to: `baseline`, `format` and `prompt-assets` behave
24
+ exactly as before — they surface in `staleness[]`, print a `::warning::`, and **exit 0**. Only
25
+ `unverifiable-skill` fails a bare `replay`, which is what the epoch produces and what `rehash` clears. So
26
+ a cassette that also carries, say, baseline drift migrates cleanly and then goes green with that drift
27
+ still reported as a warning, exactly as before this release. (Measured, not inferred: a cassette with a
28
+ deliberately wrong `fingerprint.baseline` replays `pass: true`, exit 0, with a non-failing `[baseline]`
29
+ finding.)
30
+
31
+ **`verify-cassettes` is stricter than `replay`, and that is not new.** It treats ANY staleness finding as
32
+ not-green, so a migrated cassette that still carries baseline drift replays green and reds the
33
+ verification gate — as it did before this release, for the same reason. Clear it by re-recording, or by
34
+ re-stamping `fingerprint.baseline` where the baseline moved without changing anything the recording
35
+ exercises; [docs/cassette.md](./docs/cassette.md#cassette-versioning) covers when that re-stamp is honest
36
+ and when it is not. This release also ships a new platform baseline, so expect that drift on cassettes
37
+ recorded against the previous one.
38
+
39
+ **What changed.** A plugin manifest now folds into `skillHash`/`contentSig` through canonical (JCS-style)
40
+ serialization instead of insertion-order `JSON.stringify`, so **reordering keys in `plugin.json` no longer
41
+ re-stales every cassette that hashes it** — semantically identical input now produces an identical digest.
42
+ `contentSig` additionally folds the directory markers `skillHash` has always folded, so an added or removed
43
+ **empty directory** is finally visible to it; `CONTENTSIG_ALGO` moves 4 → 5 to say so. A new
44
+ `fingerprint.hashFormat` records which transform produced the digests — **absent means the legacy
45
+ pre-v12 transform, never raw bytes**, since every cassette already on disk carries version-stripped
46
+ manifest digests.
47
+
48
+ **Why it fails rather than warns.** A pre-epoch digest and a post-epoch digest came from different
49
+ algorithms; they cannot be compared at all. That is `unverifiable-skill` — "this was not verified" — and
50
+ not `format`, which is waivable and exits 0. A warning everyone ignores for a release is exactly the
51
+ green-against-unverified state the strict-replay change below exists to end, and on the day of an epoch it
52
+ would apply to every cassette in existence.
53
+
54
+ **Some cassettes will migrate with an unchanged digest.** A manifest whose keys were already in canonical
55
+ order, or a tree with no manifest at all, hashes identically under both algorithms. Those are still
56
+ flagged and migrated, **by recorded version rather than by whether the number moved** — otherwise they
57
+ would pass unlabelled, and at the next epoch nobody could tell which algorithm produced them.
58
+
59
+ `cassetteVersion` now means the minimum reader for the **whole cassette**, not just for its `scenario`
60
+ keys: a v11 reader handed a v12 cassette would recompute legacy digests and report drift that is not
61
+ there. The scenario-aware differential still applies above that floor.
62
+
63
+ - **A bare `replay` now FAILS when skill staleness cannot be verified** (`unverifiable-skill`), where it
64
+ previously warned on stderr, recorded the class in `staleness[]`, and exited `0`. "Could not be checked
65
+ at all" and "checked and unchanged" are different claims and only the second is green — a cassette that
66
+ had silently stopped proving anything kept passing the lane most people run, and green-against-unverified
67
+ is worse than a loud red because silence prompts a re-record while green does not. The major is required because a previously-green
68
+ input now exits non-zero — `verdict.pass` / `ok` and the per-command exit behaviour are covered surfaces
69
+ ([SPEC.md §12](./SPEC.md#12-versioning--the-10-compatibility-contract)). Note the *meaning* of exit 1 is
70
+ unchanged (still assertion-or-agent failure), so §11's exit-code table needs no edit; what changed is
71
+ which inputs reach it.
72
+
73
+ **Deliberately narrow.** The content-drift classes (`skill`, `shared-root`) still require
74
+ `--fail-on-skill-drift`, so that flag keeps its meaning and no inverse escape hatch is needed.
75
+
76
+ **One exception, and it exists to stop a false green:** under an explicit `--session`, the drift classes
77
+ are escalated too. Without that, pointing `--session` at a WRONG but resolvable tree would turn
78
+ `unverifiable-skill` (a hard fail) into ordinary `skill` drift (warn-only) — so the flag could be used,
79
+ accidentally, to silence the very gate it exists to help you escape. `--session` is an escape from
80
+ "cannot verify", never from "verified, and it changed".
81
+
82
+ **Migration.** The commonest cause is a cassette that MOVED — use the new `--session <file>` below
83
+ rather than re-recording. If you genuinely want the old behaviour for a lane, it was never expressible
84
+ as a flag and still isn't; fix the resolution instead.
85
+
86
+ - **`verify-cassettes --output-format json` gained a REQUIRED `privacyScanned` field on every result.**
87
+ `schema/verify-cassettes.json` is a covered 1.0 surface ([SPEC.md §12](./SPEC.md#12-versioning--the-10-compatibility-contract)),
88
+ and `privacyScanned` is now in its `required` list — so a consumer validating this build's output against
89
+ an older copy of the schema is unaffected, but one validating an OLDER CLI's output against the NEW schema
90
+ will fail. It is emitted on every return path, so anything keying on it can rely on it.
91
+
92
+ The field exists because `error` became ambiguous. A cassette that fails SHAPE validation is now still
93
+ privacy-scanned (see Fixed, below), so `error` covers both "never scanned" and "scanned fine, just not
94
+ replayable" — and a gate cannot tell those apart from it. **If you have CI keyed on `error` meaning "this
95
+ file was not checked", that reading is now wrong; switch to `privacyScanned === false`,** where
96
+ `findings: []` is an absence of evidence rather than evidence of absence. `--skip-privacy` also reports
97
+ `false`, for the same reason.
98
+
99
+ ### Added
100
+
101
+ - **`provenance.asarGateIds` — a gate-membership change is now nameable.** A baseline recorded
102
+ `provenance.fcache` as two aggregates and a timestamp, so when a sync moved `featureCount` **271 → 278**
103
+ nothing could say *which* seven arrived, a count-neutral membership swap was invisible entirely, and
104
+ `content16`'s diff line had to hedge "membership **and/or** values moved". The fcache is also
105
+ server-refreshed on its own schedule (3.7–20.8 min observed), so a count delta between two baselines is
106
+ a net over days of rollout rather than a fact about the Desktop release — and the previous payload is
107
+ overwritten in place, so the question goes unanswerable the moment you think to ask it.
108
+
109
+ Baselines now additionally record the gate ids **this release's own bundle references**, sorted. That is
110
+ a pure function of the shipped asar: reproducible by anyone, stable across the fcache's refetches, and
111
+ attributable to the release. Diffing two baselines names the delta outright (1.32885.1 → 1.34493.1:
112
+ **+14 / −1**), and `sync --diff` prints the ids rather than a count.
113
+
114
+ It is deliberately **not** intersected with the syncing machine's fcache. Gate membership varies by
115
+ account segment, so filtering through one machine would both leak which gates that operator is served
116
+ and drop DARK gates — 51 of the recorded ids are absent from the live fcache, `enableToolSearchAuto`
117
+ among them. To turn an id into a name, grep it as a quoted literal in the extracted bundle; the call
118
+ site names it, which is how `PINNED_GATES` was built in the first place.
119
+
120
+ - **`record` now scans what it wrote, and quarantines a leaking recording instead of publishing it.**
121
+ `scanCassette` had exactly one production call site — `verify-cassettes` — which runs at commit time at
122
+ the earliest. `hostInventoryPreflight` does fire before the paid spawn, but it reads the tier and the
123
+ destination path, never the resulting bytes: it is a prediction, and it can be wrong in both directions.
124
+
125
+ After redaction and before the write, the finished cassette is scanned. A `host-inventory` or
126
+ `machine-inventory` finding on a repo-visible path writes the recording to `<runs-root>/quarantine/`
127
+ (honouring `--run-dir` / `COWORK_HARNESS_RUNS_DIR`) alongside a `.findings.txt` naming exactly what
128
+ leaked, then fails without writing the requested path.
129
+
130
+ Three things about that policy are deliberate. **Quarantine, not discard** — the tokens are already
131
+ spent, and throwing the recording away is both the most expensive answer and the one most likely to end
132
+ in "just commit it anyway". **Only the machine-identity classes trigger it** — `email` / `currency` /
133
+ `domain` / `path` findings are frequently legitimate scenario content, and a gate that fires on those
134
+ teaches the operator to pass the escape flag by reflex, which is how a safety gate becomes decoration.
135
+ **Outside a git repo it warns instead of quarantining** — nothing there publishes the file by accident,
136
+ so quarantining would be obstruction rather than protection. If the runs root is itself inside a working
137
+ tree, quarantine falls back to the OS temp dir and says so; moving a leak into another committable
138
+ location would be theatre.
139
+
140
+ `--allow-host-inventory-fixture` (the flag the preflight already honours) still writes the file, and now
141
+ reports what it is publishing rather than going quiet.
142
+
143
+ Coverage is labelled honestly: the policy (`classifyRecordLeak`) and the effect (`quarantineCassette`)
144
+ are pure, exported and mutation-tested; the *wiring* inside `recordScenarioObject` needs a live spawn to
145
+ reach, so it is guarded structurally — the same split this repo already uses for `buildCassette`.
146
+
147
+ - **Corrected a standing inaccuracy in the record-consent docs.** `SKILL.md` and `ci-recipe.md` both said
148
+ the host-inventory preflight "refuses outright rather than warn". It refuses for a **new** cassette path
149
+ but **warns** for one that already exists — deliberately, since refusing there would fire on every
150
+ `--rerecord-stale` pass and make the escape flag reflexive. That warn path was the gap the record-time
151
+ quarantine above now covers, so both documents now say which is which.
152
+
153
+
154
+ - **Platform baseline `desktop-1.34493.1` (agent `2.1.237`).** The Cowork system prompt, both sub-agent
155
+ append branches, all 28 pinned gate states, the VM egress policy and the 22-key spawn env are all
156
+ unchanged — re-derived from the new bundle rather than inferred from an absent diff row (only the
157
+ minified prompt constant id rotated). `sync` reports no unknown deltas.
158
+
159
+ The substantive change is in the **agent**, and an asar-only pass cannot see it: `2.1.237` adds six
160
+ Cowork-specific risk categories to the auto-mode permission rubric's vocabulary —
161
+ `cowork_delete_grant`, `cowork_folder_access`, `cowork_run_routine_now`,
162
+ `cowork_scheduled_task_delete`, `cowork_scheduled_task_write`, `cowork_skill_persistence` — in both the
163
+ native binary and the VM ELF. They are **inert for this harness**: the rubric is entered only on
164
+ `permissionMode === "auto"`, which no scenario can request and no baseline pins (now pinned by
165
+ `test/auto-mode-unreachable.test.ts`). They matter because the rubric's reach now names operations the
166
+ harness does model, so the existing "auto-mode permission rubric is not modeled" gap has moved from
167
+ generic infrastructure risk into Cowork territory. The agent's env-flag export table also moves
168
+ 524 → 533; none of the additions are set by the Cowork spawn.
169
+
170
+ All three committed example cassettes are re-recorded against this baseline and stamp v12 /
171
+ `hashFormat: "jcs1"`.
172
+
173
+ - **`--session <file>` on `replay` and `verify-cassettes`** — the escape hatch for a relocated cassette. A
174
+ cassette stores `session:` relative to its own directory, so any move (`git mv`, a repo reorganisation, a
175
+ copy into another project) made skill staleness permanently unverifiable with no way to say where the
176
+ tree went; the only remedies were moving the file back or re-recording. It takes a **session**, not skill
177
+ directories, because `staleness.hash_ignore` is a session-level field that is not stored in the cassette —
178
+ an override carrying only directories would silently change the hash boundary. Refused for a directory
179
+ target, for a path that is not a file, and for inline scenarios; the resolved session **and the dirs it
180
+ produced** are echoed on stderr, since an override that silently pinned the wrong tree would manufacture
181
+ false greens. An explicit override is trusted: a mismatch under it is reported as real drift rather than
182
+ downgraded.
183
+
184
+ - **Duplicate manifest paths are now reported as ambiguous** instead of silently under-reported. Two mounts
185
+ can each contribute `skills/x/SKILL.md`, and every consumer keys `fileSigs` by path, so duplicates
186
+ collapsed to the last occurrence and the changed-file list could name the wrong file or none. Drift was
187
+ always still DETECTED — the hash folds every entry — so this is an attribution fix, not a false-green fix.
188
+ Exact attribution needs per-root identity in the digest, which is a hash-format epoch change.
189
+
190
+ ### Fixed
191
+
192
+ - **A cassette that fails shape validation is now privacy-scanned.** `verify-cassettes` does two
193
+ independent jobs — a privacy scan and a staleness check — and both sat behind one strict `readCassette`.
194
+ Any document that failed shape validation returned early, so `scanCassette`, on the very next line, never
195
+ ran: the file was reported with zero findings, which reads in every summary as `0 PII finding(s)`. That is
196
+ a clean-looking number from an instrument that never ran, and a file too broken to replay is exactly the
197
+ kind of file a leak arrives in. Measured on a malformed fixture carrying MCP server names, an account org
198
+ and an agent roster: **0 findings and exit 3 before, 6 findings and exit 1 after.**
199
+
200
+ The fix is a read-boundary **split**, not a loosening: `readCassette` stays exactly as strict (replay,
201
+ staleness and the hash-format epoch's version/`hashFormat` invariant all depend on it). A separate
202
+ `readCassetteForScan` reads the transcript only, requiring nothing but `events: string[]`, and
203
+ `scanCassette`'s parameter type is narrowed to the fields it genuinely reads — so a future scan axis that
204
+ reaches for some other field is a compile error until the projection carries it, rather than silently
205
+ reading `undefined` off a partial document. Narrowing that type immediately turned up five fields the
206
+ scan reads that a hand audit had missed (`userVisibleRoots`, `scenario.name`, `scenario.session`,
207
+ `scenarioSource`, `environment.agentImage.ref`).
208
+
209
+ The projection also **fails closed on an unrecognized tier**. `scanCassette` exempts a positively-sealed
210
+ tier and scans everything else including `undefined`, but it tests set membership — so an arbitrary
211
+ string (`"garbage"`, a typo'd `"containerr"`) is neither, and would have skipped the structural
212
+ host-inventory scan entirely. The strict reader cannot produce that; this one can, because malformed
213
+ input is its whole job.
214
+
215
+ - **The pre-commit cassette gate now fails CLOSED.** `.githooks/pre-commit` tested `hook_status` for `1`
216
+ (block) and `3` (warn) and let every other outcome through to a successful commit, so the guard switched
217
+ itself off — silently — on outcomes that ordinary refactors produce: exit `2` (`examples/replays/` renamed
218
+ or emptied, or any CLI flag renamed), exit `127` (`node` off `PATH`), and a missing `dist/cli.js`, which is
219
+ the state of a fresh clone, a `git clean -xdf`, a branch switch, and any tree with a failing typecheck
220
+ (`npm run build` does `rm -rf dist` first). None of those fail a test elsewhere. Anything that is not a
221
+ proven clean `0` now blocks.
222
+
223
+ This matters more than a local convenience: `ci.yml` triggers on `push: [main]` and `pull_request`, but the
224
+ documented workflow lands with `merge --ff-only` into `main` and pushes afterwards — so on that path the
225
+ hook is not one layer of two, it is the only gate, and what it waves through is already public when CI
226
+ reds.
227
+
228
+ - **An unscannable cassette no longer commits unscanned.** Exit 3 folds together unverifiable *staleness*,
229
+ an unsupported `cassetteVersion`, and a per-file read error. Only the first means "we looked and could not
230
+ conclude"; the other two mean the privacy scan never ran, so there is no evidence either way about host
231
+ inventory — and a cassette recorded by a newer CLI than the committer's `dist/` committed with nothing
232
+ having checked it. The hook now splits exit 3 by cause (`--output-format json`) and blocks on the two that
233
+ are not staleness, in line with the `can't verify ⇒ not green` rule the CLI states everywhere else. A
234
+ payload it cannot interpret is treated as undetermined, not as staleness.
235
+
236
+ - **The gate no longer misses cassettes by filename.** `record --out` accepts an arbitrary path and does not
237
+ validate the suffix, so a recording written to e.g. `notes/run.json` was invisible to the hook's trigger,
238
+ to CI's pathspec, and to `git ls-files '*.cassette.json'`. The hook trigger is now content-derived (any
239
+ staged `.json` whose staged blob carries the `"generator": "cowork-harness"` marker), and CI cross-checks
240
+ the suffix-derived set against the content-derived one. Separately, `resolveInputs` does not recurse, so
241
+ `examples/replays/sub/x.cassette.json` was reachable by neither the directory scan nor the per-file loop
242
+ (which excluded all of `examples/replays/`); the exclusion is now scoped to files *directly* in that
243
+ directory.
244
+
245
+ - **CI scans every tracked cassette, not just one directory.** The sweep is derived from `git ls-files`
246
+ rather than a hard-coded path, fails loudly if the pathspec ever matches zero files (an `xargs -r` sweep
247
+ exits 0 on no matches and reads as success), and carries exactly one exclusion with its reason recorded:
248
+ `test/evals/files/report-check.cassette.json` is an eval *attachment*, not a recording — it has no
249
+ `scenario.session`, so `readCassette` rejects its shape before the scan can run. Measured rather than
250
+ assumed: adding a `session` field does not make it verifiable, it converts the exit 3 into an exit 1 on
251
+ baseline drift for a fixture that is never re-recorded.
252
+
253
+ - **The local gate is no longer laxer than the CI gate on the same directory.** The hook passed
254
+ `--allow-email`/`--allow-domain` suppressions that `ci.yml` does not; the committed fixtures verify clean
255
+ without them, so they are gone. Its scratch file also moved from a predictable `/tmp` path to `mktemp`, and
256
+ a clean run now says so instead of passing in silence.
257
+
258
+ - **`sync` derived the enforced egress allowlist from a bundle-wide regex, and Desktop 1.34493.1 made that
259
+ wrong.** `network.allowDomains` is the allowlist the harness ENFORCES (`boundaryAllowList` plus the
260
+ session egress plan), but it was built by matching every `*.anthropic.com` / `*.claude.ai` literal in the
261
+ whole app bundle. 1.34493.1 added a webview first-party-origin classifier — a navigation-trust tier naming
262
+ `www.claude.ai` and `staging.claude.ai` — and the sweep pulled both into the enforced list, which would
263
+ have permitted egress Cowork denies.
264
+
265
+ Narrowing the sweep was investigated and rejected: on the first-party deployment there is nothing to
266
+ narrow to. The 1p class returns `vmEgressPolicy(){return null}`, so `resolveVmAllowedDomains` falls
267
+ through to the session's **server-delivered** `egressAllowedDomains`, and the only host the bundle
268
+ contributes is the OTLP endpoint. `vmAllowedDomains`/`firewallAlso` are the 3p path and Desktop's own
269
+ renderer endpoints — neither is the VM allowlist. Any bundle scan is unsound both ways: blind to
270
+ server-delivered hosts, open to hosts that are not egress.
271
+
272
+ `network.allowDomains` is therefore a **pinned, hand-curated list carried forward** from the newest
273
+ committed baseline, and a new fail-closed `checkEgressContractFacts` guards the three constructions that
274
+ justify pinning (the 1p null policy, the resolver's fall-through to its caller-supplied argument, and the
275
+ OTLP-only augmentation) — so a real change in how Cowork computes egress hard-fails `sync` instead of
276
+ silently rewriting the list. The baseline's `network.$comment` now records that provenance and names the
277
+ entries that are unverified as VM egress; `network` became a `looseObject` so that note survives loading.
278
+
279
+ - **Consumers of 1.25.0 were NOT affected by this** — it is recorded because the shape is worth knowing,
280
+ not because it shipped. While building `--session`, skill staleness, session-shape staleness, label
281
+ provenance and the skill-hash debug dump could each have resolved a different session. The cassette-relative join was duplicated byte-identically
282
+ in three functions and `sessionFingerprintDrift` accepted an override its only caller never passed, so
283
+ `--session` would have verified skill content against the override while session shape still resolved the
284
+ recorded path — reporting "clean" on an axis that hard-fails when used normally. All consumers now share
285
+ one resolver.
286
+
287
+ - **An unresolvable session now says WHY** — missing file, unreadable/unparseable YAML, or declared mounts
288
+ that do not exist — instead of one undifferentiated message. Those point at different fixes, and only the
289
+ first looks like a relocation, so it also names the remedy.
290
+
291
+ ### Known limitations (documented, not fixed)
292
+
293
+ - **Multi-root skill hashing is order-dependent.** `hashSkillDirs` folds roots sorted by absolute path
294
+ while deliberately excluding a root's own name from the digest, so identical content at differently
295
+ sorting directory names hashes differently. One axis (root ORDER) fails loudly as false drift. A second is
296
+ worse: roots fold into one digest with no root-boundary marker, so moving a file BETWEEN roots is
297
+ invisible — a genuine false green. Multi-root cassettes are still not refused, because none exists in
298
+ any reachable corpus and refusing would break input nobody has; instead replay now emits a note when a
299
+ cassette records two or more roots. Fixing it needs a hash-format epoch.
300
+ Not scheduled: no multi-root cassette exists in any reachable corpus (32 cassettes on the widest
301
+ denominator across three repos), and no session declares 2+ plugin/skill roots. Pinned by tests in
302
+ `test/skill-hash.test.ts` so the eventual fix is a deliberate change.
303
+
304
+
9
305
  ## [1.25.0] — 2026-08-20
10
306
 
11
307
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -88,6 +88,18 @@ When a Desktop release moves something `sync` doesn't read, it reports an `unkno
88
88
 
89
89
  ## Commit & PR
90
90
 
91
+ - **A pre-commit hook gates cassettes, and it fails CLOSED.** `npm install` points `core.hooksPath` at
92
+ `.githooks/`. Staging a `baselines/desktop-*.json`, any `*.cassette.json`, or any `.json` whose staged
93
+ content carries the `"generator": "cowork-harness"` marker runs `verify-cassettes` — and **anything that
94
+ is not a proven clean exit `0` blocks the commit**, including a missing `dist/cli.js`. That last one
95
+ catches people out: `npm run build` does `rm -rf dist` first, so a tree with a failing typecheck has no
96
+ built CLI at all. Run `npm run build` and re-commit. The check is scoped to commits that stage one of
97
+ those files, so an ordinary source commit is unaffected.
98
+
99
+ It is deliberately strict because for a maintainer it is the *only* pre-publication gate: CI triggers on
100
+ `push: [main]` and `pull_request`, but the local flow lands with `merge --ff-only` and pushes afterwards,
101
+ so by the time CI reds, a leaked recording is already in public history. `git commit --no-verify`
102
+ bypasses it — if you need that for anything other than a broken hook, say so in the PR.
91
103
  - Conventional, imperative commit subjects (`add …`, `fix …`, `parity: sync to <ver>`).
92
104
  - Open PRs against `main`. CI runs a nine-stage pipeline (`build`, `test`, `action-self-test`, `python`, `image-recipe`, `boundary`, `scenarios`, `parity-drift`, `floor` — see `ci.yml`) on every PR including forks (no secrets needed) except `scenarios`; live scenarios only run on same-repo PRs/pushes with `ANTHROPIC_API_KEY` set.
93
105
  - **Which stages actually block a merge is narrower than the pipeline.** The branch ruleset requires status *contexts*, not jobs, and the `ci-green` aggregator that carries the `typecheck · test · build` context declares `needs: [build, test, floor, image-recipe]` — so `action-self-test`, `boundary`, `scenarios`, and `parity-drift` run and report but do **not** gate. (`python` **does** gate: its job name is the second required context, `pytest helper lane (-m 'not cowork')` — this line previously listed it as non-gating, which was wrong.) Read a red one as a real signal anyway: `boundary` in particular is the secrets-free proof that the sandbox enforces Cowork's limits. `ci.yml`'s comment above `ci-green` is the authority on the current wiring.