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.
- package/.claude/skills/cowork-harness/SKILL.md +5 -5
- package/.claude/skills/cowork-harness/references/assertion-catalog-agents-skills-budgets.md +1 -1
- package/.claude/skills/cowork-harness/references/assertion-catalog-gates-hooks-modifiers.md +5 -5
- package/.claude/skills/cowork-harness/references/assertion-catalog-outcome-files-tools.md +2 -2
- package/.claude/skills/cowork-harness/references/assertion-catalog.md +4 -4
- package/.claude/skills/cowork-harness/references/assertions-guide.md +10 -5
- package/.claude/skills/cowork-harness/references/authoring.md +4 -3
- package/.claude/skills/cowork-harness/references/ci-recipe.md +9 -8
- package/.claude/skills/cowork-harness/references/critique.md +3 -3
- package/.claude/skills/cowork-harness/references/debugging.md +8 -4
- package/.claude/skills/cowork-harness/references/eval.md +4 -2
- package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
- package/.claude/skills/cowork-harness/references/gotchas.md +3 -1
- package/.claude/skills/cowork-harness/references/hillclimb-recipe.md +1 -1
- package/.claude/skills/cowork-harness/references/hillclimb.md +22 -2
- package/.claude/skills/cowork-harness/references/measurement.md +1 -1
- package/.claude/skills/cowork-harness/references/run-record-replay.md +7 -3
- package/.claude/skills/cowork-harness/references/scenario-schema.md +8 -5
- package/.claude/skills/cowork-harness/references/semantic-judging.md +1 -1
- package/.claude/skills/cowork-harness/references/task-recipes.md +28 -11
- package/.claude/skills/cowork-harness/scripts/scenario.py +1 -1
- package/CHANGELOG.md +196 -0
- package/DESIGN.md +2 -2
- package/README.md +4 -4
- package/RELEASING.md +5 -2
- package/SPEC.md +22 -14
- package/baselines/desktop-2.31226.1.json +1529 -0
- package/baselines/prompts/cowork-system-prompt-fingerprints.json +10 -1
- package/dist/assert.js +98 -26
- package/dist/critique/command.js +34 -10
- package/dist/eval/classify.js +5 -3
- package/dist/fixture/cli.js +12 -2
- package/dist/fixture/export.js +101 -15
- package/dist/fixture/session-tokens.js +111 -0
- package/dist/fixture/usage.js +6 -3
- package/dist/fixture/workspace.js +73 -8
- package/dist/hillclimb/cases.js +7 -6
- package/dist/json-schema.js +251 -0
- package/dist/run/cassette.js +142 -35
- package/dist/run/chat-result.js +1 -0
- package/dist/run/chat.js +2 -1
- package/dist/run/execute.js +45 -5
- package/dist/run/hook-failure-blocks.js +412 -0
- package/dist/run/lint-load.js +13 -1
- package/dist/run/recorded-agent-version.js +51 -0
- package/dist/run/run.js +80 -1
- package/dist/run/schema-files.js +65 -0
- package/dist/run/verdict.js +8 -1
- package/dist/run/verify-context.js +5 -0
- package/dist/runtime/container.js +2 -1
- package/dist/runtime/hostloop-stage.js +9 -4
- package/dist/runtime/hostloop.js +9 -3
- package/dist/runtime/microvm.js +2 -1
- package/dist/runtime/protocol.js +6 -0
- package/dist/runtime/stage.js +4 -2
- package/dist/session.js +4 -0
- package/dist/types.js +28 -4
- package/dist/usage-limit.js +28 -0
- package/docs/cassette.md +11 -5
- package/docs/ci.md +3 -3
- package/docs/cli.md +40 -18
- package/docs/companion-skill.md +2 -2
- package/docs/critique.md +9 -6
- package/docs/eval.md +19 -1
- package/docs/fidelity-gaps.md +7 -3
- package/docs/hillclimb.md +20 -0
- package/docs/maintenance.md +1 -1
- package/docs/run-status.md +2 -1
- package/docs/scenario.md +92 -18
- package/examples/replays/README.md +1 -1
- package/examples/replays/example-multiselect-gate.cassette.json +1 -1
- package/examples/replays/example-pdf-skill.cassette.json +1 -1
- package/examples/replays/hostloop-computer-links.cassette.json +1 -1
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/python/test_scenario_lint.py +1 -1
- package/schema/cassette.v15.json +3 -3
- package/schema/cassette.v16.json +381 -0
- package/schema/critique-report.json +1 -1
- package/schema/run-result.json +10 -3
- package/schema/scenario.schema.json +30 -4
- 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.
|
|
7
|
-
tracks-harness: cowork-harness 4.
|
|
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.
|
|
30
|
-
> `desktop-2.31226.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
39
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
152
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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;
|
|
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.
|
|
4
|
-
(baseline `desktop-2.31226.
|
|
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
|
|
527
|
-
predicate) only for checks
|
|
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
|