cowork-harness 4.9.0 → 4.9.1

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 (67) hide show
  1. package/.claude/skills/cowork-harness/SKILL.md +5 -5
  2. package/.claude/skills/cowork-harness/references/assertion-catalog-agents-skills-budgets.md +1 -1
  3. package/.claude/skills/cowork-harness/references/assertion-catalog-gates-hooks-modifiers.md +2 -2
  4. package/.claude/skills/cowork-harness/references/assertion-catalog-outcome-files-tools.md +1 -1
  5. package/.claude/skills/cowork-harness/references/assertion-catalog.md +1 -1
  6. package/.claude/skills/cowork-harness/references/assertions-guide.md +1 -1
  7. package/.claude/skills/cowork-harness/references/authoring.md +1 -1
  8. package/.claude/skills/cowork-harness/references/ci-recipe.md +5 -5
  9. package/.claude/skills/cowork-harness/references/critique.md +1 -1
  10. package/.claude/skills/cowork-harness/references/debugging.md +8 -1
  11. package/.claude/skills/cowork-harness/references/eval.md +1 -1
  12. package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
  13. package/.claude/skills/cowork-harness/references/gotchas.md +1 -1
  14. package/.claude/skills/cowork-harness/references/hillclimb-recipe.md +1 -1
  15. package/.claude/skills/cowork-harness/references/hillclimb.md +4 -3
  16. package/.claude/skills/cowork-harness/references/measurement.md +1 -1
  17. package/.claude/skills/cowork-harness/references/run-record-replay.md +12 -8
  18. package/.claude/skills/cowork-harness/references/scenario-schema.md +4 -2
  19. package/.claude/skills/cowork-harness/references/semantic-judging.md +1 -1
  20. package/.claude/skills/cowork-harness/references/task-recipes.md +5 -4
  21. package/.claude/skills/cowork-harness/scripts/scenario.py +79 -28
  22. package/AGENTS.md +4 -2
  23. package/CHANGELOG.md +76 -0
  24. package/CONTRIBUTING.md +1 -1
  25. package/DESIGN.md +2 -2
  26. package/README.md +4 -4
  27. package/RELEASING.md +37 -15
  28. package/SPEC.md +1 -1
  29. package/baselines/desktop-2.37337.1.json +1674 -0
  30. package/baselines/prompts/cowork-system-prompt-fingerprints.json +16 -0
  31. package/dist/cli.js +13 -11
  32. package/dist/eval/usage.js +5 -2
  33. package/dist/hillclimb/cli.js +7 -7
  34. package/dist/hillclimb/usage.js +38 -1
  35. package/dist/run/cassette.js +6 -6
  36. package/dist/run/multi-leg-scenario.js +1 -1
  37. package/dist/run/regrade-usage.js +2 -2
  38. package/dist/search-corpus/cli.js +4 -2
  39. package/dist/search-corpus/harvest.js +48 -8
  40. package/dist/search-corpus/pool.js +24 -2
  41. package/dist/sync/cowork-sync.js +212 -30
  42. package/dist/sync/spawn-env-registry.js +10 -0
  43. package/dist/types.js +1 -1
  44. package/docs/ci.md +6 -4
  45. package/docs/cli.md +21 -20
  46. package/docs/companion-skill.md +6 -3
  47. package/docs/debugging.md +7 -0
  48. package/docs/decider-dir.md +4 -0
  49. package/docs/eval.md +2 -1
  50. package/docs/fidelity-gaps.md +2 -1
  51. package/docs/hillclimb.md +3 -2
  52. package/docs/maintenance.md +3 -1
  53. package/docs/scenario.md +17 -11
  54. package/examples/README.md +3 -1
  55. package/examples/replays/README.md +1 -1
  56. package/examples/replays/example-multiselect-gate.cassette.json +1 -1
  57. package/examples/replays/example-pdf-skill.cassette.json +1 -1
  58. package/examples/replays/hostloop-computer-links.cassette.json +1 -1
  59. package/package.json +2 -2
  60. package/python/test_scenario_lint.py +89 -0
  61. package/schema/cassette.v18.json +3 -3
  62. package/schema/critique-compare.json +1 -1
  63. package/schema/run-result.json +3 -3
  64. package/schema/scenario-multi-leg.schema.json +1 -1
  65. package/schema/scenario.schema.json +1 -1
  66. package/scripts/check-surface.ts +145 -32
  67. package/scripts/lib/surface.ts +72 -10
@@ -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, asserting artifacts, egress, or sub-agent dispatch, measuring how long each tool call took (toolDurations / trace), or debugging a failed run or verdict from its result.json or transcript. 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. Also for comparing two versions of a skill before merging an edit — did it make answers worse? (`eval`: paired A/B, pinned models) — or improving one round by round (`hillclimb`, the `/claude-api hillclimb` runner). 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 / critique / stats / eval / hillclimb commands and the session-vs-scenario split.
4
4
  metadata:
5
5
  author: cowork-harness
6
- version: 4.9.0
7
- tracks-harness: cowork-harness 4.9.0 (baseline desktop-2.31226.1)
6
+ version: 4.9.1
7
+ tracks-harness: cowork-harness 4.9.1 (baseline desktop-2.37337.1)
8
8
  ---
9
9
 
10
10
  # cowork-harness
@@ -26,8 +26,8 @@ allowlist). This skill exists mostly to keep you out of those traps — the *Inv
26
26
  full landmine catalog in [`references/gotchas.md`](references/gotchas.md) are the highest-value part.
27
27
  Read them.
28
28
 
29
- > **Version note:** the facts and `file:line` pointers here track `cowork-harness 4.9.0` (baseline
30
- > `desktop-2.31226.1`). If your checkout is newer, prefer the live `--help` and — in a repo checkout —
29
+ > **Version note:** the facts and `file:line` pointers here track `cowork-harness 4.9.1` (baseline
30
+ > `desktop-2.37337.1`). If your checkout is newer, prefer the live `--help` and — in a repo checkout —
31
31
  > `SPEC.md` / `docs/*.md` over this snapshot, and re-run the bundled linter.
32
32
 
33
33
  ## Preflight — make sure the harness can actually run
@@ -43,7 +43,7 @@ Before the first command, confirm the CLI is reachable and **fail loud** (never
43
43
 
44
44
  - **One-shot check.** Run `cowork-harness doctor [--tier <tier>]` first — a read-only prerequisite check that inspects Docker, the staged agent, the token, the baseline and the `device_bridge` image (built, or built on the first bridge run) in one pass. The bullets below explain each thing it checks (and how to fix it).
45
45
  - **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>`.
46
- - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 4.9.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@^4.9.0" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@^4.9.0"`. **Pin `@^4.9.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.
46
+ - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 4.9.1**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@^4.9.1" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@^4.9.1"`. **Pin `@^4.9.1`, 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.
47
47
 
48
48
  This skill documents the CURRENT surface, not release history. If `cowork-harness --version` is
49
49
  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)
@@ -1,6 +1,6 @@
1
1
  # Assertion catalog: sub-agents, skills and connectors, budgets, tasks, delivery
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Part 2 of 3 of the per-key table. The conventions every key shares (an item with several
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Part 2 of 3 of the per-key table. The conventions every key shares (an item with several
4
4
  keys is an AND; glob vs regex name matching) and the verdict-signal table are in
5
5
  [`assertion-catalog.md`](./assertion-catalog.md); the other parts are [`assertion-catalog-outcome-files-tools.md`](./assertion-catalog-outcome-files-tools.md), [`assertion-catalog-gates-hooks-modifiers.md`](./assertion-catalog-gates-hooks-modifiers.md).
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Assertion catalog: gates, hooks, path denial, verdict modifiers, egress, judged keys
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Part 3 of 3 of the per-key table. The conventions every key shares (an item with several
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Part 3 of 3 of the per-key table. The conventions every key shares (an item with several
4
4
  keys is an AND; glob vs regex name matching) and the verdict-signal table are in
5
5
  [`assertion-catalog.md`](./assertion-catalog.md); the other parts are [`assertion-catalog-outcome-files-tools.md`](./assertion-catalog-outcome-files-tools.md), [`assertion-catalog-agents-skills-budgets.md`](./assertion-catalog-agents-skills-budgets.md).
6
6
 
@@ -17,7 +17,7 @@ refused at load: no question reaches the harness to grade.
17
17
  | `gate_answers_delivered: true` | every answered gate's answer reached the model (observed `tool_result`; unobserved = fail); **zero gates fired passes vacuously** — pair with `gate_answer_count_min: >= 1` to also require a gate, or drop this key and declare `questions_count_max: 0` in a scenario that expects none |
18
18
  | `gate_answers_delivered: false` | asserts at least one answered gate's answer was **confirmed not delivered** (an observed delivery failure); an unobserved/null delivery does **not** satisfy this — for negative-path delivery tests. Requires a gate, so **mutually exclusive** with `questions_count_max: 0` (refused by `run`/`skill`/`record`) |
19
19
  | `gate_answer_count_min: <N>` | at least N AskUserQuestion gates fired AND were delivered non-error — presence companion to `gate_answers_delivered`'s vacuous-pass. **`: 0` asserts nothing** and does not satisfy that pairing; `>= 1` is **mutually exclusive** with `questions_count_max: 0` (refused by `run`/`skill`/`record`) |
20
- | `gates_all_scripted: true \| {include_permissions: true}` | every AskUserQuestion gate that fired was answered by a scripted rule (`answers:` / `--answer`) — not the LLM decider, `first`, or an external or human decider; a failure names each such gate and who answered it. A gate asked but never answered fails. **Evidence:** the run's decisions — a result with no decisions record, or an answer with no recognisable source, fails evidence-unavailable, never passes. On replay each gate is re-classified against the cassette's frozen `answers:`; that is evidence-unavailable when those answers were redacted, or when the cassette records that a live decider answered a gate yet its frozen rules cover every gate (they are not the rules it recorded with). **`{include_permissions: true}`** also checks tool permissions: a scripted `when_tool` rule or a fixed rule counts (a Read/Glob/Grep registry allow, a strict-parity deny, the harness's own path-gate deny, the fail-closed deny when nothing answered live — on replay that row means the recording held no answer, evidence-unavailable); a web_fetch deny that no answer source decided fails (`answered by abstain-fallback`), because real Cowork asks the user (under the default `on_unanswered: fail` such a fetch ends the run as an unanswered gate before any deny, so this arises only under `first` or `llm`); cowork parity's permissive off-registry auto-allow never does, and a replayed web_fetch permission cannot be attributed (evidence-unavailable). Zero gates passes (nothing needed a person), so pair it with `gate_answer_count_min: >= 1` or `questions_count_max` to state whether gates were expected (`lint` warns `unpaired-gates-all-scripted`). |
20
+ | `gates_all_scripted: true \| {include_permissions: true}` | every AskUserQuestion gate that fired was answered by a scripted rule (`answers:` / `--answer`) — not the LLM decider, `first`, or an external or human decider; a failure names each such gate and who answered it. A gate asked but never answered fails. **Evidence:** the run's decisions — a result with no decisions record, or an answer with no recognisable source, fails evidence-unavailable, never passes. On replay each gate is re-classified against the cassette's frozen `answers:`; that is evidence-unavailable when those answers were redacted, or when the cassette records that a live decider answered a gate yet its frozen rules cover every gate (they are not the rules it recorded with). **`{include_permissions: true}`** also checks tool permissions: a scripted `when_tool` rule or a fixed rule counts (a Read/Glob/Grep registry allow, a strict-parity deny, the harness's own path-gate deny, the fail-closed deny when nothing answered live — on replay that row means the recording held no answer, evidence-unavailable); a web_fetch deny that no answer source decided fails (`answered by abstain-fallback`), because real Cowork asks the user (under the default `on_unanswered: fail` such a fetch ends the run as an unanswered gate before any deny, so this arises only under `first`: the LLM decider always picks a button or fails the run); cowork parity's permissive off-registry auto-allow never does, and a replayed web_fetch permission cannot be attributed (evidence-unavailable). Zero gates passes (nothing needed a person), so pair it with `gate_answer_count_min: >= 1` or `questions_count_max` to state whether gates were expected (`lint` warns `unpaired-gates-all-scripted`). |
21
21
  | `hook_blocked: <regex>` | one of the harness's own PreToolUse hook callbacks blocked a tool whose name matches the regex (`RunResult.hookEvents`; a plugin's hook never reaches it: use `hook_event_blocked`) — evidence-unavailable if hook telemetry is absent. Replay: needs a `controlOut` cassette (a custom hook's decision lives only there, not the recorded stream). **Does not see the agent's own refusal**: on `hostloop` against Desktop 2.7032.0+ a relative `Read`/`Write`/`Edit` is denied by the agent's permission rules before any hook runs, so neither this key nor `path_denied` records it — assert it with `tool_result_contains: "denied by your permission settings"` |
22
22
  | `no_hook_blocked: true` | no tool was blocked by the harness's own hook callbacks (a plugin's hook never reaches it: use `no_hook_event_blocked`) (distinguishes a real tool crash from an intentional hook block) — evidence-unavailable if hook telemetry is absent. Replay: needs a `controlOut` cassette. **Only `true` is valid** |
23
23
  | `hook_event_fired: <HookEvent>` | a **command hook** for this event (a plugin's `hooks/hooks.json` or manifest hook — `Stop`, `SessionStart`, `PostToolUse`, …) ran: a `hook_response` system frame with that `hook_event` was recorded (`RunResult.contextEvents`). Any outcome counts. The harness passes `--include-hook-events` whenever a staged plugin declares hooks — that is what puts events other than SessionStart/Setup on the stream — so a recording made without it reports "never fired". Content-class, grades on replay. Recorded end-to-end for `Stop` ([stop-hook-probe.scenario.yaml](https://github.com/yaniv-golan/cowork-harness/blob/main/examples/probes/stop-hook-probe.scenario.yaml)) and for `PreToolUse` at `container` (deciding by JSON and by exit 2); the other names match the same frame but have not each been recorded |
@@ -1,6 +1,6 @@
1
1
  # Assertion catalog: outcome, transcript, files and artifacts, tools, tool results
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Part 1 of 3 of the per-key table. The conventions every key shares (an item with several
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Part 1 of 3 of the per-key table. The conventions every key shares (an item with several
4
4
  keys is an AND; glob vs regex name matching) and the verdict-signal table are in
5
5
  [`assertion-catalog.md`](./assertion-catalog.md); the other parts are [`assertion-catalog-agents-skills-budgets.md`](./assertion-catalog-agents-skills-budgets.md), [`assertion-catalog-gates-hooks-modifiers.md`](./assertion-catalog-gates-hooks-modifiers.md).
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Assertion catalog
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Every `assert:` key with its semantics, and the
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Every `assert:` key with its semantics, and the
4
4
  verdict-signal table. Which keys survive `replay` is in [`scenario-schema.md`](./scenario-schema.md#replay-class);
5
5
  the scenario and session YAML fields are there too.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Assertions guide
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Read it when choosing assertion keys: the two orthogonal axes and the goal → key map. The full catalog starts at `assertion-catalog.md`, which indexes the per-family files that hold every key's row.
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Read it when choosing assertion keys: the two orthogonal axes and the goal → key map. The full catalog starts at `assertion-catalog.md`, which indexes the per-family files that hold every key's row.
4
4
 
5
5
  ### Assertions: two orthogonal axes
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Authoring a scenario
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Read it when composing a `scenarios/*.yaml`: session vs scenario, discovery, the fidelity tier, the answer path, `web_fetch`, and scaffold + lint.
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Read it when composing a `scenarios/*.yaml`: session vs scenario, discovery, the fidelity tier, the answer path, `web_fetch`, and scaffold + lint.
4
4
 
5
5
  ## Part I — AUTHOR a scenario
6
6
 
@@ -1,6 +1,6 @@
1
1
  # CI recipe — replay vs live lanes
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`).
3
+ Self-contained reference. Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.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):
@@ -17,7 +17,7 @@ job-summary reporter (verdict table, staleness findings, cost/turns when availab
17
17
  CLI major reaches your workflow the moment it is promoted even though your `uses:` ref never changed — so a
18
18
  copy-pasted recipe that omits the input takes a major bump with no say in it. `^4` holds the major, needs no
19
19
  patch number to remember, and only wants a human decision at the next major. Pin an exact version
20
- (e.g. `version: "4.9.0"`) instead when you want byte-reproducible CI.
20
+ (e.g. `version: "4.9.1"`) instead when you want byte-reproducible CI.
21
21
 
22
22
  Reach for the manual multi-step form below only when you need per-step control the Action's inputs don't
23
23
  cover (a custom flag combination, a different runner matrix per step, or `lint`/`verify-cassettes` gated
@@ -82,7 +82,7 @@ sha256-*checked* but not hard-blocking on mismatch — it's advisory for an inte
82
82
  GitHub-hosted runners, no token/Docker/agent:
83
83
 
84
84
  ```yaml
85
- - run: npm i -g "cowork-harness@^4.9.0"
85
+ - run: npm i -g "cowork-harness@^4.9.1"
86
86
  - run: cowork-harness lint scenarios/*.yaml --strict --min-severity WARN
87
87
  # no silent false-greens. WITHOUT --strict this
88
88
  # step cannot fail on a WARN-class rule (e.g.
@@ -411,7 +411,7 @@ jobs:
411
411
  with: { node-version: '24' }
412
412
  - uses: actions/setup-python@v5
413
413
  with: { python-version: '3.x' } # python3 only — PyYAML is bundled with the linter
414
- - run: npm i -g "cowork-harness@^4.9.0"
414
+ - run: npm i -g "cowork-harness@^4.9.1"
415
415
  - run: cowork-harness lint scenarios/*.yaml # no-silent-false-green (needs python3; PyYAML bundled)
416
416
  - run: cowork-harness verify-cassettes cassettes/ --output-format json # privacy + staleness gate
417
417
  - run: cowork-harness replay cassettes/ --output-format json # token-free content/structure
@@ -440,7 +440,7 @@ jobs:
440
440
  echo "live=true" >> "$GITHUB_OUTPUT"
441
441
  fi
442
442
  - if: steps.guard.outputs.live == 'true'
443
- run: npm i -g "cowork-harness@^4.9.0"
443
+ run: npm i -g "cowork-harness@^4.9.1"
444
444
  - if: steps.guard.outputs.live == 'true'
445
445
  run: cowork-harness run scenarios/ --output-format json
446
446
  env:
@@ -1,6 +1,6 @@
1
1
  # Critique — the facts a plugin install can't otherwise reach
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). This is **not** a trim of the full
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.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, reading several critiques, 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
  # Debugging a run
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Read it when a run misbehaved or a green looks wrong: triage, the observability output, and `chat`.
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Read it when a run misbehaved or a green looks wrong: triage, the observability output, and `chat`.
4
4
 
5
5
  ## Part III — Debug
6
6
 
@@ -35,6 +35,13 @@ received a question or permission request anyway; `events.jsonl` and `control-ou
35
35
  already does.
36
36
  <!-- END triage-canonical -->
37
37
 
38
+ **A multi-leg run (`legs:`) is read one leg at a time.** `trace`, `verify-run` and `regrade` take
39
+ `--leg <id>` (or the leg dir `<root>/legs/<id>` itself); `inspect` and `fixture export` take the leg dir. A
40
+ multi-leg root without `--leg` is refused, and `trace` / `inspect` name its legs. A host-action leg has no
41
+ agent session and is refused too — its record is `legs/<id>/action.json`. `search-pool build --from <root>` is the
42
+ exception: it reads every prompt leg of the root, each as its own source run. See
43
+ [docs/scenario.md → Multi-leg scenarios](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/scenario.md#multi-leg-scenarios-legs).
44
+
38
45
  **`verify-run` never calls the semantic judge**, so it cannot re-grade a `semantic_matches` or `semantic_pairwise` assert. When the
39
46
  rubric changed (or you want another judge model) on a run you already paid for, use
40
47
  `cowork-harness regrade <run-dir> --scenario <scenario.yaml>` instead: it re-grades those asserts against the
@@ -1,6 +1,6 @@
1
1
  # `eval` — paired before/after comparison of a skill edit (EXPERIMENTAL)
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). The full guide is
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). The full guide is
4
4
  [docs/eval.md](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/eval.md); this is the part you
5
5
  need while running it.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Fidelity tiers & answer paths
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`).
3
+ Self-contained reference. Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`).
4
4
 
5
5
  > **This page vs. the repo docs.** This is the **offline snapshot** that ships inside the installed
6
6
  > plugin — it is self-contained on purpose. The repo carries four other fidelity views, each answering a
@@ -1,6 +1,6 @@
1
1
  # Gotchas
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). The full "✓ passed ≠ correct" landmine catalog.
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). The full "✓ passed ≠ correct" landmine catalog.
4
4
 
5
5
  ## Gotchas — the "✓ passed ≠ correct" landmines
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Recipe 7 — Climb a skill with `/claude-api hillclimb` and the harness as its runner
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). It needs a `cowork-harness` whose
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). It needs a `cowork-harness` whose
4
4
  `hillclimb --help` lists `--skill` (help goes to stderr). This page is the loop's procedure, step by step, in the order of the
5
5
  `/claude-api hillclimb` guide. Every mechanic (flags, refusals, the gate, `regrade`, `freeze-ref`, exit codes,
6
6
  row keys) is in [`hillclimb.md`](hillclimb.md); the setup and the full list of differences from the guide's own
@@ -1,6 +1,6 @@
1
1
  # `hillclimb` — the runner for a `/claude-api hillclimb` loop
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). It needs a `cowork-harness` whose
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). It needs a `cowork-harness` whose
4
4
  `hillclimb --help` lists `--skill` (help goes to stderr). The command reference is
5
5
  [docs/cli.md](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/cli.md); this is the part a loop needs
6
6
  while it runs. It covers `run`, `check`, `state-template`, `freeze-ref` and `regrade`.
@@ -254,8 +254,9 @@ and rewrites each row through the same producer `run` writes it with. Pass the s
254
254
  `COWORK_HARNESS_RUNS_DIR` the runs were written with, or every row is listed as having no kept run dir. A runs root
255
255
  moved to where `--run-dir` now points is read from there; a COPY of it while the original still exists is refused
256
256
  row by row (point `--run-dir` at the original root instead). A bare
257
- `prune` keeps hillclimb-labelled runs; `prune --include-hillclimb` deletes them for every flow under the runs root,
258
- a loop still running included, and `freeze-ref` re-reads a frozen reference's source run, so pass it only once
257
+ `prune` keeps hillclimb-labelled runs; `prune --include-hillclimb` ranks them with every other run under
258
+ `--keep-last`, so the runs of every flow under the runs root, a loop still running included, can lose the ones
259
+ a later re-grade or reference freeze needs (freezing a reference re-reads its source run). Pass it only once
259
260
  every climb there is finished.
260
261
 
261
262
  - **Default:** the row is rebuilt from the scenario as it is now, so a changed value, an added or a removed assert
@@ -1,6 +1,6 @@
1
1
  # Measurement
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Read it before comparing runs: `--repeat`, `--ablate-skill`, and the hygiene that keeps a batch valid.
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Read it before comparing runs: `--repeat`, `--ablate-skill`, and the hygiene that keeps a batch valid.
4
4
 
5
5
  ### Measure — before/after, with/without (`--repeat`, `--ablate-skill`)
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Run, record and lock
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). Read it when running a scenario, recording or placing a cassette, reading verdict signals, checking a background run, or choosing CI lanes.
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). Read it when running a scenario, recording or placing a cassette, reading verdict signals, checking a background run, or choosing CI lanes.
4
4
 
5
5
  ## Part II — RUN, RECORD & LOCK
6
6
 
@@ -229,7 +229,7 @@ Recognize these before "fixing" a non-bug:
229
229
  `markitdown`/`magika` (`ml_extract`), `cv2` (`cv`), `camelot`/`tabula` (`pdf_tables`), or `wand`
230
230
  (`magick`) can trip this even though real Cowork **ships** those (per the rootfs manifest captured at
231
231
  Desktop `2.9939.2` — `baselines/provisioning/rootfs-provisioning.json`, which is the dated evidence
232
- behind that sentence; 8 baselines have shipped since without a re-capture). The message says so ("likely a FALSE
232
+ behind that sentence; 9 baselines have shipped since without a re-capture). The message says so ("likely a FALSE
233
233
  NEGATIVE (real Cowork ships them)"). Fix: rebuild full parity (`--build-arg COWORK_FULL_PARITY=1`, point
234
234
  `COWORK_AGENT_IMAGE` at it), or — if the skill's fallback is genuinely equivalent — assert
235
235
  `allow_missing_capability: true`. (Two sources: a skill *observed using* an omitted family, live lane;
@@ -439,11 +439,13 @@ with the ordinary commands, one scenario at a time:
439
439
  `trace <root | run-id> --leg <id>`, `inspect <root>/legs/<id>`. A prompt leg is read from its snapshot
440
440
  (`legs/<id>/snapshot/`, its files as they were when it ended), so every prompt leg of a kept run re-grades at no
441
441
  cost (a host-action leg has no session and is refused). The snapshot is a copy-on-write clone where the filesystem
442
- supports one (APFS, btrfs, XFS with reflink), costing almost no disk until a later leg changes a file, and
443
- otherwise (ext4, the usual Linux and CI disk) a full copy of the leg's outputs and connected folders, per prompt
444
- leg; `snapshot.json` records which as `method` (`clone`, `copy`, `mixed` or `none`), and `leg-post-manifest.json`
442
+ supports one (macOS APFS through `cp -c`, only when the folder is on the same volume as the run dir; Linux btrfs,
443
+ XFS with reflink), costing almost no disk until a later leg changes a file, and otherwise (ext4, the usual Linux and
444
+ CI disk, or a folder on another volume) a full copy of the leg's outputs and connected folders, per prompt leg;
445
+ `snapshot.json` records which as `method` (`clone`, `copy`, `mixed` — one folder cloned, another copied — or
446
+ `none`), on macOS `fallback` says why a folder was copied rather than cloned, and `leg-post-manifest.json`
445
447
  holds the hashes that prove it is the leg's own. A leg over `COWORK_HARNESS_LEG_SNAPSHOT_MAX_BYTES` (default
446
- 512 MiB; a malformed value is refused before the run) gets no snapshot. A leg with no usable snapshot falls back to its session tree and is refused when a later leg changed that tree. A
448
+ 512 MiB; a malformed value is refused before the run) gets no snapshot. A leg with no usable snapshot falls back to its session tree. Under `workdir: persistent` only the last leg of a finished scenario can use it (later legs changed the shared tree); every other leg is refused. That last leg, and every `cowork-task` leg, is refused when its tree changed after the leg ended, or when there is no post-run manifest to check it against. A
447
449
  multi-leg root without `--leg` is refused, naming the legs.
448
450
 
449
451
  Full rules: [docs/scenario.md → "Multi-leg scenarios"](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/scenario.md#multi-leg-scenarios-legs).
@@ -467,8 +469,10 @@ Full rules: [docs/scenario.md → "Multi-leg scenarios"](https://github.com/yani
467
469
  path unless `--allow-host-paths` (a path into a run dir or a guest session is refused regardless).
468
470
  `--session-paths` tokenises this run's own session roots instead of refusing them, and staging writes the new
469
471
  run's roots in (hostloop, container, microvm); `--exclude <path>` drops a file.
470
- - `prune [--keep-last <n>] [--pinned-older-than <N>d]` removes old run dirs (default `--keep-last 5`);
471
- `--dry-run` previews it.
472
+ - `prune [--keep-last <n>] [--pinned-older-than <N>d|h|m] [--include-hillclimb]` removes old run dirs (default
473
+ `--keep-last 5`); `--dry-run` previews it. Hillclimb-labelled runs are kept outside `--keep-last`;
474
+ `--include-hillclimb` ranks them with the rest, so every flow's runs under the runs root (a running climb
475
+ included) can lose the ones `hillclimb regrade` / `freeze-ref` need; pass it only once every climb there is finished.
472
476
 
473
477
  ### Place assertions in the right CI lane
474
478
 
@@ -1,7 +1,7 @@
1
1
  # Scenario & session schema, replay class, web_fetch, authoring gotchas
2
2
 
3
- Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 4.9.0`
4
- (baseline `desktop-2.31226.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 4.9.1`
4
+ (baseline `desktop-2.37337.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` and `fidelity` are required:
@@ -156,6 +156,8 @@ metrics: # OPTIONAL — numbers read from JSON files
156
156
  # run as `<script> <events-file> [args]` over this turn's scrubbed slice of
157
157
  # events.jsonl; it must print exactly one JSON number. Script + deps: regular,
158
158
  # git-tracked files inside the scenario's repo (no symlink, no hard link, <= 1 MiB).
159
+ # `timeout_ms`: the script's wall-clock limit per run (1000-300000, default
160
+ # 30000; command only — refused on an artifact metric), past it command_timeout.
159
161
  # RUNS THE REPO'S CODE AS YOU (only repos you would `npm test`). Live runs run it;
160
162
  # replay reports the frozen record-time value (cassette v18); verify-run / regrade /
161
163
  # hillclimb regrade carry it forward unless --allow-metric-commands. Its own reasons:
@@ -1,6 +1,6 @@
1
1
  # `semantic_matches` — the full semantics
2
2
 
3
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`). The one-line summary is in
3
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.1`). The one-line summary is in
4
4
  [assertion-catalog-gates-hooks-modifiers.md](assertion-catalog-gates-hooks-modifiers.md); this is the whole contract, split out so the catalog stays within the
5
5
  agent's single-read size.
6
6
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Each recipe composes facts that live scattered across SKILL.md and the other references into one
4
4
  decision path. Every one answers a question a real fleet owner had to work out the hard way.
5
- Tracks `cowork-harness 4.9.0` (baseline `desktop-2.31226.1`), same as SKILL.md's front-matter. Recipe 2's `resolved-tier`/`unverifiable-tier` staleness classes and
5
+ Tracks `cowork-harness 4.9.1` (baseline `desktop-2.37337.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
 
@@ -62,7 +62,7 @@ Top-level fields of a `*.cassette.json` (schema [`schema/cassette.v18.json`](htt
62
62
 
63
63
  | Field | What it is |
64
64
  |---|---|
65
- | `$schema`, `generator`, `cassetteVersion` | Provenance: schema URL, producing tool, format version — the MINIMUM a reader needs for this scenario, not the recorder's version (current max: 18 — the hash-format epoch floors every stamp at 12, so a fresh recording stamps 12 unless a key lifts it: 13 when its `assert:` uses the object form of `tool_called` / `tool_not_called`, 14 for a v14 key such as `include_fork_results` on `semantic_matches`, 15 for a v15 one such as `gates_all_scripted`, a glob `artifact_json`, an `artifact_json` carrying `schema`, any `artifact_json` on `lane: remote`, or a recording under `answer_channel: none`, 16 for a recording whose `hookFailureBlocks` lists an event or is unknown, 18 for a scenario with `device_bridge: true` (a v17 bridge recording still reads) or one declaring a command metric (its record-time values frozen in `commandMetrics`); the full list is in the cassette docs' version table) |
65
+ | `$schema`, `generator`, `cassetteVersion` | Provenance: schema URL, producing tool, format version — the MINIMUM a reader needs for this scenario, not the recorder's version (current max: 18 — the hash-format epoch floors every stamp at 12, so a fresh recording stamps 12 unless a key lifts it: 13 when its `assert:` uses the object form of `tool_called` / `tool_not_called`, 14 for a v14 key such as `include_fork_results` on `semantic_matches`, 15 for a v15 one such as `gates_all_scripted`, a glob `artifact_json`, an `artifact_json` carrying `schema`, any `artifact_json` on `lane: remote`, or a recording under `answer_channel: none`, 16 for a recording whose `hookFailureBlocks` lists an event or is unknown, 18 for a scenario with `device_bridge: true` (a v17 bridge recording still reads) or one declaring a command metric (its record-time values frozen in `commandMetrics`), and for a multi-leg (`legs:`) scenario; the full list is in the cassette docs' version table) |
66
66
  | `scenario` | The embedded scenario snapshot at record time |
67
67
  | `events` | The recorded agent event stream (the replay source) |
68
68
  | `legs`, `verdict` | Multi-leg form only (a `legs:` scenario, v18), in place of `events`: `legs[]` holds one entry per leg in order — a prompt leg nests its own complete single-prompt cassette under `cassette`, a host action its record under `action`, a leg after a failing one `status: "skipped"` — and `verdict.pass` is the scenario verdict at record time. `replay` and `verify-cassettes` read this form; other cassette readers refuse it by name |
@@ -521,6 +521,8 @@ cowork-harness search-pool export market-sizing --out ~/market-sizing-pool.json
521
521
  cowork-harness search-pool import ~/market-sizing-pool.json market-sizing-copy # re-verifies both hashes
522
522
  ```
523
523
 
524
+ - **Multi-leg runs.** A multi-leg run under a `--from` dir adds each prompt leg as its own source run (`legs/<id>/`);
525
+ a host-action leg has no searches.
524
526
  - **One skill per pool.** A run whose skill differs from the others, or that names no single skill, is refused. Pass
525
527
  `--skill <name>` to name the skill for runs that name none, or `--mixed` for a pool of several skills (a measured
526
528
  variant, never a baseline).
@@ -548,5 +550,4 @@ Copy [the resumable-probe scenario](https://github.com/yaniv-golan/cowork-harnes
548
550
  - Re-grade or read one leg with `--leg <id>`: `verify-run <run-dir> <legs.yaml> --leg <id>`,
549
551
  `regrade <run-dir> --scenario <legs.yaml> --leg <id>`, `trace <run-dir | run-id> --leg <id>`. Each prompt leg is read
550
552
  from its own snapshot (its files as they stood when it ended; a clone where the filesystem supports one, else a
551
- full copy, so budget disk on ext4 and CI), so it re-grades at no cost. A leg too large to snapshot falls back to its
552
- session tree and is refused once a later leg changed it; a host-action leg is refused.
553
+ full copy, so budget disk on ext4 and CI), so it re-grades at no cost. A leg too large to snapshot falls back to its session tree. Under `workdir: persistent` only the last leg of a finished scenario can use it (later legs changed the shared tree); every other leg is refused. That last leg, and every `cowork-task` leg, is refused when its tree changed after the leg ended, or when there is no post-run manifest to check it against; a host-action leg is refused.
@@ -272,16 +272,24 @@ VERDICT_MODIFIER_KEYS = {
272
272
  _CLASSIFIED_KEYS = CONTENT_KEYS | GATE_KEYS | MANIFEST_KEYS | LIVE_ONLY_KEYS | VERDICT_MODIFIER_KEYS | {"replay_protocol_fidelity"}
273
273
 
274
274
 
275
+ def _str_list(v, name):
276
+ """`v` as a list of strings, or a TypeError naming the field: a string where a key list belongs would otherwise
277
+ be iterated as characters, and the loaders fall back to their embedded copies on any error."""
278
+ if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
279
+ raise TypeError(f"assertion-keys.json {name} is not a list of strings")
280
+ return v
281
+
282
+
275
283
  def _load_assert_keys():
276
284
  """The authoritative `assert:` key set, generated from the Zod Assertion schema into a sibling
277
285
  `assertion-keys.json` (so the unknown-key check can't drift). Falls back to the embedded
278
286
  `_CLASSIFIED_KEYS` (kept equal to the generated list) with a loud warning if the file is missing."""
279
287
  p = Path(__file__).resolve().parent / "assertion-keys.json"
280
288
  try:
281
- return set(json.loads(p.read_text(encoding="utf-8"))["keys"])
282
- except Exception:
289
+ return set(_str_list(json.loads(p.read_text(encoding="utf-8"))["keys"], "keys"))
290
+ except Exception as e:
283
291
  print(
284
- f"::warning:: assertion-keys.json not found next to scenario.py ({p}) — "
292
+ f"::warning:: assertion-keys.json missing or malformed next to scenario.py ({p}): {e} — "
285
293
  "using a built-in key list that may be stale (run `npm run schema`).",
286
294
  file=sys.stderr,
287
295
  )
@@ -344,22 +352,35 @@ def _load_hook_events():
344
352
  p = Path(__file__).resolve().parent / "assertion-keys.json"
345
353
  try:
346
354
  d = json.loads(p.read_text(encoding="utf-8"))
347
- served, known = set(d["servedHookEvents"]), set(d["knownHookEvents"])
348
- # Tolerate a sidecar generated before liveVerifiedHookEvents existed: fall back rather than
349
- # dropping to the embedded KNOWN set wholesale, which would undo the 33-name fix. Membership
350
- # test, NOT truthiness: an explicitly EMPTY list means the firing receipt was withdrawn, and
351
- # `or` would silently restore the 3-name claim -- widening a receipt, which is the one direction
352
- # this constant exists to prevent.
353
- live = set(d["liveVerifiedHookEvents"]) if "liveVerifiedHookEvents" in d else set(_FALLBACK_LIVE_VERIFIED_HOOK_EVENTS)
354
- if served and known:
355
- return served, known, live
356
- except Exception:
357
- pass
358
- return (
359
- set(_FALLBACK_SERVED_HOOK_EVENTS),
360
- set(_FALLBACK_KNOWN_HOOK_EVENTS),
361
- set(_FALLBACK_LIVE_VERIFIED_HOOK_EVENTS),
362
- )
355
+ served = set(_str_list(d["servedHookEvents"], "servedHookEvents"))
356
+ known = set(_str_list(d["knownHookEvents"], "knownHookEvents"))
357
+ if not (served and known):
358
+ raise ValueError("servedHookEvents or knownHookEvents is empty")
359
+ except Exception as e:
360
+ print(
361
+ f"::warning:: assertion-keys.json missing or malformed next to scenario.py ({p}): {e} — "
362
+ "using built-in hook-event lists that may be stale (run `npm run schema`).",
363
+ file=sys.stderr,
364
+ )
365
+ return (
366
+ set(_FALLBACK_SERVED_HOOK_EVENTS),
367
+ set(_FALLBACK_KNOWN_HOOK_EVENTS),
368
+ set(_FALLBACK_LIVE_VERIFIED_HOOK_EVENTS),
369
+ )
370
+ # Tolerate a sidecar generated before liveVerifiedHookEvents existed: fall back rather than
371
+ # dropping to the embedded KNOWN set wholesale, which would undo the 33-name fix. Membership
372
+ # test, NOT truthiness: an explicitly EMPTY list means the firing receipt was withdrawn, and
373
+ # `or` would silently restore the 3-name claim -- widening a receipt, which is the one direction
374
+ # this constant exists to prevent. For the same reason a MALFORMED list verifies nothing: falling
375
+ # back to the built-in list there would widen a receipt the sidecar does not give.
376
+ if "liveVerifiedHookEvents" not in d:
377
+ return served, known, set(_FALLBACK_LIVE_VERIFIED_HOOK_EVENTS)
378
+ try:
379
+ live = set(_str_list(d["liveVerifiedHookEvents"], "liveVerifiedHookEvents"))
380
+ except TypeError as e:
381
+ print(f"::warning:: {e} ({p}) — treating no hook event as live-verified (run `npm run schema`).", file=sys.stderr)
382
+ live = set()
383
+ return served, known, live
363
384
 
364
385
 
365
386
  SERVED_HOOK_EVENTS, KNOWN_HOOK_EVENTS, LIVE_VERIFIED_HOOK_EVENTS = _load_hook_events()
@@ -410,7 +431,7 @@ _EMBEDDED_TOP_LEVEL_KEYS = {
410
431
  "allow_host_writes",
411
432
  "allow_host_hooks", # protocol consent: a staged plugin's hooks run as NATIVE HOST processes # hostloop native-split: consent for a writable connected folder (pre-run gate)
412
433
  "workspace_fixture", # a saved outputs tree staged into outputs/ before turn 1 (scenario-file-relative dir)
413
- "metrics", # numbers read from JSON files the run wrote, reported beside the verdict (RunResult.metrics)
434
+ "metrics", # numbers read from JSON files the run wrote or printed by a command metric's script, reported beside the verdict (RunResult.metrics)
414
435
  }
415
436
 
416
437
 
@@ -424,10 +445,10 @@ def _load_top_level_keys():
424
445
  keys = json.loads(p.read_text(encoding="utf-8")).get("topLevelKeys")
425
446
  if not keys:
426
447
  raise KeyError("topLevelKeys")
427
- return set(keys)
428
- except Exception:
448
+ return set(_str_list(keys, "topLevelKeys"))
449
+ except Exception as e:
429
450
  print(
430
- f"::warning:: assertion-keys.json missing or has no topLevelKeys next to scenario.py ({p}) — "
451
+ f"::warning:: assertion-keys.json missing, malformed or without topLevelKeys next to scenario.py ({p}): {e} — "
431
452
  "using a built-in top-level-key list that may be stale (run `npm run schema`).",
432
453
  file=sys.stderr,
433
454
  )
@@ -500,14 +521,26 @@ def _load_enums():
500
521
  enums = json.loads(p.read_text(encoding="utf-8")).get("enums")
501
522
  if not enums:
502
523
  raise KeyError("enums")
503
- return enums
504
- except Exception:
524
+ if not isinstance(enums, dict):
525
+ raise TypeError("assertion-keys.json enums is not a map")
526
+ except Exception as e:
505
527
  print(
506
- f"::warning:: assertion-keys.json missing or has no enums next to scenario.py ({p}) — "
528
+ f"::warning:: assertion-keys.json missing, malformed or without enums next to scenario.py ({p}): {e} — "
507
529
  "using a built-in enum-value map that may be stale (run `npm run schema`).",
508
530
  file=sys.stderr,
509
531
  )
510
532
  return dict(_EMBEDDED_ENUMS)
533
+ # Each field's values must be a list of strings: a string value would turn the membership test into a substring
534
+ # test, and anything else would crash the lint. A malformed field falls back to its built-in values alone.
535
+ out = {}
536
+ for field, values in enums.items():
537
+ try:
538
+ out[field] = _str_list(values, f"enums.{field}")
539
+ except TypeError as e:
540
+ print(f"::warning:: {e} ({p}) — using its built-in values (run `npm run schema`).", file=sys.stderr)
541
+ if field in _EMBEDDED_ENUMS:
542
+ out[field] = list(_EMBEDDED_ENUMS[field])
543
+ return out
511
544
 
512
545
 
513
546
  # field id -> allowed values (generated from the zod schemas; see _load_enums)
@@ -1484,8 +1517,8 @@ def _hook_block_contradictions(items):
1484
1517
 
1485
1518
  # A multi-leg (`legs:`) scenario: these top-level keys, and per-leg prompt/host_action/session/assert. Kept equal to
1486
1519
  # MultiLegScenarioObject (src/run/multi-leg-scenario.ts); the harness loader is the authority and runs first under
1487
- # the `cowork-harness lint` wrapper.
1488
- _MULTI_LEG_TOP_KEYS = {
1520
+ # the `cowork-harness lint` wrapper. Read from the generated assertion-keys.json at run time; this is its fallback.
1521
+ _EMBEDDED_MULTI_LEG_TOP_KEYS = {
1489
1522
  "name",
1490
1523
  "baseline",
1491
1524
  "session",
@@ -1502,6 +1535,24 @@ _MULTI_LEG_TOP_KEYS = {
1502
1535
  "allow_host_hooks",
1503
1536
  "workspace_fixture",
1504
1537
  }
1538
+
1539
+
1540
+ def _load_multi_leg_top_keys():
1541
+ """A multi-leg scenario's top-level keys, from the generated assertion-keys.json (multiLeg.topLevelKeys), so the
1542
+ unknown-key check can't drift; falls back to the embedded copy (kept equal, test-enforced) with a warning."""
1543
+ p = Path(__file__).resolve().parent / "assertion-keys.json"
1544
+ try:
1545
+ return set(_str_list(json.loads(p.read_text(encoding="utf-8"))["multiLeg"]["topLevelKeys"], "multiLeg.topLevelKeys"))
1546
+ except Exception as e:
1547
+ print(
1548
+ f"::warning:: multiLeg.topLevelKeys missing or malformed in {p}: {e} — "
1549
+ "using a built-in key list that may be stale (run `npm run schema`).",
1550
+ file=sys.stderr,
1551
+ )
1552
+ return set(_EMBEDDED_MULTI_LEG_TOP_KEYS)
1553
+
1554
+
1555
+ _MULTI_LEG_TOP_KEYS = _load_multi_leg_top_keys()
1505
1556
  # The scenario-wide keys a prompt leg runs with (legScenario in src/run/execute.ts).
1506
1557
  _LEG_SHARED_KEYS = (
1507
1558
  "name",
package/AGENTS.md CHANGED
@@ -58,8 +58,10 @@ here**, and the failure is silent. Every one below was hit by an agent working i
58
58
  hand-maintained** — `cassette.v*.json`, `run-result.json`, `doctor.json`, `protocol.v1.json`,
59
59
  `critique-report.json`, `verify-cassettes.json`, `regrade.json`, `schedule-cost.json`. Adding a field to those means editing the TS type **and**
60
60
  the JSON by hand; nothing syncs them. Then regenerate the surface baseline (below).
61
- - **Surface-baseline ordering:** edit the schema → `npm run check:surface` (review the diff, expect
62
- `+N -0 ~0`) → `npm run gen:surface` → `check:surface` again (now `+0 -0 ~0`). Running `gen:surface`
61
+ - **Surface-baseline ordering:** edit the schema → `npm run check:surface` (review the diff: a new property or
62
+ an output-schema addition reads `+N -0 ~0`; a tightened constraint on the scenario or session schema reads `~N`
63
+ by design and needs a major, see RELEASING.md; a loosened one is listed as widened) → `npm run gen:surface` →
64
+ `check:surface` again (now `+0 -0 ~0`). Running `gen:surface`
63
65
  first hides the very diff you were supposed to review.
64
66
  - **A `RunResult` field is a 5-call-site change.** `assembleRunResult` has **5** call sites across
65
67
  `assemble-run-result.ts` / `execute.ts` / `cassette.ts` / `chat-result.ts`, plus the TS type, the