cowork-harness 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.claude/skills/cowork-harness/SKILL.md +454 -0
  2. package/.claude/skills/cowork-harness/references/ci-recipe.md +272 -0
  3. package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +191 -0
  4. package/.claude/skills/cowork-harness/references/scenario-schema.md +442 -0
  5. package/.claude/skills/cowork-harness/scripts/assertion-keys.json +8 -0
  6. package/.claude/skills/cowork-harness/scripts/scenario.py +10 -1
  7. package/AGENTS.md +74 -0
  8. package/CHANGELOG.md +274 -1
  9. package/CONTRIBUTING.md +83 -0
  10. package/DESIGN.md +192 -0
  11. package/README.md +160 -92
  12. package/SECURITY.md +63 -0
  13. package/SPEC.md +584 -0
  14. package/baselines/desktop-1.11847.5.json +2 -0
  15. package/baselines/desktop-1.12603.1.json +2 -0
  16. package/baselines/desktop-1.13576.1.json +3 -1
  17. package/baselines/desktop-1.14271.0.json +3 -1
  18. package/baselines/desktop-1.15200.0.json +3 -1
  19. package/baselines/desktop-1.15962.0.json +3 -1
  20. package/baselines/desktop-1.15962.1.json +3 -1
  21. package/baselines/desktop-1.17377.1.json +4 -1
  22. package/baselines/desktop-1.17377.2.json +232 -0
  23. package/baselines/desktop-1.18286.0.json +250 -0
  24. package/baselines/prompts/desktop-1.18286.0/system-prompt-append.md +214 -0
  25. package/dist/agent/session.js +33 -4
  26. package/dist/assert.js +88 -0
  27. package/dist/baseline.js +96 -9
  28. package/dist/cli.js +1019 -201
  29. package/dist/hostloop/pretooluse-path-hook.js +141 -0
  30. package/dist/hostloop/safety.js +28 -0
  31. package/dist/hostloop/workspace-handler.js +3 -4
  32. package/dist/prompt.js +31 -8
  33. package/dist/run/cassette.js +95 -17
  34. package/dist/run/chat.js +70 -5
  35. package/dist/run/computer-links.js +198 -0
  36. package/dist/run/diff.js +178 -0
  37. package/dist/run/display-translate.js +47 -0
  38. package/dist/run/doctor.js +41 -7
  39. package/dist/run/envelope.js +70 -10
  40. package/dist/run/execute.js +163 -11
  41. package/dist/run/matrix.js +131 -0
  42. package/dist/run/renderer.js +36 -12
  43. package/dist/run/repeat.js +89 -0
  44. package/dist/run/run-index.js +253 -0
  45. package/dist/run/run-status.js +12 -0
  46. package/dist/run/run.js +15 -2
  47. package/dist/run/trace-view.js +59 -7
  48. package/dist/run/verdict.js +9 -1
  49. package/dist/runtime/argv.js +26 -4
  50. package/dist/runtime/hostloop-stage.js +94 -0
  51. package/dist/runtime/hostloop.js +145 -57
  52. package/dist/scan.js +26 -8
  53. package/dist/session.js +36 -0
  54. package/dist/sync/baseline-diff.js +138 -0
  55. package/dist/sync/cowork-sync.js +24 -2
  56. package/dist/types.js +105 -12
  57. package/dist/vm-paths.js +341 -0
  58. package/docs/README.md +6 -1
  59. package/docs/boundary.md +11 -5
  60. package/docs/cassette.md +55 -24
  61. package/docs/chat.md +12 -5
  62. package/docs/debugging.md +22 -2
  63. package/docs/fidelity-gaps.md +16 -1
  64. package/docs/gotchas.md +17 -0
  65. package/docs/invariants.md +24 -0
  66. package/docs/maintenance.md +25 -0
  67. package/docs/protocol.md +136 -0
  68. package/docs/scenario.md +115 -9
  69. package/docs/session.md +7 -0
  70. package/docs/stats.md +74 -0
  71. package/examples/README.md +22 -17
  72. package/examples/replays/README.md +10 -1
  73. package/examples/replays/example-multiselect-gate.cassette.json +98 -0
  74. package/examples/replays/example-pdf-skill-ci-selftest-failing.yaml +30 -0
  75. package/examples/replays/example-pdf-skill.cassette.json +24 -25
  76. package/fixtures/protocol/v1/allow-response.json +13 -0
  77. package/fixtures/protocol/v1/deny-response.json +11 -0
  78. package/fixtures/protocol/v1/hook-callback.json +27 -0
  79. package/fixtures/protocol/v1/initialize.json +18 -0
  80. package/fixtures/protocol/v1/mcp-message.json +42 -0
  81. package/fixtures/protocol/v1/permission-request.json +13 -0
  82. package/fixtures/protocol/v1/question-answer-response.json +36 -0
  83. package/fixtures/protocol/v1/question-request.json +33 -0
  84. package/llms.txt +44 -0
  85. package/package.json +15 -3
  86. package/python/README.md +19 -11
  87. package/schema/protocol.v1.json +261 -0
  88. package/schema/run-result.json +24 -2
  89. package/schema/scenario.schema.json +53 -2
  90. package/schema/session.schema.json +3 -0
@@ -0,0 +1,454 @@
1
+ ---
2
+ name: cowork-harness
3
+ description: Test or debug a Claude Code skill/plugin under Claude Cowork's runtime — sandboxed agent, default-deny egress, the can_use_tool permission/question protocol — using the cowork-harness CLI. Use when validating or regression-testing a skill, authoring or debugging a scenario YAML (prompt + scripted answers + assert:), choosing a fidelity tier, scripting AskUserQuestion / tool-permission answers, or asserting artifacts, egress, or sub-agent dispatch. Especially when a harness run no-ops an assertion, fails on an unanswered gate, false-greens, a steered answer never reaches the model, or a web_fetch is unexpectedly denied or gated. NOT for generic unit testing (pytest/vitest of your own scripts) or non-Cowork CI. Covers the skill / run / chat / record / replay / trace / decide / assertions / scaffold commands and the session-vs-scenario split.
4
+ metadata:
5
+ author: cowork-harness
6
+ version: 0.22.0
7
+ tracks-harness: cowork-harness 0.22.0 (baseline desktop-1.18286.0)
8
+ ---
9
+
10
+ # cowork-harness
11
+
12
+ This skill teaches you to drive the **`cowork-harness` CLI** — a fixture that runs a Claude Code
13
+ skill the way **Claude Cowork** runs it (sandboxed agent, default-deny egress, the permission /
14
+ AskUserQuestion control protocol). It is *not* the CLI itself: you still invoke `cowork-harness …`
15
+ in the shell; this skill tells you how to author scenarios, pick a fidelity tier, choose an answer
16
+ path, place assertions in the right CI lane, and avoid the harness's "✓ passed ≠ actually correct"
17
+ traps.
18
+
19
+ The single most important idea: **a green run is not automatically a correct run.** The harness has
20
+ several ways to no-op a check while still producing a green run (skip an assertion on replay — now
21
+ flagged with a loud `::warning::`, not silent — auto-answer a gate, observe an empty egress
22
+ allowlist). This skill exists mostly to keep you out of those traps — the Gotchas section below is
23
+ the highest-value part. Read it.
24
+
25
+ > **Version note:** the facts and `file:line` pointers here track `cowork-harness 0.22.0` (baseline
26
+ > `desktop-1.18286.0`). If your checkout is newer, prefer the live `--help`, `SPEC.md`, and
27
+ > `docs/*.md` over this snapshot, and re-run the bundled linter.
28
+
29
+ ## 0. Preflight — make sure the harness can actually run
30
+
31
+ The 10-second inner loop, once the CLI is on PATH:
32
+
33
+ ```bash
34
+ cowork-harness doctor # prerequisites OK? (Docker, agent, token, baseline)
35
+ cowork-harness skill ./my-skill "do X" # run the skill once against the staged agent
36
+ ```
37
+
38
+ Before the first command, confirm the CLI is reachable and **fail loud** (never fake a pass) when a tier's dependencies are missing:
39
+
40
+ - **One-shot check.** Run `cowork-harness doctor [--tier <tier>]` first — a read-only prerequisite check that inspects Docker, the staged agent, the token, and the baseline in one pass. The bullets below explain each thing it checks (and how to fix it).
41
+ - **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 0.22.0**. If it's missing or older, prefix every command with the version floor `npx cowork-harness@>=0.22.0 <cmd>` (Node ≥ 20), or install once with `npm i -g cowork-harness@>=0.22.0`. **Pin `@>=0.22.0`, never `@latest`** — `@latest` can silently fetch an older CLI and the new commands fail as "unknown command", whereas the floor **fails loud** if no compatible version is published. (≥ 0.22.0 is what gates the commands/assertions this skill teaches: `assertions --list`, `scaffold <run-id>`, `trace --view dispatches`, `artifact_json` incl. the `in:` operator, `verify-cassettes`, batch `record <dir>`/`--rerecord-stale`, `record --concurrency <N>`, record-time redaction, multiSelect/`answer:`, `verify-run` answer-coverage, `record --max-artifact-bytes`, live record-time deciders, `verify-cassettes --allow-domain`/`--allow-email`/`--allow-path`/`--allow-file` (`path` — local absolute filesystem paths — is the scanner's 4th class, new in 0.21.0), scenario `skills:` staleness scoping with `COWORK_HARNESS_AGENT_SCOPE=skill`, `chat --plugin`, `/help` in the REPL, `hostloop`'s native host/VM process split with its `allow_host_writes:` consent field, and `computer_links_resolve` (new in 0.22.0).)
42
+ - **Agent binary (sandboxed live tiers — `container`/`microvm`/`hostloop`/`cowork`).** The staged Claude Code agent is **bind-mounted** from a local Claude Desktop install, or point `COWORK_AGENT_BINARY` at a `claude-code-vm/<ver>/claude` ELF. Nothing is bundled. `protocol` (L0) and `replay` need no staged agent; for the sandboxed tiers, no agent → no run; report that, don't skip silently.
43
+ - **Docker / Lima.** Only `--fidelity protocol` (L0) runs without them. `container` / `microvm` / `hostloop` / `cowork` need Docker (Lima for L2). If they're absent, drop to `--fidelity protocol` and **say so** — a green that never exercised the sandbox is not a sandbox pass.
44
+ - **Auth.** `CLAUDE_CODE_OAUTH_TOKEN` (preferred) or `ANTHROPIC_API_KEY`, via env or `.env`. Minting an OAuth token needs the **`claude` CLI** (`npm i -g @anthropic-ai/claude-code`, then `claude setup-token`).
45
+
46
+ ## 1. Pick the loop
47
+
48
+ - **"Is it even alive?"** (inner loop) → `cowork-harness skill <folder> "<prompt>"`. Fastest; no
49
+ scenario file.
50
+ - **Repeatable, asserted regression** → author a `scenarios/*.yaml` and run `cowork-harness run`.
51
+ This is the CI-grade path and most of this skill.
52
+ - **Multi-turn debugging** → `cowork-harness chat` (interactive; gates answered at the TTY, **not** an
53
+ asserted test — see *Debugging with `chat`* in `docs/scenario.md`, or `docs/debugging.md` for the full
54
+ debugging map).
55
+
56
+ Full command set: `skill · run · chat · record · replay · verify-cassettes · rehash · prune · lint ·
57
+ verify-run · trace · inspect · diff · stats · decide · gates · answer · scaffold · assertions --list · sync ·
58
+ list · boundary-check · vm <init|status|delete|prune> · doctor`. Always check `cowork-harness <cmd> --help`.
59
+
60
+ ## 2. Two files: session vs scenario
61
+
62
+ - **`sessions/*.yaml`** — pre-prompt setup: `model`, mounts (`folders`), and discovery
63
+ (marketplaces / plugins / skills / mcp). One session is reused by many scenarios. A scenario that
64
+ omits `session:` gets an all-defaults **inline** session (not a file on disk).
65
+ - **`scenarios/*.yaml`** — the test: `prompt`, scripted `answers:`, and `assert:`.
66
+
67
+ This split matters: release ground truth (`baseline:` / `baselines/`, produced by `sync`) is
68
+ **separate** from authored setup (`session:` / `sessions/`). "profile" is retired vocabulary — do
69
+ not use it. See `references/scenario-schema.md` for every field.
70
+
71
+ ## 3. Discovery: how the skill-under-test gets mounted
72
+
73
+ The skill is **copied fresh into the sandbox each run**. Wire it via `plugins.local_plugins` +
74
+ `plugins.enabled: [<plugin>@local]` in the session (or `--marketplace` / `--plugin` flags on
75
+ `skill`). A missing mount source is now a **hard error** (`mount source(s) not found …`); set
76
+ `COWORK_HARNESS_SOFT_MISSING=1` to fall back to warn-and-exclude. Mount names are always derived from
77
+ the folder basename (collision-resolved); there is no `to:` override. See `references/scenario-schema.md`.
78
+
79
+ > **`git add` a brand-new skill before testing it.** Inside a git repo the harness stages the
80
+ > **git-tracked** files (the fidelity boundary — real Cowork installs from a repo and sees only committed
81
+ > files). An **all-untracked** skill folder used to mount *empty* and the agent reported "the skill isn't
82
+ > installed" then did the work itself — a green-looking run where the skill never loaded. That now
83
+ > **hard-fails** (`BoundaryError`, exit 3) naming the dir, and a partially-tracked folder emits a loud
84
+ > `::notice:: [stage]` listing the excluded files. Fix: `git add` the skill, or `COWORK_HARNESS_GITSET=0`
85
+ > to copy untracked files (won't reflect what ships). A folder **outside** any repo is copied raw (no guard).
86
+
87
+ ## 4. Choose a fidelity tier
88
+
89
+ | Tier | What it gives you | Use when |
90
+ |---|---|---|
91
+ | `protocol` | Fastest; no sandbox, no egress | Pure protocol/answer-shape tests. **Rejected** if the scenario asserts egress. |
92
+ | `container` | Real sandbox + real default-deny egress (**default**) | Most functional + boundary tests. |
93
+ | `microvm` | VM-grade escape **isolation** (macOS arm64). Egress transport is the *same allowlist proxy as `container`* — not better network fidelity | Testing untrusted code escape, not network behavior. |
94
+ | `hostloop` / `cowork` | Production split-exec: the agent loop is a **native process on the host** (no container around the file tools — matching production), with native Bash/WebFetch disabled and routed host-side via the workspace SDK-MCP server into a Docker VM sidecar | Highest-fidelity / parity runs. A writable connected folder needs `allow_host_writes: true` (see scenario-schema.md). |
95
+
96
+ Set the tier in the **scenario's `fidelity:` field**, not a flag — `run` rejects `--fidelity`
97
+ (it's a `skill`-only flag). See `references/fidelity-and-answers.md`.
98
+
99
+ ## 5. Choose an answer path (gates: AskUserQuestion + tool-permission)
100
+
101
+ Default to **deterministic**: scripted `answers:` + `on_unanswered: fail`. Anything that brings a
102
+ live model into answering flags the run `nonDeterministic` — keep those out of deterministic
103
+ regressions.
104
+
105
+ | Path | How | Deterministic? |
106
+ |---|---|---|
107
+ | Scripted | `answers:` rules + `on_unanswered: fail` | ✅ (the CI/agent default) |
108
+ | LLM decider | `on_unanswered: llm` (YAML) **or** `--decider-llm` (CLI) | ❌ flags nonDeterministic |
109
+ | Spawned helper | `--decider-cmd '<helper>'` | depends on helper |
110
+ | In-band (driving agent) | `--decider-dir <dir>` (+ a Monitor) | depends |
111
+
112
+ Exact accepted values (teach precisely): `--on-unanswered` takes `fail|prompt|first` on `skill`,
113
+ only `fail|first` on `run`. **`llm` is NOT an `--on-unanswered` value** — the bare flag
114
+ `--on-unanswered llm` is rejected (use `--decider-llm`); the YAML spelling is `on_unanswered: llm`.
115
+ The word `agent` is **retired** — do not write `on_unanswered: agent` (the schema rejects it).
116
+ `--on-unanswered first` is itself flagged `nonDeterministic` — it is *not* a deterministic stand-in
117
+ for scripted answers. See `references/fidelity-and-answers.md`.
118
+
119
+ **Which gates to anchor (re-record robustness).** The model rewords option labels (and sometimes the
120
+ question) every run, so a brittle exact-label `choose:` is itself a re-record-fragility source — it drifts and
121
+ forces a re-record. The practical rule: **label-anchor only the gates whose choice drives an `assert:`** (or
122
+ materially changes behavior); for gates whose answer is immaterial to your assertions, `on_unanswered: first`
123
+ is the more re-record-robust choice — accept the `nonDeterministic` flag rather than trade it for a flaky
124
+ anchor. (When label *order* is stable but the text drifts, a positional `choose` is the middle option — the
125
+ linter flags positional `choose` as order-dependent, so use it deliberately.) The caution stands: `first`
126
+ *masks* an unanswered gate, so don't use it for a gate you actually need answered a specific way.
127
+
128
+ ### External deciders and the "first" shorthand
129
+
130
+ When using `--decider-cmd` or `--decider-dir`, the helper's output is passed through
131
+ `coerceLabel` **with the "first" shorthand disabled**. This means a helper that returns the literal
132
+ string `"first"` must match an actual label named `"first"` — it is **not** coerced to option 1.
133
+ This prevents a helper bug (accidentally emitting `"first"`) from silently green-ing option 1.
134
+
135
+ The `"first"` shorthand remains active only for the built-in `--on-unanswered first` path. If you
136
+ write an external helper, return a label name or option index — never the bare word `"first"` unless
137
+ your gate actually has a label called `"first"`.
138
+
139
+ ## 5a. Checking whether a background run is alive
140
+
141
+ Never use `ps aux` to check on a `cowork-harness` run you launched in the background — it only sees
142
+ processes in your OWN PID namespace, which is frequently NOT the harness process's namespace (e.g. when
143
+ you're a sandboxed subagent). An empty `ps aux` match tells you nothing about whether the run is still
144
+ going.
145
+
146
+ Use **`cowork-harness status <dir> [--follow]`** instead — reads `<outDir>/status.json`, a file the
147
+ harness writes/updates throughout the run's lifecycle (including a crash-safety net for a thrown
148
+ error/`SIGTERM`, AND staleness detection for a hard `SIGKILL`/OOM-kill that no exit handler can catch —
149
+ either way you get `"error"`/`stale` instead of a permanently-trusted `"running"`), so liveness is
150
+ checkable regardless of PID namespace. The harness prints `[status] <outDir>` to stderr as soon as the
151
+ run starts, so capture stderr to get the exact directory. `--follow` fails loud on a timeout/staleness
152
+ rather than hanging forever. See `docs/run-status.md` for the full recipe.
153
+
154
+ ## 6. Assertions: two orthogonal axes
155
+
156
+ Conflating these is the **biggest landmine**. An assertion key has two independent properties:
157
+
158
+ - **Axis A — robust to LLM phrasing drift?** Structural/boundary keys (`subagent_dispatched`,
159
+ `egress_*`, `file_exists`, `user_visible_artifact`, `result`) are robust. Free-text content is
160
+ not: match prose with `transcript_matches` / `transcript_contains` (stable lexical markers only —
161
+ not semantic content the model paraphrases, which re-records red); check structured JSON with YAML
162
+ `artifact_json` (or the pytest lane for complex predicates), not via a transcript substring.
163
+ - **Axis B — survives `replay`?** *Independent of Axis A.* On the token-free `replay` lane, only
164
+ **content keys** evaluate; filesystem / egress keys are skipped (live-only) — loudly, via an
165
+ `::warning::` annotation, not a silent no-op. A key
166
+ being "robust" says nothing about whether it runs on your replay gate.
167
+
168
+ Getting Axis B wrong means a check that **does nothing in CI** — the harness warns loudly when it skips
169
+ (an `::warning::` annotation, not a silent no-op — see the Axis B bullet above), and the bundled linter
170
+ catches it before you push — run it (§9).
171
+
172
+ See `references/scenario-schema.md` for the full assertion catalog with each key's replay class.
173
+
174
+ ## 7. web_fetch (fail-closed, two-path)
175
+
176
+ `web_fetch` behaves unlike `curl`. A URL is gated by **provenance**, not the egress allowlist:
177
+
178
+ - A URL is *provenanced* iff it appeared in the **prompt** or a **prior `web_fetch` result**. To
179
+ make a fetch succeed, put the URL in the prompt.
180
+ - **Provenanced** → fetches (still SSRF-guarded per redirect hop); the egress hostname allowlist is
181
+ **not consulted**.
182
+ - **Not provenanced** → raises a per-domain approval gate (`webfetch:<domain>`) that is
183
+ **fail-closed** (it is *not* auto-allowed; `--on-unanswered first` won't allow it). Answer it with
184
+ a scripted rule (`when_tool: "webfetch:<domain>"` + `grant: domain|once`), a session
185
+ `web_fetch.approved_domains`, or a live decider.
186
+
187
+ Surprise to remember: adding a host to `egress.extra_allow` is a **no-op** for a provenanced fetch.
188
+ Full model in `references/scenario-schema.md`.
189
+
190
+ ## 8. Run, then lock determinism
191
+
192
+ Read the verdict and the inline failing transcript. To pin a flaky-because-stochastic gate, paste
193
+ the echoed `--answer "<q>=<choice>"` footer lines back into the scenario's `answers:` for a
194
+ deterministic re-run. Use `cowork-harness trace <id>` to digest a run. If only an *assertion* is wrong (the
195
+ run itself was fine), `cowork-harness verify-run <run-dir> <scenario.yaml>` re-checks the `assert:` block against
196
+ a **kept** run dir (`--keep`, or a `--session-id` run) with no live re-record — tokens-free, ~1s per iteration.
197
+ When the scenario declares `answers:`, verify-run **also** checks they still match the run's actual gates (a
198
+ reworded gate or a `choose:` the run never offered fails here in ~1s instead of on a paid re-record). Or skip
199
+ the discovery/encode/record dance entirely and answer gates **live during the recording** with
200
+ `record --decider-dir`/`--decider-llm` (the cassette is flagged non-deterministic but replays deterministically).
201
+
202
+ **Author answers WITHOUT re-paying — the cheap loop.** You don't need a fresh paid record to discover a
203
+ scenario's gates or their labels: `--keep` ONE run, then `cowork-harness trace <run-dir> --view questions`
204
+ (and `verify-run`) read the gates + offered option labels out of that run's `events.jsonl` for free. Iterate
205
+ your `answers:` against that kept run, then record once. **But the kept run is a snapshot:** if you change the
206
+ skill's gate phrasing afterward, re-`--keep` — verify-run's answer-coverage *refuses* (exit 2, "predates the
207
+ current skill") rather than vouch against stale labels, but the trace/inspect path can't warn you, so re-keep
208
+ deliberately. (A token-free probe of "which gates fire" isn't possible — gates are model-decided per run.)
209
+
210
+ Run artifacts are written to `~/.cowork-harness/runs/…` by default — **outside any working tree**, so a run
211
+ launched from a repo root never drops sensitive skill inputs/outputs into it. Pass `--run-dir <path>` (or set
212
+ `COWORK_HARNESS_RUNS_DIR`) to relocate; in CI point it at a workspace path so an artifact-upload step can
213
+ collect the runs.
214
+
215
+ ### Validate a skill against real documents (not a cassette)
216
+
217
+ The loops above build **deterministic regressions**. A different job — drive a skill against *real* input
218
+ documents to judge whether it actually does the work (extraction, analysis), with no intent to record a
219
+ cassette — has its own recipe:
220
+
221
+ 1. **Explore with the LLM decider.** `cowork-harness skill <dir> --decider-llm --intent "<one line of what
222
+ this run is testing>"` lets a model (Sonnet default) answer each gate steered by your intent. The model replies with
223
+ the option **number** and the harness maps it to the exact label (so it can't whiff by mis-typing the
224
+ label text); an out-of-set answer fails loud. This is exploration, **not** a deterministic regression —
225
+ the run is flagged non-deterministic and a green here is not a scripted pass. The answering model
226
+ defaults to a Sonnet id (a weaker model tends to prose-decline an ambiguous judgment gate → fail-loud);
227
+ override it with `--decider-model <id>` — a cheaper model (e.g. Haiku) for simple gates to cut cost,
228
+ or Opus for the hardest judgment gates; it won't make an under-specified gate deterministic. A live
229
+ decider can false-green a semantic assertion on an oracle-less gate — see `references/fidelity-and-answers.md`.
230
+ 2. **Script the load-bearing gates — especially binary confirm gates.** Once you know which gates fire
231
+ (`trace <run-dir> --view questions`), pin the ones whose choice drives the outcome with
232
+ `--answer "<q>=<label>"` / `--answer-policy <yaml>`. When a skill **re-words its option labels run-to-run**
233
+ (LLM-authored gates), pin a **stable leading substring** instead of the full label — `--answer
234
+ "<q>=Israeli company"` binds whichever option starts with `Israeli company`. It is uniqueness-guarded and
235
+ **fails loud** if the anchor ever matches two options (the documented trade: drift-tolerance, not strict
236
+ CI reproducibility — for that, pin a full exact label or a free-text `answer:`).
237
+ 3. **Budget ~1 re-run per file.** If a gate whiffs, the run no longer vanishes — it exits non-zero but
238
+ **salvages a PARTIAL run** (the extraction the agent already did is written to disk). So the cost of a
239
+ missed gate is one re-run with a better `--intent` or a scripted answer, not a lost paid run.
240
+ 4. **Inspect the outputs to judge correctness.** `cowork-harness inspect <run-dir>` shows what the run
241
+ produced — the artifacts plus a shallow field preview of each JSON artifact (e.g. the extracted figures).
242
+ It works on a salvaged partial run too. (A partial run is marked `PARTIAL`; `verify-run` and `scaffold`
243
+ refuse to treat its half-finished output as a passing result.)
244
+ 5. **For image-only / scanned PDFs, use the full-parity image.** The default agent image omits OCR and
245
+ PDF-table tooling; if a **scenario** sets `requires_capabilities` (a scenario field — not skill
246
+ frontmatter) and the image provably omits one, the harness **aborts before the paid run (exit 3)** —
247
+ unless the scenario asserts `allow_missing_capability: true`, which downgrades it to a notice and
248
+ proceeds. Rebuild with `--build-arg COWORK_FULL_PARITY=1` and point `COWORK_AGENT_IMAGE` at it for those
249
+ skills.
250
+
251
+ ### Interpreting verdict signals
252
+
253
+ The run verdict may include `WARN`-severity signals in addition to pass/fail. One to watch for:
254
+
255
+ - **`prompt_asset_missing`** — the run proceeded but a prompt asset referenced by the scenario was
256
+ not found. The model ran against an incomplete prompt. This is a `WARN`, not a hard failure, so
257
+ the run can still green. If you see it, fix the asset path — a green with a missing asset is
258
+ not a valid pass.
259
+
260
+ ## 9. Scaffold a valid scenario, lint before you push, then place in CI
261
+
262
+ Don't hand-write the YAML from memory — that's how invented keys (`assertions:` vs `assert:`,
263
+ `json_file`, `answer_policy`) creep in. Start from the bundled generator, which emits the
264
+ known-good skeleton (right tier, scripted `answers:` + `on_unanswered: fail`, content assertions
265
+ separated from live-only ones, one concern per item) and **self-lints its own output**:
266
+
267
+ ```bash
268
+ S="${CLAUDE_PLUGIN_ROOT}/skills/cowork-harness/scripts/scenario.py"
269
+ # Working from a repo checkout instead of an installed plugin?
270
+ # S=".claude/skills/cowork-harness/scripts/scenario.py"
271
+ python3 "$S" scaffold --name report-check --skill ./skills/report-gen \
272
+ --prompt "Generate the weekly report to outputs/report.md." \
273
+ --content 'weekly report' --artifact outputs/report.md \
274
+ --egress-allowed api.weather.example.com --out scenarios/report-check.yaml
275
+ ```
276
+
277
+ Then lint every scenario — it encodes the no-silent-false-green invariants. Use the CLI wrapper
278
+ `cowork-harness lint` (it runs the same bundled `scenario.py lint`):
279
+
280
+ ```bash
281
+ cowork-harness lint scenarios/*.yaml
282
+ ```
283
+
284
+ `lint` flags: filesystem/egress-only assertions on a `replay` gate (silent no-op), bad regex
285
+ quoting, an egress assert on `protocol` fidelity, a `controlOut`-gated key on a non-`controlOut`
286
+ replay, mixed-class assertion items, and hallucinated schema (`assertions:` vs `assert:`, unknown
287
+ keys). Exit code is non-zero on errors (CI-friendly). `scaffold` auto-upgrades the tier if you ask
288
+ for egress on `protocol`, so it never emits a scenario `lint` would reject.
289
+
290
+ CI placement: a **token-free `replay` PR gate** (content/structure only) + a **nightly live `run`**
291
+ (filesystem/egress). Fastest setup: `uses: yaniv-golan/cowork-harness@v1` (a packaged GitHub Action with a
292
+ PR job-summary reporter). See `references/ci-recipe.md` for the Action, the manual step-by-step form, and
293
+ the four-stage pipeline.
294
+
295
+ ## 10. Debugging with `chat`
296
+
297
+ `cowork-harness chat` opens an interactive multi-turn REPL against a live Cowork session. It is
298
+ **not** an asserted test — no `assert:` block, no cassette. Use it to explore behavior, reproduce a
299
+ bug interactively, or test a prompt before committing it to a scenario.
300
+
301
+ **`--plugin <dir>` flag (repeatable).** Load additional skill folders alongside the primary session
302
+ plugin. Each `--plugin <dir>` appends the folder to `local_plugins`. Useful when the skill-under-test
303
+ depends on a sibling plugin:
304
+
305
+ ```bash
306
+ cowork-harness chat ./skills/report-gen --plugin ./skills/shared-utils
307
+ ```
308
+
309
+ **Note:** `--raw` mode (native `docker run -it`) can't honor the harness-managed flags, so `--upload`,
310
+ `--folder`, `--plugin`, and `--fidelity` are **rejected** with a usage error if combined with `--raw`;
311
+ only `--model` is carried through.
312
+
313
+ **`/help` in the REPL.** Type `/help` at the prompt to see available commands:
314
+
315
+ ```
316
+ Commands: /exit /quit /help
317
+ ```
318
+
319
+ The startup banner now reads `type your message (/help for commands)` as a reminder. `/exit` and
320
+ `/quit` both terminate the session.
321
+
322
+ ## Gotchas — the "✓ passed ≠ correct" landmines
323
+
324
+ Stated as *symptom → why → fix*. The full catalog (with `file:line`) is in the references; these
325
+ are the ones that bite hardest.
326
+
327
+ 1. **An assertion passed but tested nothing on the PR gate.** *Why:* on a manifest-less cassette
328
+ `replay` skips filesystem/egress keys (`file_exists`, `user_visible_artifact`, `artifact_json`,
329
+ `egress_*`, `no_delete_in_outputs`, `self_heal_ran`, `transcript_no_host_path`); a *mixed* item like
330
+ `{result, egress_denied}` greens on `result` while its `egress_denied` half is dropped. (`record`
331
+ snapshots an `artifacts` manifest, which makes
332
+ `file_exists`/`user_visible_artifact`/`artifact_json`/`computer_links_resolve`
333
+ replay-checkable — but the live-only egress keys stay skipped.) *Fix:* put egress/live-only checks on
334
+ a live gate; keep one concern per `assert:` item; run the linter. The harness warns loudly on skip.
335
+
336
+ 2. **A steered gate answer never reached the model.** *Why:* `serializeDecision` must emit
337
+ `updatedInput: { questions, answers }`; a header-only gate (empty `question`) can never be keyed.
338
+ *Fix:* give every gate a non-empty `question`. (multiSelect gates ARE supported on **every** answer
339
+ channel: scripted `choose:` list, in-band `--decider-dir` via a repeated `--choose` / a JSON-array
340
+ reply, and `--decider-cmd` via a JSON-array reply — all deliver the same `", "`-joined wire shape.
341
+ Free-text "Other" via `answer:`. Do NOT hand-write a multiSelect reply as a bare comma-joined
342
+ string — send an array; a scalar is treated as one selection.) `question_asked` / `questions_count_max` /
343
+ `gate_answers_delivered` only evaluate on replay **with a `controlOut` cassette** — re-record an
344
+ old cassette or they're excluded (loudly), not vacuously passed. `gate_answers_delivered` *fails*
345
+ on unobserved delivery (absence of evidence is failure, not neutral).
346
+
347
+ 3. **A multi-key `assert:` item is an AND.** A single list item with more than one key passes iff
348
+ **every** key passes. *Fix:* one concern per item unless you genuinely mean conjunction (and a
349
+ mixed-class conjunction still loses its filesystem half on replay — see gotcha 1).
350
+
351
+ 4. **`tool_called` doesn't mean "attempted".** Tool counts are authoritative and de-duped: a tool
352
+ that was *requested then denied* does **not** register as called. *Fix:* don't assert `tool_called`
353
+ to prove an attempt; it proves the tool actually ran.
354
+
355
+ 5. **`subagent_declared_but_unused` fires on declared-but-didn't-use-THAT-tool**, even if the
356
+ sub-agent used other tools. And `subagent_dispatched` matches by dispatch **description** too —
357
+ skills often dispatch with no `subagent_type` (`agentType:"unknown"`), so match the description.
358
+
359
+ 6. **`dispatch_count_max` asserts but does NOT enforce.** The harness records the count; it does not
360
+ reproduce Cowork's skip-on-cap (`{perTask:1, global:3}`, deferred). Passing means "happened to
361
+ dispatch ≤N this run," not "the harness capped it." Don't read enforcement into the assert.
362
+
363
+ 7. **`protocol` is rejected (not silently passed) if the scenario asserts egress** — boundary
364
+ assertions need a sandboxed tier (`container`+). Good: this one fails loud by design.
365
+
366
+ 8. **Read-only mounts are enforced; delete-deny is not.** `mode:r` mounts get a real `:ro` bind
367
+ (a write fails in-guest). But `rw` vs `rwd` (write-but-no-delete on `outputs/` / connected folders) is
368
+ *not* mount-enforced — `rm` succeeds and is only caught post-hoc by `no_delete_in_outputs`.
369
+
370
+ 9. **Keep `.env` out of any mounted folder** — it is copied into the sandbox and the token could
371
+ leak. Put it at a working-dir or install root (token resolution: env > `--dotenv` > `./.env` >
372
+ install `.env`). **Inverse footgun — running from a git worktree:** a worktree's `./.env` is gitignored, so
373
+ it's **absent** there and you'll get "no model credentials." *Fix:* pass `--dotenv <main-checkout>/.env`
374
+ (or set the env var) — that's exactly what `--dotenv` is for.
375
+
376
+ 10. **A base64 artifact that was scrubbed at record time will fail artifact assertions at replay.**
377
+ When `record` detects a secret embedded in a base64 artifact, it replaces the entire artifact
378
+ body with `[REDACTED:base64]` and emits a `::warning::`. Any `artifact_json` or content
379
+ assertion targeting that artifact will fail at replay because the body no longer matches. *Fix:*
380
+ do not let secrets flow into artifacts; if the artifact is intentionally opaque, drop the
381
+ content assertion and gate on `file_exists` on the live lane instead.
382
+
383
+ 11. **An external decider returning `"first"` does not select option 1.** The `"first"` keyword
384
+ shorthand is disabled for `--decider-cmd` / `--decider-dir` helpers (see §5). If your helper
385
+ accidentally emits `"first"` and no label named `"first"` exists, the gate fails — it does
386
+ **not** silently pick the first option. This is intentional: a helper bug should fail loud, not
387
+ green wrong. *Fix:* have helpers return a label name or numeric index.
388
+
389
+ 12. **`prompt_asset_missing` is a WARN, not a hard failure — greens can hide it.** The
390
+ `prompt_asset_missing` verdict signal (see §8) does not block a green verdict. Scan the verdict
391
+ signals section after every run; a run that greened with this signal ran against an incomplete
392
+ prompt. *Fix:* treat `prompt_asset_missing` as a blocking error in CI by checking the signals
393
+ array.
394
+ 13. **`result: success` means the agent didn't error, NOT that the task completed — always assert on
395
+ artifacts/content.** A turn that ends on a plain-text re-ask ("which file did you mean?") still
396
+ reports `result: success`. The harness catches a run that ends on a question and did **no productive
397
+ work after its last gate** — both the no-gate case ("which file?" with no tool calls) AND the
398
+ *answered-gate-then-re-ask* case (the agent answers an `AskUserQuestion`, then asks again in plain text
399
+ and stops) — with a **`stalled`** verdict signal (suppress with `allow_stall: true` if ending on a
400
+ question is intended). The signal is a **tool-position heuristic**, not deliverable detection, so it is
401
+ imprecise both ways: (a) a post-gate tool *call* clears the flag whether it **succeeded or errored** (false
402
+ negative — an agent that ran a tool after the gate and still stalled is not caught); and (b) a deliverable
403
+ written *before* a final confirmation gate does **not** clear it, so a write-then-confirm-then-question run
404
+ is flagged (false positive — use `allow_stall: true` for a deliberate confirm-terminal skill). The broad
405
+ guard is therefore YOUR assertions — assert the deliverable
406
+ (`file_exists` / `artifact_json` / `transcript_matches`), never just `result: success`. Note:
407
+ `on_unanswered` governs **unanswered** `AskUserQuestion` gates; the `stalled` signal covers stalling
408
+ *after* one is answered. A "type-it-in-notes" option has **no scripted deterministic answer** today (the
409
+ `OTHER:` directive works only on the LLM-decider path, not scripted `choose:`; on an options-bearing gate
410
+ a bare out-of-set LLM answer fails loud (exit 2) — see the LLM-decider free-text note in
411
+ `references/fidelity-and-answers.md`).
412
+ 14. **A positional `choose` (`first` / index) is order-dependent.** `choose: "2"` survives label drift
413
+ but NOT option *re-ordering* — if the gate presents its options in a different order run-to-run, the
414
+ index lands on a different option (a silent re-record flake). Prefer an exact label when order is
415
+ stable; `lint` flags positional `choose` with an advisory.
416
+ 15. **A scripted `choose:` matching no offered option HARD-fails the run — `on_unanswered: first` does NOT
417
+ backstop it.** This is distinct from an *unanswered* gate (no rule matched → falls to `on_unanswered`): a
418
+ rule that DID match the gate but whose `choose:` names a label the gate never offered (the model reworded
419
+ it) is treated as an authoring bug and fails loud — `first`/`llm` won't absorb it. The error now prints the
420
+ **offered options** (and a closest-match suggestion), so fix the anchor from the error alone — no need to
421
+ dig through `events.jsonl`. (This is exactly the drift `verify-run` answer-coverage catches in ~1s; use it
422
+ before a paid record.)
423
+ 16. **Batch record keeps going — you don't need a one-at-a-time wrapper.** `record <dir>` and `record <dir>
424
+ --rerecord-stale` run **every** scenario, collect failures, and report them at the end (non-zero exit on
425
+ any failure) — a failing scenario does NOT abort the batch. So a single `cowork-harness record cassettes/
426
+ --rerecord-stale` surfaces ALL stale anchors in one pass (add `--concurrency <N>` to parallelize); a shell
427
+ wrapper that loops one cassette at a time with `set -e` defeats this and rediscovers stale anchors serially.
428
+
429
+ 17. **Editing `scenarios/*.yaml` `assert:` does NOT change a plain `replay`.** *Why:* `replay` evaluates the
430
+ assertions **frozen in the cassette** by default — it is byte-deterministic and ignores the working tree (so
431
+ a committed cassette can't silently re-interpret against an uncommitted YAML). This used to be a *silent*
432
+ no-op; now plain `replay` prints a `::notice::` when a sibling's `assert:` differs and points you at the fix.
433
+ *Fix:* to re-check token-free against the edited block, `replay --assert-from <scenario.yaml>` (or
434
+ `--reassert`). That opt-in path is safe by construction for the authored fields — it **hard-fails** if
435
+ `prompt`/`answers`/`baseline`/`fidelity`/`skills`/`requires_capabilities` or the skill content (when a
436
+ fingerprint exists) drifted from the recording (re-record then), and `expect_denied`/filesystem/egress keys
437
+ are sourced but stay **live-only** (it warns; they don't move the replay verdict). **Caveat:** the `session`
438
+ (model / data mounts / discovery) is NOT drift-checked or fingerprinted, so a **model change** between record
439
+ and re-assert is undetected — the notice flags this; re-record if the session changed. `verify-run` reads
440
+ on-disk `assert:` against a kept *run dir*; `replay --assert-from` is the equivalent for a *cassette*.
441
+
442
+ For the complete gotcha list, the assertion catalog, the YAML schema, the fidelity/answer tables,
443
+ and the CI recipe, read the files in `references/`.
444
+
445
+ ## References
446
+
447
+ - `references/scenario-schema.md` — scenario/session YAML schema, full assertion catalog (with each
448
+ key's replay class), the web_fetch model, and the complete gotcha list.
449
+ - `references/fidelity-and-answers.md` — fidelity tiers, answer paths, the determinism contract.
450
+ - `references/ci-recipe.md` — the packaged GitHub Action, replay-vs-live lane split, and the four-stage
451
+ GitHub Actions pipeline.
452
+ - `scripts/scenario.py` — `scaffold` a valid scenario skeleton and `lint` scenarios for the
453
+ no-silent-false-green invariants (both usable as CI steps).
454
+ - `../../../docs/run-status.md` — checking a background run's status without `ps aux`.