cowork-harness 4.6.1 → 4.7.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 (82) 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 +5 -5
  4. package/.claude/skills/cowork-harness/references/assertion-catalog-outcome-files-tools.md +2 -2
  5. package/.claude/skills/cowork-harness/references/assertion-catalog.md +4 -4
  6. package/.claude/skills/cowork-harness/references/assertions-guide.md +10 -5
  7. package/.claude/skills/cowork-harness/references/authoring.md +4 -3
  8. package/.claude/skills/cowork-harness/references/ci-recipe.md +9 -8
  9. package/.claude/skills/cowork-harness/references/critique.md +3 -3
  10. package/.claude/skills/cowork-harness/references/debugging.md +8 -4
  11. package/.claude/skills/cowork-harness/references/eval.md +4 -2
  12. package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
  13. package/.claude/skills/cowork-harness/references/gotchas.md +3 -1
  14. package/.claude/skills/cowork-harness/references/hillclimb-recipe.md +1 -1
  15. package/.claude/skills/cowork-harness/references/hillclimb.md +22 -2
  16. package/.claude/skills/cowork-harness/references/measurement.md +1 -1
  17. package/.claude/skills/cowork-harness/references/run-record-replay.md +7 -3
  18. package/.claude/skills/cowork-harness/references/scenario-schema.md +8 -5
  19. package/.claude/skills/cowork-harness/references/semantic-judging.md +1 -1
  20. package/.claude/skills/cowork-harness/references/task-recipes.md +28 -11
  21. package/.claude/skills/cowork-harness/scripts/scenario.py +1 -1
  22. package/CHANGELOG.md +196 -0
  23. package/DESIGN.md +2 -2
  24. package/README.md +4 -4
  25. package/RELEASING.md +5 -2
  26. package/SPEC.md +22 -14
  27. package/baselines/desktop-2.31226.1.json +1529 -0
  28. package/baselines/prompts/cowork-system-prompt-fingerprints.json +10 -1
  29. package/dist/assert.js +98 -26
  30. package/dist/critique/command.js +34 -10
  31. package/dist/eval/classify.js +5 -3
  32. package/dist/fixture/cli.js +12 -2
  33. package/dist/fixture/export.js +101 -15
  34. package/dist/fixture/session-tokens.js +111 -0
  35. package/dist/fixture/usage.js +6 -3
  36. package/dist/fixture/workspace.js +73 -8
  37. package/dist/hillclimb/cases.js +7 -6
  38. package/dist/json-schema.js +251 -0
  39. package/dist/run/cassette.js +142 -35
  40. package/dist/run/chat-result.js +1 -0
  41. package/dist/run/chat.js +2 -1
  42. package/dist/run/execute.js +45 -5
  43. package/dist/run/hook-failure-blocks.js +412 -0
  44. package/dist/run/lint-load.js +13 -1
  45. package/dist/run/recorded-agent-version.js +51 -0
  46. package/dist/run/run.js +80 -1
  47. package/dist/run/schema-files.js +65 -0
  48. package/dist/run/verdict.js +8 -1
  49. package/dist/run/verify-context.js +5 -0
  50. package/dist/runtime/container.js +2 -1
  51. package/dist/runtime/hostloop-stage.js +9 -4
  52. package/dist/runtime/hostloop.js +9 -3
  53. package/dist/runtime/microvm.js +2 -1
  54. package/dist/runtime/protocol.js +6 -0
  55. package/dist/runtime/stage.js +4 -2
  56. package/dist/session.js +4 -0
  57. package/dist/types.js +28 -4
  58. package/dist/usage-limit.js +28 -0
  59. package/docs/cassette.md +11 -5
  60. package/docs/ci.md +3 -3
  61. package/docs/cli.md +40 -18
  62. package/docs/companion-skill.md +2 -2
  63. package/docs/critique.md +9 -6
  64. package/docs/eval.md +19 -1
  65. package/docs/fidelity-gaps.md +7 -3
  66. package/docs/hillclimb.md +20 -0
  67. package/docs/maintenance.md +1 -1
  68. package/docs/run-status.md +2 -1
  69. package/docs/scenario.md +92 -18
  70. package/examples/replays/README.md +1 -1
  71. package/examples/replays/example-multiselect-gate.cassette.json +1 -1
  72. package/examples/replays/example-pdf-skill.cassette.json +1 -1
  73. package/examples/replays/hostloop-computer-links.cassette.json +1 -1
  74. package/llms.txt +1 -1
  75. package/package.json +2 -2
  76. package/python/test_scenario_lint.py +1 -1
  77. package/schema/cassette.v15.json +3 -3
  78. package/schema/cassette.v16.json +381 -0
  79. package/schema/critique-report.json +1 -1
  80. package/schema/run-result.json +10 -3
  81. package/schema/scenario.schema.json +30 -4
  82. package/scripts/check-versions.ts +2 -1
@@ -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.6.1
7
- tracks-harness: cowork-harness 4.6.1 (baseline desktop-2.31226.0)
6
+ version: 4.7.1
7
+ tracks-harness: cowork-harness 4.7.1 (baseline desktop-2.31226.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.6.1` (baseline
30
- > `desktop-2.31226.0`). 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.7.1` (baseline
30
+ > `desktop-2.31226.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, and the baseline 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.6.1**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@^4.6.1" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@^4.6.1"`. **Pin `@^4.6.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.
46
+ - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 4.7.1**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@^4.7.1" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@^4.7.1"`. **Pin `@^4.7.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.6.1` (baseline `desktop-2.31226.0`). Part 2 of 3 of the per-key table. The conventions every key shares (an item with several
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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.6.1` (baseline `desktop-2.31226.0`). Part 3 of 3 of the per-key table. The conventions every key shares (an item with several
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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
 
@@ -21,9 +21,9 @@ refused at load: no question reaches the harness to grade.
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 |
24
- | `hook_event_blocked: <HookEvent> \| {event, tool?, via?, min?, max?}` | that command hook **blocked**: a `hook_response` frame for the event denied — by exit code 2, or (object form only) by a JSON decision on stdout on a frame the agent marks `outcome: success` (exit 0, or an HTTP hook's 2xx status): a PreToolUse `permissionDecision: "deny"` or a top-level `decision: "block"`, which the agent treats alike. A JSON decision on any other frame, such as exit 1, is unreadable, since the frame does not show whether the agent applied it; a hook the agent cancelled (timed out or aborted) decided nothing. Only stdout that parses whole as a JSON object decides, since stdout is the hook's ordinary output too: a word like `deny`, prose, or JSON after other text is no decision. The bare event means at least one frame with exit code 2, the channel this key has always counted; a JSON deny is not counted there, and when the bare form fails it names any JSON deny it saw. The **object form** counts blocking frames by either channel, one per hook run (PreToolUse runs once per matching tool call, subagent calls included); `via: exit2` counts exit code 2 alone, `via: json` the JSON deny alone, and `via: any` (the default) either. `{event: Stop, max: 0}` is the per-event negative, and fails on a hook that denied by JSON alone. `tool` keeps only frames whose `hook_name` is `<event>:<tool>`: the tool that **fired** (or the SessionStart source), not the matcher in hooks.json. The shell is `Bash` at `container` and `mcp__workspace__bash` at `hostloop`, so the wrong name reports "never fired". An event whose frames carry no tool name (`Stop` is named `Stop`) reports evidence-unavailable for any `tool`. Fails naming the exit codes seen when the hook fired without blocking; fails "never fired" when no frame is in scope, `max: 0` included, so a disabled hook never passes. A frame whose outcome cannot be read (no exit code, a hook that started and never answered, stdout a redaction policy rewrote or the agent truncated, output the agent did not apply as a verdict) is evidence-unavailable when it could change the verdict. A Python hook whose script is missing (`python3 missing.py`) also exits 2, so it reads as a block. Cannot-verify when the run has no context events. Frames carry no plugin id: when a second staged plugin declares the event, or a `protocol` run can see hooks installed on the host, the run warns that the frame cannot be attributed to the plugin under test; the verdict does not change. Content-class |
25
- | `no_hook_event_blocked: true \| {event, tool?}` | no command hook blocked: every `hook_response` frame (or every frame for `event`) neither exited 2 nor denied by JSON, the same rule as `hook_event_blocked`'s object form. **Never vacuous**: no frame in scope is evidence-unavailable, and `true` also needs a frame of an event other than SessionStart/Setup, because those stream even when hook events were not requested (they stream only when a staged plugin declares hooks). A frame whose outcome cannot be read (no exit code, a hook that started and never answered, stdout a redaction policy rewrote or the agent truncated, output the agent did not apply as a verdict) is evidence-unavailable. Frames carry no plugin id, so a second staged plugin's hook, or a host plugin's at `protocol`, counts as a frame here (the run warns). Distinct from `no_hook_blocked`, which reads the harness's **own** PreToolUse callbacks from `controlOut`. Content-class |
26
- | `hook_decision: {event, decision, tool?, min?, max?}` | counts the `hook_response` frames for `event` whose decision is `decision`, read from the hook's stdout JSON on a frame the agent marks `outcome: success` (exit 0, or an HTTP hook's 2xx status): `hookSpecificOutput.permissionDecision` on `PreToolUse` and `PreModelSwitch`, where it overrides a top-level `decision`, and which the agent ignores on other events; for `PermissionRequest` `hookSpecificOutput.decision.behavior`; for `Elicitation` and `ElicitationResult` an `action: decline`, a deny; else the top-level `decision`; or from exit code 2, which is a deny. A JSON decision on any other frame, such as exit 1, is unreadable, since the frame does not show whether the agent applied it; a hook the agent cancelled (timed out or aborted) decided nothing. `decision` is one of `allow`, `deny`, `ask`, `defer`, as the agent applies them, plus two aliases: `block` means `deny` and `approve` means `allow`. So `deny` matches exit 2, `permissionDecision: "deny"` and `decision: "block"` alike, and the message shows which. Range and `tool` as for `hook_event_blocked` (default: at least one). Only stdout that parses whole as a JSON object decides; empty or non-JSON stdout is no decision, not an error, unless the agent rejected the frame's output, so `allow` never matches a hook that printed nothing. Fails "never fired" when no frame is in scope. A frame whose decision cannot be read (stdout a redaction policy rewrote so it does not parse, truncated output, a frame with no `stdout` field, a decision shape it does not model, a top-level `decision` other than `approve` or `block`, output the agent did not apply (`outcome: "error"` with exit 0, or stderr that opens with the agent's rejection of the JSON, its refusal to read an incomplete capture, or its failure to run the hook), no exit code, a hook that started and never answered, a `hookSpecificOutput` whose `hookEventName` is missing or names another event) is evidence-unavailable when it could change the verdict. Frames carry no plugin id: when a second staged plugin declares the event, or a `protocol` run can see hooks installed on the host, the run warns that the frame cannot be attributed to the plugin under test; the verdict does not change. `record` warns when its redaction policy rewrites hook stdout that this key, `hook_event_blocked` or `no_hook_event_blocked` reads, since the committed cassette's verdict could then only be evidence-unavailable. Content-class, grades on replay |
24
+ | `hook_event_blocked: <HookEvent> \| {event, tool?, via?, min?, max?}` | that command hook **blocked**: a `hook_response` frame for the event denied — by exit code 2, or (object form only) by a JSON decision on stdout on a frame the agent marks `outcome: success` (exit 0, or an HTTP hook's 2xx status): a PreToolUse `permissionDecision: "deny"` or a top-level `decision: "block"`, which the agent treats alike. A JSON decision on any other frame, such as exit 1, is unreadable, since the frame does not show whether the agent applied it; a hook the agent cancelled (timed out or aborted) decided nothing by its output. A hook that sets `onFailure: "block"` (agent 2.1.295 and later; a command hook that is not `async` or `asyncRewake`, or an http hook) is the exception: the agent turns its failure or its timeout into a block after it emits the frame. So on an event the run has such a hook on, a timed-out frame and a failed one (any exit but 0 and 2, or an HTTP status outside 2xx) are unreadable. The same holds when the run's hook sources could not all be read (`RunResult.hookFailureBlocks`). Stop, SubagentStop, TaskCompleted and TeammateIdle are exempt, as the agent does not block there. Only stdout that parses whole as a JSON object decides, since stdout is the hook's ordinary output too: a word like `deny`, prose, or JSON after other text is no decision. The bare event means at least one frame with exit code 2, the channel this key has always counted; a JSON deny is not counted there, and when the bare form fails it names any JSON deny it saw. The **object form** counts blocking frames by either channel, one per hook run (PreToolUse runs once per matching tool call, subagent calls included); `via: exit2` counts exit code 2 alone, `via: json` the JSON deny alone, and `via: any` (the default) either. `{event: Stop, max: 0}` is the per-event negative, and fails on a hook that denied by JSON alone. `tool` keeps only frames whose `hook_name` is `<event>:<tool>`: the tool that **fired** (or the SessionStart source), not the matcher in hooks.json. The shell is `Bash` at `container` and `mcp__workspace__bash` at `hostloop`, so the wrong name reports "never fired". An event whose frames carry no tool name (`Stop` is named `Stop`) reports evidence-unavailable for any `tool`. Fails naming the exit codes seen when the hook fired without blocking; fails "never fired" when no frame is in scope, `max: 0` included, so a disabled hook never passes. A frame whose outcome cannot be read (no exit code, a hook that started and never answered, stdout a redaction policy rewrote or the agent truncated, output the agent did not apply as a verdict) is evidence-unavailable when it could change the verdict. A Python hook whose script is missing (`python3 missing.py`) also exits 2, so it reads as a block. Cannot-verify when the run has no context events. Frames carry no plugin id: when a second staged plugin declares the event, or a `protocol` run can see hooks installed on the host, the run warns that the frame cannot be attributed to the plugin under test; the verdict does not change. Content-class |
25
+ | `no_hook_event_blocked: true \| {event, tool?}` | no command hook blocked: every `hook_response` frame (or every frame for `event`) neither exited 2 nor denied by JSON, the same rule as `hook_event_blocked`'s object form. **Never vacuous**: no frame in scope is evidence-unavailable, and `true` also needs a frame of an event other than SessionStart/Setup, because those stream even when hook events were not requested (they stream only when a staged plugin declares hooks). A frame whose outcome cannot be read (no exit code, a hook that started and never answered, stdout a redaction policy rewrote or the agent truncated, output the agent did not apply as a verdict, or a failed or timed-out frame on an event with an `onFailure: "block"` hook — see `hook_decision`) is evidence-unavailable. Hooks on PreCompact, PostCompact, ConfigChange, DirectoryAdded, Elicitation, ElicitationResult, InstructionsLoaded, Notification, SessionEnd, StopFailure, WorktreeCreate, WorktreeRemove, CwdChanged and FileChanged stream no frame at all, so a block there never shows: `true` is evidence-unavailable when one of those events has an `onFailure: "block"` hook, or when the run's hook sources could not all be read. Frames carry no plugin id, so a second staged plugin's hook, or a host plugin's at `protocol`, counts as a frame here (the run warns). Distinct from `no_hook_blocked`, which reads the harness's **own** PreToolUse callbacks from `controlOut`. Content-class |
26
+ | `hook_decision: {event, decision, tool?, min?, max?}` | counts the `hook_response` frames for `event` whose decision is `decision`, read from the hook's stdout JSON on a frame the agent marks `outcome: success` (exit 0, or an HTTP hook's 2xx status): `hookSpecificOutput.permissionDecision` on `PreToolUse` and `PreModelSwitch`, where it overrides a top-level `decision`, and which the agent ignores on other events; for `PermissionRequest` `hookSpecificOutput.decision.behavior`; for `Elicitation` and `ElicitationResult` an `action: decline`, a deny; else the top-level `decision`; or from exit code 2, which is a deny. A JSON decision on any other frame, such as exit 1, is unreadable, since the frame does not show whether the agent applied it; a hook the agent cancelled (timed out or aborted) decided nothing by its output. A hook that sets `onFailure: "block"` (agent 2.1.295 and later; a command hook that is not `async` or `asyncRewake`, or an http hook) is the exception: the agent turns its failure or its timeout into a block after it emits the frame. So on an event the run has such a hook on, a timed-out frame and a failed one (any exit but 0 and 2, or an HTTP status outside 2xx) are unreadable. The same holds when the run's hook sources could not all be read (`RunResult.hookFailureBlocks`). Stop, SubagentStop, TaskCompleted and TeammateIdle are exempt, as the agent does not block there. A run whose agents all report 2.1.293 or earlier in their init frames has no `onFailure`, so its inventory is empty whatever its sources say. The inventory reads as unknown when a hook source exists but cannot be read, or when there is too much to walk. The likeliest case is one installed plugin with a broken manifest or `hooks.json`. Rarer: a settings file that does not parse; a skill, command or agent whose frontmatter declares `hooks:` and does not parse; more than 50,000 entries walked in all, across the staged plugins and the config dir's skills, commands, agents and plugins. That needs a very large tree, or a plugins dir whose `installed_plugins.json` is missing or does not parse, so every plugin dir is walked. `decision` is one of `allow`, `deny`, `ask`, `defer`, as the agent applies them, plus two aliases: `block` means `deny` and `approve` means `allow`. So `deny` matches exit 2, `permissionDecision: "deny"` and `decision: "block"` alike, and the message shows which. Range and `tool` as for `hook_event_blocked` (default: at least one). Only stdout that parses whole as a JSON object decides; empty or non-JSON stdout is no decision, not an error, unless the agent rejected the frame's output, so `allow` never matches a hook that printed nothing. Fails "never fired" when no frame is in scope. A frame whose decision cannot be read (stdout a redaction policy rewrote so it does not parse, truncated output, a frame with no `stdout` field, a decision shape it does not model, a top-level `decision` other than `approve` or `block`, output the agent did not apply (`outcome: "error"` with exit 0, or stderr that opens with the agent's rejection of the JSON, its refusal to read an incomplete capture, or its failure to run the hook), no exit code, a hook that started and never answered, a `hookSpecificOutput` whose `hookEventName` is missing or names another event) is evidence-unavailable when it could change the verdict. Frames carry no plugin id: when a second staged plugin declares the event, or a `protocol` run can see hooks installed on the host, the run warns that the frame cannot be attributed to the plugin under test; the verdict does not change. `record` warns when its redaction policy rewrites hook stdout that this key, `hook_event_blocked` or `no_hook_event_blocked` reads, since the committed cassette's verdict could then only be evidence-unavailable. Content-class, grades on replay |
27
27
  | `hook_output_contains: {event, stream?, text \| matches}` / `hook_output_not_contains: {…}` | a command hook **printed** (or never printed) a text on `stdout` / `stderr` / either (`stream`, default `any`) — some (or no) `hook_response` frame for `event` carries `text` (literal, case-sensitive) or `matches` (regex, case-insensitive, no multiline flag — `^`/`$` anchor the whole stream). For a hook that fails open on stderr while exiting 0. No frame for the event FAILS both. On a redacted stream, a literal miss and any `matches` result are evidence-unavailable for either key, while a literal hit outside a token counts; a miss is also evidence-unavailable on a missing field, a hook that started without a response, or output the agent truncated. Frames carry no plugin id (a second plugin, or at `protocol` without a sealed config dir a host-installed one, can answer). Content-class. Recorded end-to-end for `Stop` |
28
28
  | `vm_path_denied: true` | **`fidelity: hostloop` only** — at least one recorded path denial (`RunResult.pathDenials`, any source) targeted a `/sessions` VM path — evidence-unavailable if path-denial telemetry is absent. Replay: needs a `controlOut` cassette. Any other tier FAILS "cannot verify". **Only `true` is valid** |
29
29
  | `path_denied: {tool?, path_matches?, source?, agent_scope?}` | **`fidelity: hostloop` only** — a path denial matching ALL given matchers (`tool` glob, `path_matches` regex, `source` ∈ pretooluse/can_use_tool/permission_denied, `agent_scope` ∈ main/subagent/any) was recorded. Replay: needs a `controlOut` cassette. Any other tier FAILS "cannot verify" |
@@ -42,7 +42,7 @@ refused at load: no question reaches the harness to grade.
42
42
  | `max_peak_rss_bytes: <N>` | peak sampled RSS of the agent sandbox ≤ N bytes (`RunResult.resources.peakRssBytes`) — live-only: replay never spawns a sandbox to sample, so evidence-unavailable on replay/protocol (never a vacuous pass); also evidence-unavailable when sampling captured no RSS value |
43
43
  | `semantic_matches: {rubric: [...], min_pass?, judge_model?, include_subagent_text?, include_fork_results?, evidence_files?: [..]}` | a pinned LLM judge grades each fixed `rubric` claim against the run's answer: the final result text, the transcript and the files the agent authored. The transcript is top-level `assistant_text` only — it excludes every `tool_use`/`tool_result` and sub-agent text unless opted in. Full semantics (evidence scope, fork results, refusals, judge provenance, cost): [semantic-judging.md](semantic-judging.md). On `lane: remote`: judged on the transcript only; `evidence_files` is rejected at load. |
44
44
  | `semantic_pairwise: {refs?: [..], rubric?: [..], pass_if?, order?, judge_model?, include_subagent_text?, include_fork_results?, evidence_files?: [..]}` | a pinned LLM judge compares this run's judged document with a **frozen reference** — the same document an earlier run (usually the baseline) produced, written once by `ref freeze` and never regenerated — and answers win, tie, loss or `both_bad`. **The judged document is the one `semantic_matches` builds** (final message + transcript + authored files, same `evidence_files` / `include_subagent_text` / `include_fork_results` options), so the transcript **excludes every `tool_use`/`tool_result`** and a criterion about whether a tool was called cannot be judged from it. `refs` are reference stores relative to the scenario file; the run is judged against every one (under `hillclimb run`, against the flow's own references instead — only the baseline's gates `pass_if`, a later variant's is a metric recorded with `gate: false`), and `pass_if` (default `not_worse`) must hold against each gating one: `win`, `not_worse` (win or tie), or `any` (graded at all — a metric only). `both_bad` fails `win` and `not_worse`. Which output the judge sees first is a seeded coin per run, assert and reference (`order: both` judges both orders: a win in one and a loss in the other is position bias and scores as a tie; any other disagreement keeps the worse outcome; each order's own outcome is recorded as `pairwise[].orders.candidate_first` / `.ref_first`, absent for a single-order grade, and a judge that favours whichever output it sees first shows across runs as `candidate_first` winning more often than `ref_first`). The judge never sees the words "reference" or "baseline", and the stored reference is scrubbed with the grading process's secret set before it reads it (`pairwise[].refRedactions`). A reference must have been frozen with the same evidence options and for the same prompt; a missing, damaged, differently-scoped or other-prompt one **refuses the run before it spends anything**, as does a reference store inside any mounted source (the agent could read its own answer key). Unavailable evidence refuses with `semantic_matches`' typed reasons. Per-reference outcomes land in `assertions[].pairwise`. **LIVE-ONLY** — skipped on replay. |
45
- | `artifact_json: {artifact, path, …}` | assert a JSON artifact's contents — `equals`/`gt`/`in`/`exists`/`absent`/`is_null` over a dotted `path` (`in` = membership in a list, for a stochastic/LLM value; `absent` ≠ `is_null`; an unresolved intermediate fails loud). A glob `artifact` (`*`, `?`, `**`; `[` is literal) needs `match: each` (every match) or `match: any` (≥1), e.g. `{artifact: outputs/artifacts/runs/*/run_status.json, match: each, path: status, equals: complete}`. It matches files under the user-visible roots only; uploaded inputs are not matched. Zero matches fail; >200 matches, a link (a matched file, or a symlinked directory on the way to one), a body-less match or an incomplete walk (over 32 levels below the glob's fixed part, over 20,000 entries, or an unreadable directory) is evidence-unavailable. Names match exactly, with no case or Unicode folding. `authored: true` applies to every match. A glob that can reach no user-visible root (an `uploads/` glob) fails and says why. At `record`, a match stored hash-only over the body cap, or an artifact walk that could not see the whole tree, is refused, as for a literal path. Rejected at load on `lane: remote`, in every form (that lane's container filesystem is not locally observable) |
45
+ | `artifact_json: {artifact, path, …}` | assert a JSON artifact's contents — `equals`/`gt`/`in`/`exists`/`absent`/`is_null` over a dotted `path` (`in` = membership in a list, for a stochastic/LLM value; `absent` ≠ `is_null`; an unresolved intermediate fails loud). A glob `artifact` (`*`, `?`, `**`; `[` is literal) needs `match: each` (every match) or `match: any` (≥1), e.g. `{artifact: outputs/artifacts/runs/*/run_status.json, match: each, path: status, equals: complete}`. It matches files under the user-visible roots only; uploaded inputs are not matched. Zero matches fail; >200 matches, a link (a matched file, or a symlinked directory on the way to one), a body-less match or an incomplete walk (over 32 levels below the glob's fixed part, over 20,000 entries, or an unreadable directory) is evidence-unavailable. Names match exactly, with no case or Unicode folding. `authored: true` applies to every match. A glob that can reach no user-visible root (an `uploads/` glob) fails and says why. At `record`, a match stored hash-only over the body cap, or an artifact walk that could not see the whole tree, is refused, as for a literal path. Rejected at load on `lane: remote`, in every form (that lane's container filesystem is not locally observable). `schema: <JSON Schema>` (draft 2020-12, inline or `{file: <path>}` read at load and inlined) checks the value at `path` (or the whole document): the first 5 errors with JSON paths; refused at load: `format` (use `pattern`), `$id`, a non-local `$ref`, `$dynamicRef`/`$dynamicAnchor`/`$recursiveRef`/`$recursiveAnchor`, `nullable`, the `content*` keywords, the draft-07 list-form `items` (use `prefixItems`), a `__proto__` key, NaN/Infinity in `enum`/`const`/`default`/`examples`, a `$ref` to the root with nothing to stop the recursion, an unknown keyword, a type-specific keyword (`required`, `properties`, `items`, `minimum`, …) with no `type`, a root with no validating keyword, a `$schema` other than draft 2020-12, a schema deeper than 64 levels or one that contains itself (a YAML alias loop; write recursion as a local `$ref` to `$defs`) |
46
46
  | `computer_links_resolve: true` | every `computer://` link in the model-visible transcript resolves to an artifact that exists in the run's collected outputs/mounts — a dangling link fails, naming which target was checked (a live host path, the collected work tree, or the replay manifest). **Requires ≥1 link** (zero links fails — use `computer_links_resolve_if_present` for the presence-free variant). **Only `true` is valid** (`false` is rejected by the schema) **Sees top-level `assistant_text` only — it excludes every `tool_use`/`tool_result`**, so a `computer://` link that appeared only inside a tool call or its result is invisible to it. Rejected at load on `lane: remote`. |
47
47
  | `computer_links_resolve_if_present: true` | like `computer_links_resolve` but passes vacuously when the transcript has zero `computer://` links — the presence-free variant. **Only `true` is valid** **Sees top-level `assistant_text` only — it excludes every `tool_use`/`tool_result`**, so a `computer://` link that appeared only inside a tool call or its result is invisible to it. Rejected at load on `lane: remote`. |
48
48
 
@@ -1,6 +1,6 @@
1
1
  # Assertion catalog: outcome, transcript, files and artifacts, tools, tool results
2
2
 
3
- Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`). Part 1 of 3 of the per-key table. The conventions every key shares (an item with several
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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
 
@@ -16,7 +16,7 @@ keys is an AND; glob vs regex name matching) and the verdict-signal table are in
16
16
  | `no_delete_in_outputs: true` | no delete op touched `mnt/outputs` — **only `true` is valid**; `false` is rejected. Checks on **every** baseline. Omitting it: on a baseline recording outputs `rw` (Desktop before 2.16120.0) a detected delete still fails via the `outputs_delete` verdict signal, which fires *because* the key was not authored (`allow_outputs_delete: true` accepts an intended one); on `rwd` (2.16120.0+, including `latest`, where Cowork allows deletes in outputs) nothing checks outputs deletes, so author this key to keep the check. Detection is a post-run bash-command scan plus a per-turn filesystem diff of `outputs/`, not mount enforcement, so a green means none was *detected*. Fails when the diff proves the delete, when a delete in command/call position has an `outputs/` path as its own operand, or when the diff could not verify; a hit resting only on the detector's inference (e.g. a Python variable named `rm`) with a clean diff passes, and the `outputs_delete_unconfirmed` warn is still raised in the run output — see that code for the classes of real delete that land there |
17
17
  | `no_delete_in_mounts: true` | no delete op touched `outputs` or any `rw` connected folder, except mounts waived by `allow_delete_in`. Covers `outputs` on **every** baseline, including those where Cowork allows an outputs delete (unless outputs is waived, authoring it arms the outputs check, so a filesystem-proven delete fails as the `outputs_delete` signal, which `allow_outputs_delete` waives). Production denies `unlink`/`rmdir` on a `rw` connected folder until approval, so `no_delete_in_outputs` covers only part of the rule. **only `true` is valid** |
18
18
  | `no_unexpected_files: [<glob>, …]` | every **newly created** file under a user-visible root matches ≥1 glob (workRoot-relative paths; `**` = whole path segment for any depth — use `outputs/handoff/**` for per-run subdirs); `[]` = no new files; **new-files-only** — overwriting a pre-existing file in place is invisible (use content-level producer stamping); live/verify-run without a pre-run manifest ⇒ evidence-unavailable hard-fail (live runs capture the baseline only when this key is asserted; recordings always capture); an **incomplete post-run filesystem walk** (an unreadable subtree — a permission/I-O error) also fails evidence-unavailable rather than reporting "no strays" over a partial tree — distinct from the missing-manifest case (a `--resume` run), which fails for a different reason; captured on every live sandbox tier including microvm (its outputs are snapshotted from the VM into the run dir); replay needs `cassette.preRunPaths` (≥0.24 recordings) — cassettes without it **exclude** the key with a loud warning. ⚠️ **Not a stand-in for "file X must not exist":** there is no negative-existence key, and inverting this one is a different claim — it is new-files-only (a pre-existing file is invisible to it) and needs a pre-run manifest. Assert the content-level consequence instead (`tool_not_called`, or the absent `artifact_json`) rather than widening an allowlist for every incidental lock/temp file. Rejected at load on `lane: remote`. |
19
- | `input_unmodified: <glob> \| [<glob>, …]` | a single glob or a list; every **pre-existing** file (incl. uploaded files under `uploads/**`) whose workRoot-relative path matches ≥1 glob keeps an unchanged content hash after the run — the in-place-mutation companion to `no_unexpected_files`'s new-files check (`[]` is rejected by the schema — list at least one glob); a glob that matches **no** pre-run path fails loud (a typo or renamed mount would otherwise verify zero files and pass vacuously); a matched file that was deleted counts as a content change (fails); live/verify-run without a pre-run hash manifest ⇒ evidence-unavailable hard-fail (a `--resume` run); captured on every live sandbox tier including microvm; replay needs `cassette.preRunHashes` — cassettes without it **exclude** the key with a loud warning; on replay it compares against the manifest's recorded `sha256`, never a re-hash of the materialized tree. On `lane: remote` it still reads the local stand-in for the inputs (unconfirmed). |
19
+ | `input_unmodified: <glob> \| [<glob>, …]` | a single glob or a list; every **pre-existing** file (incl. uploaded files under `uploads/**`) whose workRoot-relative path matches ≥1 glob keeps an unchanged content hash after the run — the in-place-mutation companion to `no_unexpected_files`'s new-files check (`[]` is rejected by the schema — list at least one glob); a glob that matches **no** pre-run path fails loud (a typo or renamed mount would otherwise verify zero files and pass vacuously); a matched file that was deleted counts as a content change (fails); live/verify-run without a pre-run hash manifest ⇒ evidence-unavailable hard-fail (a `--resume` run); captured on every live sandbox tier including microvm; replay needs `cassette.preRunHashes` — cassettes without it **exclude** the key with a loud warning; on replay it compares against the manifest's recorded `sha256`, never a re-hash of the materialized tree. On `lane: remote` it still reads the local stand-in for the inputs, which is right there: an edit to a staged copy inside the cloud container does not reach the device. |
20
20
  | `self_heal_ran: <bool>` | a bash command the model wrote did (not) name a plugin under `/sessions/<id>/mnt/.local-plugins/` or `/sessions/<id>/mnt/.remote-plugins/` — the plugin-root self-heal path. It reads the command as the model wrote it, so a host plugin path that `hostloop`'s bash tool rewrote to the VM mount does not count |
21
21
  | `file_absent: <path>` | the named path does **not** exist under the work root after the run — the direct negative-existence key. Do NOT invert `no_unexpected_files` for this: that is an allowlist over NEW files only, and it needs a pre-run manifest. **LIVE/verify-run only** (a cassette records no walk health, so absence is unprovable on replay); rejected at load on `lane: remote`; evidence-unavailable on `preRunOrigin: remote-unavailable` |
22
22
  | `artifact_text: {artifact, contains?: [..], not_contains?: [..], matches?, not_matches?}` | assert over a delivered artifact's TEXT body — `artifact_json`'s companion for non-JSON files, and how you prove an internal name did not leak into a file the user receives. Literal path (no glob), so one entry per delivered surface. Manifest-class; body-less / symlinked / over-cap targets fail evidence-unavailable, and a non-UTF-8 body fails the NEGATIVE matchers rather than passing against bytes it never read. Rejected at load on `lane: remote` (that lane's container filesystem is not locally observable) |
@@ -1,6 +1,6 @@
1
1
  # Assertion catalog
2
2
 
3
- Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`). Every `assert:` key with its semantics, and the
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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
 
@@ -35,8 +35,8 @@ case-insensitive; **single-quote** the regex in YAML (double-quoted YAML eats ba
35
35
  breaks — use `'\d'`); the transcript is one concatenated string, so use `[\s\S]`, not `.`, to span
36
36
  turns. Use `transcript_matches` only for **stable lexical markers**, not semantic content the model
37
37
  paraphrases (that re-records red). Structured JSON → assert it in YAML with **`artifact_json`** (dotted
38
- `path` + operator); use the pytest lane (`assert_artifact_json`) only for predicates too complex for a
39
- dotted path.
38
+ `path` + operator, or `schema:` for a whole value's shape); use the pytest lane (`assert_artifact_json`) only for
39
+ predicates neither can express.
40
40
 
41
41
  **VerdictSignals in `result.verdict.signals`:** `computeVerdict` pushes signals into `result.verdict.signals`; eleven
42
42
  are **fail**-severity (they flip the run's pass/exit code even though `result.result` itself stays
@@ -47,7 +47,7 @@ codes (`VerdictSignal["code"]` in `src/run/verdict.ts`):
47
47
  |---|---|---|
48
48
  | `assertion` | fail | An authored `assert:` item failed |
49
49
  | `result_error` | fail | The run's SDK result was `"error"` |
50
- | `usage_limit` | fail | Usage/quota limit hit (not a skill failure) — retry after the limit resets. Emitted when `RunResult.resultErrorKind === "usage_limit"` |
50
+ | `usage_limit` | fail | Usage/quota limit hit (not a skill failure) — retry after the limit resets. Emitted when `RunResult.resultErrorKind === "usage_limit"` — including when a sub-agent hit the limit and the main loop ended `success`, where the message names the sub-agent and says to check its model and the account's plan instead of promising a reset (`decisions[]` row `subagent_usage_limit`) |
51
51
  | `transport_error` | fail | The connection dropped mid/after-run |
52
52
  | `permissive_auto_allow` | fail | A cowork-parity auto-allow real Cowork would block (opt out: `allow_permissive_auto_allow`) |
53
53
  | `outputs_delete` | fail | An unauthorized delete touched `mnt/outputs` on a baseline that records outputs `rw` (not raised on `rwd`, Desktop 2.16120.0+, unless `no_delete_in_outputs` or `no_delete_in_mounts` (outputs not waived) is authored — with `no_delete_in_outputs` that assertion fails instead; with `no_delete_in_mounts` this signal itself fires, and its message names that key), confirmed: the per-turn filesystem diff proves it, a delete in command/call position has an `outputs/` path as its own operand, or the diff could not verify the turn. Authoring `no_delete_in_outputs` moves it into that assertion; `allow_outputs_delete` waives it |
@@ -1,6 +1,6 @@
1
1
  # Assertions guide
2
2
 
3
- Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`). 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.7.1` (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.
4
4
 
5
5
  ### Assertions: two orthogonal axes
6
6
 
@@ -10,7 +10,7 @@ Conflating these is the **biggest landmine**. An assertion key has two independe
10
10
  `egress_*`, `file_exists`, `user_visible_artifact`, `result`) are robust. Free-text content is
11
11
  not: match prose with `transcript_matches` / `transcript_contains` (stable lexical markers only —
12
12
  not semantic content the model paraphrases, which re-records red); check structured JSON with YAML
13
- `artifact_json` (or the [pytest lane](https://github.com/yaniv-golan/cowork-harness/blob/main/python/README.md) for complex predicates), not via a transcript substring.
13
+ `artifact_json` (a dotted `path` + operator, or `schema:` for a whole value's shape; or the [pytest lane](https://github.com/yaniv-golan/cowork-harness/blob/main/python/README.md) for complex predicates), not via a transcript substring.
14
14
  To check a command that RAN (not one the agent mentioned), use the object form `tool_called: {tool: <name>, input: {command: <regex>}, scope?, result?}` — see [assertion-catalog-outcome-files-tools.md](./assertion-catalog-outcome-files-tools.md).
15
15
  - **Axis B — survives `replay`?** *Independent of Axis A.* On the token-free `replay` lane, only
16
16
  **content keys** evaluate; filesystem / egress keys are skipped (live-only) — loudly, via an
@@ -51,7 +51,7 @@ them by what you're trying to prove:
51
51
  | the user was **told something specific** at a gate | `question_context: {when_question, matches}` — a regex over the question label + option labels + option **descriptions**. Reach for this when the wording may land in an option's `description`, which `question_asked` and `question_options` cannot see |
52
52
  | every gate offered exactly one (or at most N) options of a kind | `question_option_count: {matches, exactly}` — counts the option LABELS matching a regex on EVERY sub-question asked (`when_question` narrows); zero sub-questions asked fails |
53
53
  | every gate was answered by a scripted rule (an unattended run that still asks) | `gates_all_scripted: true` with `gate_answer_count_min: 1` — fails naming any gate the LLM decider, `first`, or an external or human decider answered; `{include_permissions: true}` also fails cowork parity's permissive auto-allow (replay needs a `controlOut` cassette) |
54
- | a **plugin's** command hook (any event: PreToolUse, Stop, …) blocked, didn't block, or decided | `hook_event_blocked: <event>` (exit 2) or `{event, tool?, via?, max: 0}`, `no_hook_event_blocked: true`, `hook_decision: {event, decision}` — stream content, no `controlOut` needed |
54
+ | a **plugin's** command hook (any event: PreToolUse, Stop, …) blocked, didn't block, or decided | `hook_event_blocked: <event>` (exit 2) or `{event, tool?, via?, max: 0}`, `no_hook_event_blocked: true`, `hook_decision: {event, decision}` — stream content, no `controlOut` needed. A failed or timed-out frame on an event with an `onFailure: "block"` hook is evidence-unavailable, and so is unscoped `no_hook_event_blocked: true` when such a hook sits on an event that streams no frame |
55
55
  | the **harness's own** hook callbacks (the built-in Task hook, a custom hook bundle) blocked / didn't block a tool | `hook_blocked: <regex>`, `no_hook_blocked: true` (replay needs a `controlOut` cassette) — a plugin's hook never reaches this list, so `no_hook_blocked` passes over a plugin block |
56
56
  | every MCP round-trip succeeded | `no_mcp_error: true` (**live-only**) |
57
57
  | a context compaction happened | `compaction_occurred: true` |
@@ -83,7 +83,11 @@ Start the run from the state the earlier steps leave behind instead:
83
83
 
84
84
  1. Run the pipeline once to the point you want (or stop it there) with `--keep`, then
85
85
  `cowork-harness fixture export <run-dir> --out fixtures/after-scoring` — it copies the run's `outputs/`
86
- byte-for-byte and refuses (writing nothing) a file that carries a secret or a host path. Commit the directory.
86
+ byte-for-byte (apart from `--session-paths`, below) and refuses (writing nothing) a file that carries a secret or a host path. Commit the directory.
87
+ If the skill records its own paths and reads them back (an outputs-dir probe, a sub-agent's output path, a
88
+ deliverable path it compares as a string), the export refuses those files as `run_path`: add
89
+ `--session-paths` and staging writes the new run's session paths into them (not at `fidelity: protocol`).
90
+ `--exclude <path>` drops a file the step can re-create instead.
87
91
  2. Point the scenario at it and ask for the late step only:
88
92
 
89
93
  ```yaml
@@ -99,7 +103,8 @@ Start the run from the state the earlier steps leave behind instead:
99
103
  `semantic_matches` does not grade it (a rewritten one is graded). A `file_exists`/`user_visible_artifact`/
100
104
  `artifact_text`/`artifact_json` on a fixture path is refused at load unless it says `authored: true`
101
105
  (the step must write it) or `authored: false` (inheriting it is fine) — otherwise it would pass on the
102
- fixture alone.
106
+ fixture alone. On replay, a body assertion (`artifact_text`/`artifact_json`) on an untouched binary fixture
107
+ file, or one staging wrote session paths into, is evidence-unavailable: the cassette records those hash-only.
103
108
 
104
109
  What it models: re-invoking the skill in the same Cowork session after it stopped mid-work or finished — the
105
110
  files persist in `outputs/` and the skill resumes from them. The only difference is that the prior conversation
@@ -1,6 +1,6 @@
1
1
  # Authoring a scenario
2
2
 
3
- Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`). 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.7.1` (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.
4
4
 
5
5
  ## Part I — AUTHOR a scenario
6
6
 
@@ -69,7 +69,8 @@ rather than passing. A check that depends on files inside the agent's container
69
69
  rejected at LOAD time (`lane-remote-incompatible-key` in lint): delivery by location, file bodies
70
70
  (`artifact_text`/`artifact_json`), absence (`file_absent`/`no_unexpected_files`), `computer://` links,
71
71
  `no_lost_write_back`, `file_exists` with `authored: true`, and `semantic_*` with `evidence_files`. `semantic_*` without it is judged on the transcript
72
- only. `input_unmodified` still reads the local stand-in for the user's inputs (unconfirmed for the cloud lane), and
72
+ only. `input_unmodified` still reads the local stand-in for the user's inputs (a cloud run's edit of a staged copy does not
73
+ reach the device), and
73
74
  plain `file_exists` is the proxy for a written path. Delivery itself is the `delivery_unobservable` WARN (see
74
75
  `run-record-replay.md`).
75
76
 
@@ -283,7 +284,7 @@ cowork-harness lint scenarios/*.yaml
283
284
  | `unpaired-gates-all-scripted` | WARN | `gates_all_scripted` with neither `gate_answer_count_min` (≥ 1) nor `questions_count_max` — zero gates passes it, so nothing says whether gates were expected |
284
285
  | `scenario-invalid` | ERROR | the harness's scenario loader refuses the file (via `cowork-harness lint` only — see below) |
285
286
  | `baseline-unknown` | ERROR | `baseline:` names no baseline this CLI ships (via `cowork-harness lint` only) |
286
- | `workspace-fixture-invalid` | ERROR | the run refuses the scenario's `workspace_fixture` directory — missing, a symlink, a hard link, agent-config paths, untracked files in git mode, empty, or over a size cap (via `cowork-harness lint` only) |
287
+ | `workspace-fixture-invalid` | ERROR | the run refuses the scenario's `workspace_fixture` directory — missing, a symlink, a hard link, agent-config paths, untracked files in git mode, empty, over a size cap, a binary file holding a session-path token, or session-path tokens at `fidelity: protocol` (via `cowork-harness lint` only) |
287
288
  | `workspace-fixture-not-relative` | WARN | `workspace_fixture` is an absolute or `~/` path rather than one relative to the scenario file — it names a directory on one machine; when it does not exist where lint runs it is not checked (via `cowork-harness lint` only) |
288
289
  | `workspace-fixture-vacuous-assert` | ERROR | a `file_exists` / `user_visible_artifact` / `artifact_text` / `artifact_json` names a file (or directory) the `workspace_fixture` provides without stating `authored:` — it would pass on the fixture alone (via `cowork-harness lint` only) |
289
290
  | `lint-loader-internal` | ERROR | the wrapper could not run its loader check on a file — a harness bug; it never falls back to a lint that skipped the loader |
@@ -1,6 +1,6 @@
1
1
  # CI recipe — replay vs live lanes
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`).
3
+ Self-contained reference. Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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.6.1"`) instead when you want byte-reproducible CI.
20
+ (e.g. `version: "4.7.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
@@ -36,7 +36,7 @@ jobs:
36
36
  - uses: actions/checkout@v4
37
37
  - name: Stage the agent binary (official channel, sha256-verified against the pinned baseline)
38
38
  run: |
39
- V=2.1.293 # match your scenario's pinned baseline's agentVersion
39
+ V=2.1.295 # match your scenario's pinned baseline's agentVersion
40
40
  # The release channel is NOT always the stable one. Desktop also stages release CANDIDATES,
41
41
  # served from .../claude-code-releases/rc/<commit>/. For some versions the stable path 404s
42
42
  # (2.1.255); for others it returns 200 and serves a DIFFERENT BUILD UNDER THE SAME VERSION
@@ -46,7 +46,7 @@ jobs:
46
46
  # from your pinned baseline's agentBinary.releaseBaseUrl. The checksum step fails closed if you
47
47
  # don't, but it cannot tell you why. Baselines written before that field existed were
48
48
  # stable-staged, so their base is the plain https://downloads.claude.ai/claude-code-releases.
49
- B=https://downloads.claude.ai/claude-code-releases/rc/3abc54a9d60b4d12c627afad22d6e5f58a6199d2
49
+ B=https://downloads.claude.ai/claude-code-releases
50
50
  # The expected digest is baselines/desktop-<ver>.json -> agentBinary.sha256. Paste it here, or
51
51
  # read it with jq if you vendor the baseline. An unverified download is an unverified agent:
52
52
  # this step FAILS rather than staging one, which is the whole point of naming it "verified".
@@ -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.6.1"
85
+ - run: npm i -g "cowork-harness@^4.7.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.
@@ -246,6 +246,7 @@ filesystem/egress-only check on the replay lane (a silent no-op). Author new sce
246
246
  ```bash
247
247
  cowork-harness record scenarios/my-test.yaml # live run that also writes the cassette
248
248
  cowork-harness record scenarios/ # batch: record every scenario in the dir
249
+ cowork-harness record scenarios/ --case my-test # only the named file stems (repeat --case); the budget cap prices them alone
249
250
  cowork-harness record cassettes/ --rerecord-stale # re-record ONLY the cassettes whose fingerprint drifted
250
251
  cowork-harness replay cassettes/my-test.cassette.json # token-free re-evaluation of content assertions
251
252
  cowork-harness replay cassettes/ # replay every *.cassette.json in the committed dir
@@ -408,7 +409,7 @@ jobs:
408
409
  with: { node-version: '24' }
409
410
  - uses: actions/setup-python@v5
410
411
  with: { python-version: '3.x' } # python3 only — PyYAML is bundled with the linter
411
- - run: npm i -g "cowork-harness@^4.6.1"
412
+ - run: npm i -g "cowork-harness@^4.7.1"
412
413
  - run: cowork-harness lint scenarios/*.yaml # no-silent-false-green (needs python3; PyYAML bundled)
413
414
  - run: cowork-harness verify-cassettes cassettes/ --output-format json # privacy + staleness gate
414
415
  - run: cowork-harness replay cassettes/ --output-format json # token-free content/structure
@@ -437,7 +438,7 @@ jobs:
437
438
  echo "live=true" >> "$GITHUB_OUTPUT"
438
439
  fi
439
440
  - if: steps.guard.outputs.live == 'true'
440
- run: npm i -g "cowork-harness@^4.6.1"
441
+ run: npm i -g "cowork-harness@^4.7.1"
441
442
  - if: steps.guard.outputs.live == 'true'
442
443
  run: cowork-harness run scenarios/ --output-format json
443
444
  env:
@@ -562,7 +563,7 @@ moved — and `unverifiable-fixture`, a `workspace_fixture` whose signature cann
562
563
  | `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 |
563
564
  | `prompt-assets` | the baseline's committed prompt assets changed since record under the same appVersion | re-record (warns; `--strict` fails) |
564
565
  | `unverifiable-prompt-assets` | a recorded prompt-asset hash exists but the live baseline's assets can't be hashed | couldn't verify the prompt |
565
- | `fixture` | the scenario's `workspace_fixture` content changed since record (names the files) | **high** (the step now starts from different files) — re-record; `--fail-on-skill-drift` fails it |
566
+ | `fixture` | the scenario's `workspace_fixture` content (or a tokenised file's substitution scheme) changed since record (names the files) | **high** (the step now starts from different files) — re-record; `--fail-on-skill-drift` fails it |
566
567
  | `unverifiable-fixture` | a `workspace_fixture` signature can't be checked: none recorded, no cassette dir, the dir is gone, or the scan refuses it | couldn't verify the fixture. **Fails a bare `replay`** |
567
568
 
568
569
  (A pre-`effectiveFidelity` cassette with an **explicit** tier is statically knowable — it passes the tier
@@ -1,6 +1,6 @@
1
1
  # Critique — the facts a plugin install can't otherwise reach
2
2
 
3
- Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`). This is **not** a trim of the full
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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
@@ -49,12 +49,12 @@ carry that; read all three before touching anything.
49
49
  | `infraFailure` | the reason |
50
50
  | `infraFailurePhase` | `task turn` (the graded run) or `reflection turn` (critique's own protocol turn) |
51
51
  | `infraFailureKind` | why it failed — a harness `ErrCategory` (error envelope, exit 2/3) **or** a `resultErrorKind` (`usage_limit`/`transport`/`agent`) from a turn that RAN and errored (exit 1, top-level `error: null`). **Absent** = killed, or no envelope |
52
- | `gradedErrorReason` | on a `taskResult: "error"` run (still gradeable, exit 0): why the GRADED turn errored, so a quota exhaustion is not read as a skill defect |
52
+ | `gradedErrorReason` | on a `taskResult: "error"` run (still gradeable, exit 0): why the GRADED turn errored, so a dropped connection is not read as a skill defect. A task turn that hit the usage limit (its own result, or a sub-agent's) is not graded: `infraFailurePhase: "task turn"`, `infraFailureKind: "usage_limit"`, exit 2 |
53
53
 
54
54
  **Do NOT read "has a kind" as "the instrument is fine".** The CLI's top-level catch turns every
55
55
  unexpected throw into category **`internal`** — Docker down, container start failure, missing staged
56
56
  agent, harness bug — and `runtime` carries a refused run dir. Only **`unanswered`, `usage`, `boundary`**
57
- and, from the result-row taxonomy, **`usage_limit`** (quota exhausted — retry after reset) and
57
+ and, from the result-row taxonomy, **`usage_limit`** (the account's quota — the main loop's or a sub-agent's; on the task turn critique exits 2 with `RUN FAILED (task turn, usage_limit)` and spawns no reflection) and
58
58
  **`transport`** (a tail-end drop) are the caller's problem. `agent` is not: for critique's own protocol
59
59
  turn that IS the instrument breaking. The header encodes exactly that split and fails closed (an
60
60
  unrecognized kind renders as infrastructure):
@@ -1,6 +1,6 @@
1
1
  # Debugging a run
2
2
 
3
- Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`). Read it when a run misbehaved or a green looks wrong: triage, the observability output, and `chat`.
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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
 
@@ -130,7 +130,7 @@ decide which assertions from *Assertions: two orthogonal axes* in `assertions-gu
130
130
  `redundantToolCalls`, `modelUsage`, `thinking`, `skillActivity`, `subagents[]` (prompt/`dispatchModel`/
131
131
  `resolvedModel`/output/`attributedSkillId`, `outputTruncated`, `referencesRead`, `reasoning`/`reasoningElided`),
132
132
  `context` (tools/mcpServers/availableSkills), `tasks`,
133
- `workspaceFiles`, `presentedFiles`, `hookEvents`, `mcpErrors`, `contextEvents`, `resources`
133
+ `workspaceFiles`, `presentedFiles`, `hookEvents`, `mcpErrors`, `contextEvents`, `hookFailureBlocks`, `resources`
134
134
  (`probeFailures` distinguishes a failed sample from a tier that was never sampleable). Provenance/
135
135
  evidence-health fields: `command` (`run`/`skill`/`record`/`chat`/`replay` — finer than `mode`),
136
136
  `gateProvenance` (per-gate `scripted`/`decided(llm|external)`/`first-option`/`prompt` with a
@@ -148,8 +148,12 @@ decide which assertions from *Assertions: two orthogonal axes* in `assertions-gu
148
148
  **`stderrLogPath`** (the captured agent stderr) — read those and `trace <run-dir>` *before* re-running;
149
149
  a re-record rarely tells you more than the captured stderr already does. Also check
150
150
  **`resultErrorKind`** (`"transport" | "agent" | "usage_limit"`) before spending another paid run: a
151
- `"usage_limit"` failure is a quota exhaustion, not a skill bug — retry after the limit resets rather
152
- than debugging; `"transport"`/`"agent"` means something actually broke, worth localizing before
151
+ `"usage_limit"` failure is the account's quota, not a skill bug — don't debug the skill. It is also set
152
+ when a **sub-agent** hit the limit and the main loop ended `success` (then `errorSource: "agent"`): the
153
+ `usage_limit` verdict message names that sub-agent (dispatch id, type, limit text), and `decisions[]`
154
+ carries a `name: "subagent_usage_limit"` row (`detail: {toolUseId?, subagentType?, taskId?, error,
155
+ prior?}`). Retry once the quota allows; for a sub-agent's limit also check the model it is pinned to and
156
+ the account's plan — a model-scoped or credits limit does not reset on a timer; `"transport"`/`"agent"` means something actually broke, worth localizing before
153
157
  re-running.
154
158
  - **Attributing cost to sub-agent work.** `subagents[]` gives the dispatch tree — each sub-agent's
155
159
  `dispatchModel`/`resolvedModel`, `toolsUsed`, `prompt`/`output`, and `attributedSkillId` — but **not** its own token/cost;
@@ -1,6 +1,6 @@
1
1
  # `eval` — paired before/after comparison of a skill edit (EXPERIMENTAL)
2
2
 
3
- Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`). The full guide is
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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
 
@@ -48,7 +48,9 @@ cowork-harness eval report <eval-dir> # rebuild the report from the eval dir
48
48
  | `insufficient` | too few valid reps in an arm (4 of 5 needed by default) |
49
49
 
50
50
  A drop is a signal to investigate, not proof: open the run dirs the report links for that row. In order,
51
- first match wins: an infrastructure failure is excluded and reported — including a rep where no model
51
+ first match wins: an infrastructure failure is excluded and reported — including a usage limit the run lane
52
+ named (the main loop's or a sub-agent's, rule `kind_usage_limit`, even on a rep that otherwise timed out or hit
53
+ `error_max_turns`), and a rep where no model
52
54
  answered (the agent's `Not logged in` / `Authentication required` reply, rule `auth`; a usage or
53
55
  spend limit as its final message, even on a nonzero exit after spend, rule `usage_limit`; or only
54
56
  `<synthetic>` models at $0, rule `no_model_answered`). An agent-caused failure (timeout, max turns,
@@ -1,6 +1,6 @@
1
1
  # Fidelity tiers & answer paths
2
2
 
3
- Self-contained reference. Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`).
3
+ Self-contained reference. Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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.6.1` (baseline `desktop-2.31226.0`). The full "✓ passed ≠ correct" landmine catalog.
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.1`). The full "✓ passed ≠ correct" landmine catalog.
4
4
 
5
5
  ## Gotchas — the "✓ passed ≠ correct" landmines
6
6
 
@@ -178,6 +178,8 @@ authorable). Reach for this list when debugging a run's behavior, that one while
178
178
  any failure) — a failing scenario does NOT abort the batch. So a single `cowork-harness record cassettes/
179
179
  --rerecord-stale` surfaces ALL stale anchors in one pass (add `--concurrency <N>` to parallelize); a shell
180
180
  wrapper that loops one cassette at a time with `set -e` defeats this and rediscovers stale anchors serially.
181
+ To re-record only a few scenarios of a directory, name them with `--case <stem>` (repeatable): the checks
182
+ and the `--max-budget-usd` pre-flight then price only those.
181
183
  Two durability properties make the batch safe to trust: each cassette is written **atomically** (a
182
184
  same-directory temp file + rename), so an interrupted or OOM-killed batch never leaves a partial/corrupt
183
185
  cassette — a failed scenario simply produces none; and under `--concurrency <N>` each scenario runs **fully
@@ -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.6.1` (baseline `desktop-2.31226.0`). It needs a `cowork-harness` whose
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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.6.1` (baseline `desktop-2.31226.0`). It needs a `cowork-harness` whose
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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`.
@@ -159,7 +159,7 @@ firing first is a scored `errored_agent` row), `--model ID` and `--judge-model I
159
159
 
160
160
  `run` refuses (exit 2) until `_state.json` holds an approved `harness_sha`, and again whenever it changes. The
161
161
  sha covers every scenario file, its session file, its uploads and its `workspace_fixture` files (exec bits
162
- included), the lockfiles in the current directory, the `_state.json` `harness_paths` entries, and the harness
162
+ and the session-path substitution scheme included), the lockfiles in the current directory, the `_state.json` `harness_paths` entries, and the harness
163
163
  version and baseline. It is computed over every case, whatever `--case` selects, so a canary and the full pass
164
164
  need the same approval. The plugin the loop edits is never in it, and a `harness_paths` entry inside that
165
165
  plugin is refused. Review the change, then run once with `--dry-run --approve-harness` to record the new sha
@@ -382,6 +382,26 @@ Flags: `--flow DIR`, `--variant all|baseline|v<N>` (default `all`: every variant
382
382
  row, never by `--allow-doc-drift`); one whose assert has an edit inside a scrubbed literal (only a re-run applies
383
383
  it); and an open `judge_invalid` slot.
384
384
 
385
+ ## Cutting the cost per rep
386
+
387
+ Every rep re-runs the whole scenario, so a case that tests the last step of a long pipeline pays for every step
388
+ before it on every rep. Start such a case from a workspace fixture instead: a directory whose files are copied into
389
+ the session's `outputs/` before turn 1, so the prompt asks for the late step alone
390
+ ([Starting from a saved workspace](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/scenario.md#starting-from-a-saved-workspace-workspace_fixture)).
391
+
392
+ - **Make one from a kept run.** `cowork-harness fixture export <run-dir> --out <dir>`, then point the case's
393
+ `workspace_fixture:` at `<dir>` and commit both ([fixture export](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/cli.md#exporting-a-runs-outputs-as-a-fixture-fixture-export)).
394
+ Add `--session-paths` when the skill reads back paths it recorded (an outputs-dir probe, a sub-agent's output
395
+ path). `--exclude <path>` leaves out a file the step re-creates, such as a probe the skill re-runs.
396
+ - **Limits.** A fixture with session-path tokens is refused on the `protocol` tier, and `--session-paths` refuses a
397
+ protocol run. Binary files are never rewritten; one that holds a token is refused. Any other host path in a text
398
+ file is still refused (`--allow-host-paths` accepts an ordinary one, never a path into a run dir).
399
+ - **What the case then measures.** Only the steps after the fixture. An untouched fixture file is pre-run, not
400
+ authored, so a judge grades only what this step wrote, and the conversation starts fresh. The earlier steps are no
401
+ longer exercised: keep a full-pipeline case for them.
402
+ - **The fixture is in the harness gate.** Its files are hashed with the scenario, so an edited or re-exported fixture
403
+ changes the sha and needs `--approve-harness` again.
404
+
385
405
  ## Exit codes
386
406
 
387
407
  - `run`: `0` every attempted (case, rep) was scored; `1` an attempt failed (an `errors.jsonl` row, or a scored
@@ -1,6 +1,6 @@
1
1
  # Measurement
2
2
 
3
- Tracks `cowork-harness 4.6.1` (baseline `desktop-2.31226.0`). Read it before comparing runs: `--repeat`, `--ablate-skill`, and the hygiene that keeps a batch valid.
3
+ Tracks `cowork-harness 4.7.1` (baseline `desktop-2.31226.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.6.1` (baseline `desktop-2.31226.0`). 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.7.1` (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.
4
4
 
5
5
  ## Part II — RUN, RECORD & LOCK
6
6
 
@@ -141,7 +141,9 @@ launched from a repo root never drops sensitive skill inputs/outputs into it. Pa
141
141
  collect the runs. The runs dir is also where `--max-budget-usd` reads its cost history: pointed at a fresh or
142
142
  per-job directory it finds no priced run for the scenario, warns `no priced run history … proceeding
143
143
  UNCAPPED`, and runs with no cap (a batch's estimate becomes a lower bound; only the `--concurrency 1`
144
- running total still stops it). To keep the cap, leave `--run-dir` at the default, or reuse the same
144
+ running total still stops it). To re-record a few scenarios of a large directory without pricing the rest,
145
+ name them: `record scenarios/ --case <stem> [--case …] --max-budget-usd <x>` (the file name without its
146
+ extension) prices, checks and records only those. To keep the cap, leave `--run-dir` at the default, or reuse the same
145
147
  directory across invocations (a CI cache, say) so it holds at least one priced run of that scenario.
146
148
  Under `--output-format json` this is machine-readable: the envelope's top-level `budget` object reports
147
149
  `enforced: false` (single run: at least one scenario ran with no cap) or `"lower_bound"` (a `record`
@@ -224,7 +226,7 @@ Recognize these before "fixing" a non-bug:
224
226
  `markitdown`/`magika` (`ml_extract`), `cv2` (`cv`), `camelot`/`tabula` (`pdf_tables`), or `wand`
225
227
  (`magick`) can trip this even though real Cowork **ships** those (per the rootfs manifest captured at
226
228
  Desktop `2.9939.2` — `baselines/provisioning/rootfs-provisioning.json`, which is the dated evidence
227
- behind that sentence; 7 baselines have shipped since without a re-capture). The message says so ("likely a FALSE
229
+ behind that sentence; 8 baselines have shipped since without a re-capture). The message says so ("likely a FALSE
228
230
  NEGATIVE (real Cowork ships them)"). Fix: rebuild full parity (`--build-arg COWORK_FULL_PARITY=1`, point
229
231
  `COWORK_AGENT_IMAGE` at it), or — if the skill's fallback is genuinely equivalent — assert
230
232
  `allow_missing_capability: true`. (Two sources: a skill *observed using* an omitted family, live lane;
@@ -411,6 +413,8 @@ than a stuck `"running"`.)
411
413
  - `ref freeze --case-id <id>` overrides the store entry's name (default: the scenario's name).
412
414
  - `fixture export <run-dir> --out <dir>` refuses a file holding a secret, and a text file or file name holding a host
413
415
  path unless `--allow-host-paths` (a path into a run dir or a guest session is refused regardless).
416
+ `--session-paths` tokenises this run's own session roots instead of refusing them, and staging writes the new
417
+ run's roots in (hostloop, container, microvm); `--exclude <path>` drops a file.
414
418
  - `prune [--keep-last <n>] [--pinned-older-than <N>d]` removes old run dirs (default `--keep-last 5`);
415
419
  `--dry-run` previews it.
416
420
 
@@ -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.6.1`
4
- (baseline `desktop-2.31226.0`). 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.7.1`
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),
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:
@@ -127,7 +127,10 @@ workspace_fixture: fixtures/step1 # OPTIONAL — a directory (relative to this
127
127
  # (fresh runs only; never re-staged on --resume). Regular files only:
128
128
  # symlinks, hard links, .claude/.git/.mcp.json/CLAUDE.md and (git mode)
129
129
  # untracked files are refused at load, as is a presence/body assertion
130
- # on a fixture file that does not state `authored: true|false`.
130
+ # on a fixture file that does not state `authored: true|false`,
131
+ # a binary file holding a session-path token, and a fixture with
132
+ # tokens at fidelity: protocol. Text files with tokens
133
+ # (fixture export --session-paths) get this run's session roots.
131
134
  # 64 MiB cap (COWORK_HARNESS_WORKSPACE_FIXTURE_MAX_BYTES). Stamps v14.
132
135
 
133
136
  metrics: # OPTIONAL — numbers read from JSON files the run wrote, reported in
@@ -523,8 +526,8 @@ debugging a run's behavior. The two are **numbered independently**: a bare "gotc
523
526
  `mcp__workspace__*` round-trip is not double-counted.
524
527
 
525
528
  9. **Structured JSON → a structured-field assert, not a transcript substring.** Prefer YAML
526
- `artifact_json` (dotted `path` + operator); use the pytest lane (`assert_artifact_json` with a real
527
- predicate) only for checks too complex for a dotted path. Find field paths via `--keep`.
529
+ `artifact_json` (dotted `path` + operator, or `schema:` for a whole value's shape); use the pytest lane
530
+ (`assert_artifact_json` with a real predicate) only for checks neither can express. Find field paths via `--keep`.
528
531
 
529
532
  10. **`subagent_dispatched` matches by `resolvedAgentType` or `description` too** — a `Task` dispatch
530
533
  with no `subagent_type` at all falls back to the built-in `general-purpose` agent (a WILDCARD tool