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.
- package/.claude/skills/cowork-harness/SKILL.md +454 -0
- package/.claude/skills/cowork-harness/references/ci-recipe.md +272 -0
- package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +191 -0
- package/.claude/skills/cowork-harness/references/scenario-schema.md +442 -0
- package/.claude/skills/cowork-harness/scripts/assertion-keys.json +8 -0
- package/.claude/skills/cowork-harness/scripts/scenario.py +10 -1
- package/AGENTS.md +74 -0
- package/CHANGELOG.md +274 -1
- package/CONTRIBUTING.md +83 -0
- package/DESIGN.md +192 -0
- package/README.md +160 -92
- package/SECURITY.md +63 -0
- package/SPEC.md +584 -0
- package/baselines/desktop-1.11847.5.json +2 -0
- package/baselines/desktop-1.12603.1.json +2 -0
- package/baselines/desktop-1.13576.1.json +3 -1
- package/baselines/desktop-1.14271.0.json +3 -1
- package/baselines/desktop-1.15200.0.json +3 -1
- package/baselines/desktop-1.15962.0.json +3 -1
- package/baselines/desktop-1.15962.1.json +3 -1
- package/baselines/desktop-1.17377.1.json +4 -1
- package/baselines/desktop-1.17377.2.json +232 -0
- package/baselines/desktop-1.18286.0.json +250 -0
- package/baselines/prompts/desktop-1.18286.0/system-prompt-append.md +214 -0
- package/dist/agent/session.js +33 -4
- package/dist/assert.js +88 -0
- package/dist/baseline.js +96 -9
- package/dist/cli.js +1019 -201
- package/dist/hostloop/pretooluse-path-hook.js +141 -0
- package/dist/hostloop/safety.js +28 -0
- package/dist/hostloop/workspace-handler.js +3 -4
- package/dist/prompt.js +31 -8
- package/dist/run/cassette.js +95 -17
- package/dist/run/chat.js +70 -5
- package/dist/run/computer-links.js +198 -0
- package/dist/run/diff.js +178 -0
- package/dist/run/display-translate.js +47 -0
- package/dist/run/doctor.js +41 -7
- package/dist/run/envelope.js +70 -10
- package/dist/run/execute.js +163 -11
- package/dist/run/matrix.js +131 -0
- package/dist/run/renderer.js +36 -12
- package/dist/run/repeat.js +89 -0
- package/dist/run/run-index.js +253 -0
- package/dist/run/run-status.js +12 -0
- package/dist/run/run.js +15 -2
- package/dist/run/trace-view.js +59 -7
- package/dist/run/verdict.js +9 -1
- package/dist/runtime/argv.js +26 -4
- package/dist/runtime/hostloop-stage.js +94 -0
- package/dist/runtime/hostloop.js +145 -57
- package/dist/scan.js +26 -8
- package/dist/session.js +36 -0
- package/dist/sync/baseline-diff.js +138 -0
- package/dist/sync/cowork-sync.js +24 -2
- package/dist/types.js +105 -12
- package/dist/vm-paths.js +341 -0
- package/docs/README.md +6 -1
- package/docs/boundary.md +11 -5
- package/docs/cassette.md +55 -24
- package/docs/chat.md +12 -5
- package/docs/debugging.md +22 -2
- package/docs/fidelity-gaps.md +16 -1
- package/docs/gotchas.md +17 -0
- package/docs/invariants.md +24 -0
- package/docs/maintenance.md +25 -0
- package/docs/protocol.md +136 -0
- package/docs/scenario.md +115 -9
- package/docs/session.md +7 -0
- package/docs/stats.md +74 -0
- package/examples/README.md +22 -17
- package/examples/replays/README.md +10 -1
- package/examples/replays/example-multiselect-gate.cassette.json +98 -0
- package/examples/replays/example-pdf-skill-ci-selftest-failing.yaml +30 -0
- package/examples/replays/example-pdf-skill.cassette.json +24 -25
- package/fixtures/protocol/v1/allow-response.json +13 -0
- package/fixtures/protocol/v1/deny-response.json +11 -0
- package/fixtures/protocol/v1/hook-callback.json +27 -0
- package/fixtures/protocol/v1/initialize.json +18 -0
- package/fixtures/protocol/v1/mcp-message.json +42 -0
- package/fixtures/protocol/v1/permission-request.json +13 -0
- package/fixtures/protocol/v1/question-answer-response.json +36 -0
- package/fixtures/protocol/v1/question-request.json +33 -0
- package/llms.txt +44 -0
- package/package.json +15 -3
- package/python/README.md +19 -11
- package/schema/protocol.v1.json +261 -0
- package/schema/run-result.json +24 -2
- package/schema/scenario.schema.json +53 -2
- 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`.
|