cowork-harness 0.18.0 → 0.19.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/CHANGELOG.md CHANGED
@@ -6,6 +6,60 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.19.0] — 2026-06-30
10
+
11
+ ### Changed
12
+
13
+ - **The default `--decider-llm` answering model is now Sonnet (`claude-sonnet-4-5`), not Haiku.** A
14
+ measurement on a real-doc skill with judgment-heavy gates found the prior Haiku default
15
+ **prose-declined ~50% of them** (replying "I don't have information…" instead of picking an option →
16
+ a fail-loud whiff); a Sonnet decider binds those gates and the run proceeds. ⚠️ This **raises per-gate
17
+ token cost** for every `--decider-llm` run, and a gate a weaker model would have prose-declined (and
18
+ failed fast) is now more likely to be answered and the run to continue (longer/costlier). Pin a
19
+ cheaper model to restore the old cost/behavior: `--decider-model <haiku-id>` or
20
+ `COWORK_HARNESS_DECIDER_MODEL`. (n is small — read this as "Haiku is too weak for judgment-heavy
21
+ gates," not a precise rate.) The whiff error now also names the `--decider-model` lever.
22
+ ⚠️ **False-green caveat (new guidance, no behavior change):** binding-and-proceeding is the upside,
23
+ but a *decided* answer is the decider's best guess from the transcript tail — it never sees the
24
+ mounted documents — so it can fabricate (oracle-less gate) or get a doc-answerable fact wrong, and a
25
+ green run resting on it is a false pass. **Script any gate whose answer feeds a _semantic_ assertion
26
+ (`--answer` / `--answer-policy`); reserve `--decider-llm` for structural-assertion runs.** See
27
+ `references/fidelity-and-answers.md` in the skill.
28
+ - **Stall detector now also flags a stall AFTER an answered gate (H3).** The `stalled` verdict signal
29
+ previously fired only when a run ended on a question having made *no tool calls at all*. It now fires
30
+ when a run ends on a question and made **no productive tool call after its last `AskUserQuestion` gate** —
31
+ catching the case where the agent answers a gate, then re-asks in plain text and stops with the
32
+ deliverable never produced (previously a `result: "success"` false-green). The no-gate case is unchanged.
33
+ Re-derives identically on replay. ⚠️ This can flip a previously-green scenario to red — set
34
+ `allow_stall: true` to restore the prior verdict if ending on a question is the intended terminal state
35
+ (e.g. a skill that writes its output and then ends on a confirmation/"anything else?" question). The signal
36
+ is a *tool-position heuristic*, not deliverable detection, so it is imprecise in both directions: a post-gate
37
+ tool *call* (successful or errored) clears the flag even if nothing useful was produced (false negative);
38
+ and a deliverable produced *before* the final gate does NOT clear it, so a write-then-confirm-then-question
39
+ run is flagged (false positive). Assertions are the real guard — assert the deliverable
40
+ (`file_exists`/`artifact_json`), never `result` alone — and use `allow_stall` for a deliberate
41
+ question-terminal skill.
42
+
43
+ ### Fixed
44
+
45
+ - **`--decider-llm` now binds an echoed grant label on a `web_fetch` approval gate** instead of failing
46
+ loud. The web_fetch permission-approval path matched the model's reply with an exact-label check only,
47
+ so a reply that echoed the option plus a self-glossed tail past a `:` boundary (e.g.
48
+ `Allow once: fetch this URL one time` against option `Allow once`) aborted the run — the same echo shape
49
+ the `AskUserQuestion` path already tolerates. The permission path now applies the same `echoPrefixMatch`
50
+ backstop, so the echoed label binds. Out-of-set replies still fail loud.
51
+ - **The three non-retried `--decider-llm` transport failures now name their mitigation in the error.** A
52
+ timeout, a `maxBytes` overflow, and a spawn failure (e.g. `ENOENT`) forfeit the run by design — they are
53
+ not transient — but the surfaced message previously named only the failure. It now points at the lever:
54
+ `COWORK_HARNESS_LLM_TIMEOUT_MS`, `COWORK_HARNESS_LLM_MAX_BYTES`, and `PATH` / `COWORK_HARNESS_CLAUDE_BIN`
55
+ respectively. No retry behavior changed.
56
+ - `doctor`'s "no token, but a Claude Code Keychain entry exists" remedy now also names the
57
+ `--dotenv` workaround. The macOS Keychain branch previously printed only "copy the token into
58
+ `./.env`", which led an operator whose token lived in a *different* file to conclude `doctor`
59
+ ignores `--dotenv` — it does not (the global `--dotenv <path>` is honored by `doctor` exactly as
60
+ by `skill`/`run`, since it loads into `process.env` before dispatch). The remedy now reads
61
+ "… or, if the token is already in another file, point at it: `cowork-harness --dotenv <path> <cmd>`".
62
+
9
63
  ## [0.18.0] — 2026-06-27
10
64
 
11
65
  ### Fixed
package/README.md CHANGED
@@ -149,7 +149,7 @@ This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that te
149
149
  /plugin install cowork-harness@cowork-harness
150
150
  ```
151
151
 
152
- The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.18.0` (a version floor that fails loud rather than silently fetching a too-old CLI; Node ≥ 20). Tiers above `protocol` still need Docker/Lima and a Claude Desktop agent binary — see the prerequisites below.
152
+ The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.19.0` (a version floor that fails loud rather than silently fetching a too-old CLI; Node ≥ 20). Tiers above `protocol` still need Docker/Lima and a Claude Desktop agent binary — see the prerequisites below.
153
153
 
154
154
  It also follows the open [Agent Skills](https://github.com/vercel-labs/skills) spec, so it installs cross-editor (Cursor, Codex, OpenCode, …) by pointing the `npx skills` CLI at `.claude/skills/cowork-harness` in this repo. (Working *inside* this repo, the skill auto-loads as a project skill — no install needed.)
155
155
 
@@ -482,7 +482,7 @@ Most runs need **none** of these — the defaults are correct. They're grouped b
482
482
  - `COWORK_CONTAINER_RUNTIME=podman` — use Podman instead of Docker.
483
483
  - `COWORK_AGENT_IMAGE=<tag>` — override the agent image name (default `cowork-agent-base:2`); `COWORK_AGENT_BINARY=<path>` — override the auto-detected staged agent ELF.
484
484
  - `COWORK_SKIP_CAPABILITY_PROBE=1` — skip the per-run capability probe (the harness otherwise probes the agent image/VM for the document/OCR/Office capabilities the real Cowork rootfs ships and **fails a run that uses one the image omits** — a likely false negative; suppress per-scenario with `allow_missing_capability: true`, or rebuild full parity).
485
- - `COWORK_HARNESS_DECIDER_MODEL` — the default `--decider-llm` answering model (overridden by the `--decider-model` flag; falls back to the Haiku default). `COWORK_HARNESS_DECIDER_DIR_POLL_MS` / `_TIMEOUT_MS` — tune the `--decider-dir` rendezvous poll/backstop; `COWORK_HARNESS_DECIDER_CMD_TIMEOUT_MS` / `COWORK_HARNESS_LLM_TIMEOUT_MS` — backstop a hung `--decider-cmd` helper / `--decider-llm` model call (default 600 s, fail loud); `COWORK_HARNESS_LLM_RETRIES` — bounded retries for a transient non-zero `claude -p` exit in the `--decider-llm` transport (default 2, clamped to 0–10; set `0` to disable, e.g. deterministic CI); `COWORK_HARNESS_DIALOG_TIMEOUT_MS` — override the 6 s dialog auto-cancel.
485
+ - `COWORK_HARNESS_DECIDER_MODEL` — the default `--decider-llm` answering model (overridden by the `--decider-model` flag; falls back to the Sonnet default — pin a cheaper model for simple gates to cut cost). `COWORK_HARNESS_DECIDER_DIR_POLL_MS` / `_TIMEOUT_MS` — tune the `--decider-dir` rendezvous poll/backstop; `COWORK_HARNESS_DECIDER_CMD_TIMEOUT_MS` / `COWORK_HARNESS_LLM_TIMEOUT_MS` — backstop a hung `--decider-cmd` helper / `--decider-llm` model call (default 600 s, fail loud); `COWORK_HARNESS_LLM_RETRIES` — bounded retries for a transient non-zero `claude -p` exit in the `--decider-llm` transport (default 2, clamped to 0–10; set `0` to disable, e.g. deterministic CI); `COWORK_HARNESS_LLM_MAX_BYTES` — stdout cap on a `--decider-llm` model call (default 8 MiB, fail loud past it); `COWORK_HARNESS_DIALOG_TIMEOUT_MS` — override the 6 s dialog auto-cancel.
486
486
  - `COWORK_HARNESS_RUNS_DIR` (or the `--run-dir <path>` flag — a **global** flag that must precede the subcommand, like `--dotenv`) — override the default run-output root `~/.cowork-harness/runs` (kept out of any working tree so sensitive skill inputs/outputs don't land in a repo). Precedence: `--run-dir` > env > default. The root is flat and machine-global (shared across projects); pinned `--session-id` runs are guarded against cross-project overwrite, and `prune` never prunes them. In CI, set it to a workspace path (e.g. `runs`) so artifact upload can collect the runs. `COWORK_HARNESS_ALLOW_FOREIGN_RESUME=1` overrides the guard that blocks `--resume` onto another project's pinned session.
487
487
  - **Networking / loop:** `COWORK_EGRESS_PROXY` overrides the egress-proxy URL injected into the sandbox; `COWORK_PROXY_IMAGE` overrides the egress proxy Docker image name (default `cowork-egress-proxy:1`); `COWORK_DOCKER_NETWORK` pins the Docker network the agent container joins; `CLAUDE_FORCE_HOST_LOOP=1` forces the host-loop path regardless of the baseline's loop decision (the `cowork` tier's auto-pick). `COWORK_LIMACTL` overrides the `limactl` binary path (default `/opt/homebrew/bin/limactl`).
488
488
  - **Strictness escape hatches** (the harness fails loud by default): `COWORK_HARNESS_SOFT_MISSING=1` downgrades a missing mount source from a hard error to warn-and-exclude; `COWORK_HARNESS_ALLOW_CONFIG_DIR_WRITE=1` permits writing into an existing pinned `plugins.config_dir` (otherwise refused, to avoid clobbering a real Claude config).
@@ -570,7 +570,7 @@ This repo is built to be driven by agents, not just read by humans:
570
570
 
571
571
  - ✅ **Three isolation tiers (L0/L1/L2) + two loop-mode overlays** — `protocol` (L0 control loop), `container` (L1 sandboxed arm64 + per-run default-deny egress sidecar), `microvm` (L2 real Apple-VZ Linux microVM + guest firewall); plus the loop-mode overlays `hostloop` (production split-execution: agent loop on host, shell/web via the workspace SDK-MCP server) and `cowork` (auto-picks host-loop vs container the way Cowork does — gate `1143815894`). Egress enforced at container/microvm/hostloop; `boundary-check` reports **ALL CONSTRAINTS ENFORCED**.
572
572
  - ✅ **Three-seam driver** — `AgentSession` (control protocol) → `Decider` (scripted + `on_unanswered` policy, no silent false-greens) → `Run` (turn loop, sub-agent dispatch tree, `RunRecord`). Multi-turn `chat`, deterministic cassette `record`/`replay` (no token), `run.jsonl`/`trace.json` logging with secret-scrub.
573
- - ✅ **Answering live questions, every way you'd need** — `--decider-llm --intent "<one line>"` (a small model picks per question, steered by your test intent — the ergonomic default; it replies with the option **number**, mapped to the exact label, so it can't whiff by parroting the label text, and an out-of-set reply fails loud; raise the answering model for ambiguous judgment gates with **`--decider-model <id>`**; non-determinism is flagged so a green isn't mistaken for a scripted pass), `--answer-policy <yaml>`/`--answer "rx=c"` (declarative regex→label, deterministic CI — accepts a **stable partial anchor** for skills whose labels drift run-to-run), `--decider-cmd '<helper>'` (custom logic — the Python `serve_decider(fn)` adapter pre-builds the wire loop so the helper writes only the decision), and **`--decider-dir <dir>`** (the *driving agent* answers each gate **in-band** with full context — it arms a Monitor on `cowork-harness gates <dir> --follow` and replies with `cowork-harness answer <dir> --gate <N> --choose <label>`; the session-under-test stays live, no resume/re-worded question — binary-verified). **multiSelect gates** are answerable on every channel — repeat `--choose` (or, for `--decider-llm`, a comma-list of option numbers; or a JSON-array reply from a `--decider-cmd` helper / hand-written `resp-N.json`); all deliver the binary-verified `", "`-joined wire shape. Every channel keeps stdout free, so all compose with `--output-format json`. A question is **never silently answered with option 1** — unhandled fails loud. Validate any decider in ~2s with `cowork-harness decide`.
573
+ - ✅ **Answering live questions, every way you'd need** — `--decider-llm --intent "<one line>"` (a model picks per question, steered by your test intent — the ergonomic default; defaults to **Sonnet** (a weaker model prose-declines ambiguous judgment gates → fail-loud), override with **`--decider-model <id>`** — pin a cheaper model for simple gates or a stronger one for the hardest; it replies with the option **number**, mapped to the exact label, so it can't whiff by parroting the label text, and an out-of-set reply fails loud; non-determinism is flagged so a green isn't mistaken for a scripted pass — and ⚠️ a *decided* gate whose answer feeds a **semantic** assertion can still false-green, so script those), `--answer-policy <yaml>`/`--answer "rx=c"` (declarative regex→label, deterministic CI — accepts a **stable partial anchor** for skills whose labels drift run-to-run), `--decider-cmd '<helper>'` (custom logic — the Python `serve_decider(fn)` adapter pre-builds the wire loop so the helper writes only the decision), and **`--decider-dir <dir>`** (the *driving agent* answers each gate **in-band** with full context — it arms a Monitor on `cowork-harness gates <dir> --follow` and replies with `cowork-harness answer <dir> --gate <N> --choose <label>`; the session-under-test stays live, no resume/re-worded question — binary-verified). **multiSelect gates** are answerable on every channel — repeat `--choose` (or, for `--decider-llm`, a comma-list of option numbers; or a JSON-array reply from a `--decider-cmd` helper / hand-written `resp-N.json`); all deliver the binary-verified `", "`-joined wire shape. Every channel keeps stdout free, so all compose with `--output-format json`. A question is **never silently answered with option 1** — unhandled fails loud. Validate any decider in ~2s with `cowork-harness decide`.
574
574
  - ✅ **Sub-agent aggregation + `trace`** — recognizes the real `Agent` dispatch tool (binary-verified; `Task` is its alias) so `subagent_dispatched`/`dispatch_count_max` fire under `--fidelity cowork`, excluding the `TaskCreate` todo list; `cowork-harness trace <id> --view tools` digests `events.jsonl` (each tool row now shows its **result status** `ok`/`error`), and `trace <id> --view questions` shows the gate lifecycle (**question → injected answer → delivered result**); `result.json`/`--keep` surface the deep `mnt/outputs` deliverable path.
575
575
  - ✅ **Answer delivery is verified, not assumed** — an AskUserQuestion answer is injected as the binary's full tool input (`{questions, answers}`, ELF-verified), so the answer actually reaches the model; `RunResult.gateDeliveries[]` + the `gate_answers_delivered` assertion catch any delivery failure, and `RunResult.toolCounts` gives the **truthful** per-tool call count (host-routed `WebSearch` shows here, not the always-0 `usage.server_tool_use`).
576
576
  - ✅ **Binary-grounded fidelity** — cwd `/sessions/<id>`, the three-channel MCP model (`--mcp-config` honored in plain cowork mode; host/API-routed `web_fetch`; SDK-server delivery), host-loop tool partition, auth-env token-only drop, and production GrowthBook gates pinned per release.
@@ -0,0 +1,220 @@
1
+ {
2
+ "baselineVersion": 1,
3
+ "appVersion": "1.15962.1",
4
+ "agentVersion": "2.1.187",
5
+ "agentBinary": {
6
+ "stagedPath": "~/Library/Application Support/Claude/claude-code-vm/2.1.187/claude",
7
+ "format": "elf-aarch64"
8
+ },
9
+ "guest": {
10
+ "os": "linux",
11
+ "arch": "arm64",
12
+ "baseImage": "ubuntu:22.04"
13
+ },
14
+ "spawn": {
15
+ "configDirInGuest": "mnt/.claude",
16
+ "settingSources": [
17
+ "user"
18
+ ],
19
+ "permissionMode": "default",
20
+ "maxThinkingTokens": 31999,
21
+ "effortDefault": "medium",
22
+ "tools": [
23
+ "Task",
24
+ "Bash",
25
+ "Glob",
26
+ "Grep",
27
+ "Read",
28
+ "Edit",
29
+ "Write",
30
+ "NotebookEdit",
31
+ "WebFetch",
32
+ "TaskCreate",
33
+ "TaskUpdate",
34
+ "TaskGet",
35
+ "TaskList",
36
+ "TaskStop",
37
+ "WebSearch",
38
+ "Skill",
39
+ "REPL",
40
+ "JavaScript",
41
+ "AskUserQuestion",
42
+ "ToolSearch"
43
+ ],
44
+ "allowedTools": [
45
+ "Task",
46
+ "Bash",
47
+ "Glob",
48
+ "Grep",
49
+ "Read",
50
+ "Edit",
51
+ "Write",
52
+ "NotebookEdit",
53
+ "WebFetch",
54
+ "TaskCreate",
55
+ "TaskUpdate",
56
+ "TaskGet",
57
+ "TaskList",
58
+ "TaskStop",
59
+ "WebSearch",
60
+ "Skill",
61
+ "REPL",
62
+ "JavaScript",
63
+ "ToolSearch"
64
+ ],
65
+ "env": {
66
+ "CLAUDE_CODE_IS_COWORK": "1",
67
+ "CLAUDE_CODE_ENTRYPOINT": "local-agent",
68
+ "CLAUDE_CODE_TAGS": "lam_session_type:chat",
69
+ "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": "1",
70
+ "CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL": "true",
71
+ "CLAUDE_CODE_DISABLE_CRON": "1",
72
+ "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
73
+ "CLAUDE_CODE_DISABLE_AGENTS_FLEET": "1",
74
+ "CLAUDE_CODE_ENABLE_APPEND_SUBAGENT_PROMPT": "1",
75
+ "CLAUDE_CODE_ENABLE_TASKS": "true",
76
+ "CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1",
77
+ "ENABLE_PROMPT_CACHING_1H": "1",
78
+ "DISABLE_MICROCOMPACT": "1",
79
+ "MCP_CONNECTION_NONBLOCKING": "true"
80
+ },
81
+ "promptTemplate": "prompts/desktop-1.15200.0/system-prompt-append.md",
82
+ "subagentAppend": "prompts/desktop-1.15200.0/subagent-append-vm.md",
83
+ "$comment": "Binary-verified Desktop->agent spawn contract (asar 1.12603.1). See docs/cowork-spawn-contract-1.12603.1.md.",
84
+ "$comment_notSet": "Deliberately NOT set: CLAUDE_CODE_USE_COWORK_PLUGINS (Desktop never sets it; would flip the agent to cowork_settings.json/cowork_plugins). Host-derived (TZ, account UUIDs, WORKSPACE_HOST_PATHS, OTEL) injected at runtime, not pinned.",
85
+ "$comment_prompts": "Reconstructed cowork-specific sections, re-paraphrased from asar 1.15200.0 const MZi (system prompt) + generator CVr (subagent) — not the full base prompt (not cleanly extractable), and excluding the generic refusal/safety tail. The main append is delivered via the --append-system-prompt CLI flag (layered on the agent's built-in base prompt), NOT the initialize handshake; only the subagent append goes over initialize (appendSubagentSystemPrompt), gated on CLAUDE_CODE_ENABLE_APPEND_SUBAGENT_PROMPT."
86
+ },
87
+ "mountLayout": {
88
+ "sessionRoot": "/sessions/{sessionId}",
89
+ "cwd": "/sessions/{sessionId}",
90
+ "mntRoot": "/sessions/{sessionId}/mnt",
91
+ "mounts": [
92
+ {
93
+ "name": "uploads",
94
+ "mountPath": "uploads",
95
+ "mode": "r",
96
+ "purpose": "user-uploaded files (read-only — asar 'ro')"
97
+ },
98
+ {
99
+ "name": "projects",
100
+ "mountPath": ".projects/{projectId}",
101
+ "mode": "rw",
102
+ "purpose": "RESERVED namespace (and the separate UUID project-sync feature) — NOT the work-folder path. From Desktop 1.14271.0 selected work folders mount at mnt/<collision-resolved-basename> (dynamic, derived per session by buildLaunchPlan; see MOUNT_BARE_NAME_MIN_VERSION). This decorative row is not consumed for binding (staged paths come from plan.mounts)."
103
+ },
104
+ {
105
+ "name": "local-plugins",
106
+ "mountPath": ".local-plugins/marketplaces",
107
+ "mode": "r",
108
+ "purpose": "marketplace skills/plugins, runtime-discovered"
109
+ },
110
+ {
111
+ "name": "remote-plugins",
112
+ "mountPath": ".remote-plugins",
113
+ "mode": "r",
114
+ "purpose": "org-remote plugins, runtime-discovered"
115
+ },
116
+ {
117
+ "name": "outputs",
118
+ "mountPath": "outputs",
119
+ "mode": "rw",
120
+ "purpose": "session outputs/artifacts — delete denied by default (asar IX); rwd only when approved"
121
+ }
122
+ ]
123
+ },
124
+ "network": {
125
+ "mode": "gvisor",
126
+ "allowKind": "allowlist",
127
+ "allowDomains": [
128
+ "sentry.io",
129
+ "preview.claude.ai",
130
+ "downloads.claude.ai",
131
+ "api.anthropic.com",
132
+ "a-cdn.anthropic.com",
133
+ "a-api.anthropic.com",
134
+ "assets.claude.ai",
135
+ "console.anthropic.com",
136
+ "api-staging.anthropic.com",
137
+ "www.anthropic.com",
138
+ "docs.anthropic.com",
139
+ "mcp-proxy.anthropic.com",
140
+ "pivot.claude.ai",
141
+ "support.anthropic.com"
142
+ ]
143
+ },
144
+ "bgEnvStrip": {
145
+ "knownVars": [
146
+ "CLAUDE_CODE_OAUTH_TOKEN",
147
+ "CLAUDE_CODE_SESSION_KIND",
148
+ "CLAUDE_CODE_SESSION_ID",
149
+ "CLAUDE_CODE_SESSION_NAME",
150
+ "CLAUDE_CODE_SESSION_LOG"
151
+ ]
152
+ },
153
+ "$comment": "Platform baseline auto-derived by `cowork-harness sync` from a live Claude Desktop install + app.asar. VOLATILE per-release facts only. Regenerate per release; review the diff. Captured 2026-06-27 on macOS arm64.",
154
+ "capturedAt": "2026-06-27",
155
+ "platform": "darwin-arm64",
156
+ "settings": {
157
+ "autoMountFolders": {
158
+ "key": "autoMountFolders",
159
+ "default": false
160
+ },
161
+ "localAgentModeTrustedFolders": {
162
+ "key": "localAgentModeTrustedFolders",
163
+ "default": []
164
+ }
165
+ },
166
+ "provenance": {
167
+ "asarPath": "/Applications/Claude.app/Contents/Resources/app.asar",
168
+ "asarFingerprint": "687dfcfca5bdfc0f",
169
+ "gates": {
170
+ "$comment": "Production GrowthBook gate states decoded from ~/Library/Application Support/Claude/fcache (standard interactive Anthropic account, 2026-06-13; binary-verified app.asar 1.12603.1). Pin per release. Behavior-affecting gates the harness models: 1143815894 (loop), 1648655587 (dispatch cap), 1978029737 (web_fetch routing). Telemetry/auth-internal gates omitted.",
171
+ "bridgeSdkTransport:583857784": {
172
+ "on": true,
173
+ "source": "force",
174
+ "value": true,
175
+ "note": "— Cowork uses the SDK-based transport (control protocol), confirming the harness's sdkMcpServers/mcp_message path is the production transport."
176
+ },
177
+ "hostLoop:1143815894": {
178
+ "on": true,
179
+ "source": "force",
180
+ "value": true
181
+ },
182
+ "taskDispatchLimiter:1648655587": {
183
+ "on": true,
184
+ "source": "force",
185
+ "value": {
186
+ "perTask": 1,
187
+ "global": 3
188
+ },
189
+ "note": "perTask=1 global=3 (host-side SKIP: recordSkipAndEmit/GCA.PerTaskLimit — NOT queue/deny). A dispatch session launches <=1 sub-task; <=3 concurrent globally."
190
+ },
191
+ "coworkRuntimeConfig:1978029737": {
192
+ "on": true,
193
+ "source": "force",
194
+ "value": {
195
+ "sessionsBridgePollBlockMs": 30,
196
+ "coworkNativeFilePreview": true,
197
+ "coworkWebFetchViaApi": true,
198
+ "coworkWebFetchPrompt": true,
199
+ "workspaceBashWaitLonger": true
200
+ },
201
+ "note": "coworkWebFetchViaApi=true coworkWebFetchPrompt=true workspaceBashWaitLonger=true sessionsBridgePollBlockMs=30 — web_fetch is host/API-routed (POST /api/organizations/<org>/cowork/web_fetch), NOT container egress; gated by a separate web-fetch hostname allowlist + URL provenance."
202
+ },
203
+ "cliPlugin:2307090146": {
204
+ "on": false,
205
+ "source": "defaultValue",
206
+ "value": false,
207
+ "note": "— the CLI-plugin credential broker is dark-launched off for standard interactive accounts (Ch23/L106)."
208
+ },
209
+ "pluginSyncSparkplug:2340532315": {
210
+ "on": true,
211
+ "source": "force",
212
+ "value": true,
213
+ "note": "— startup syncPlugins(); plugins load via --plugin-dir (registry inert in-VM)."
214
+ }
215
+ },
216
+ "eipcChannelUuid": "4f426349-8d6f-45f3-ae22-280fef323564",
217
+ "$comment": "eipcChannelUuid is per-build; recorded for provenance only — the harness does not use Desktop IPC."
218
+ },
219
+ "requireFullVmSandbox": null
220
+ }
package/dist/cli.js CHANGED
@@ -170,16 +170,16 @@ Questions:
170
170
  first pick option 1, loudly warn — then the footer prints the --answer to lock it in
171
171
  (the footer always echoes auto-answered questions as copy-pasteable --answer lines)
172
172
  (to answer LIVE questions, use --decider-llm / --decider-cmd / --decider-dir below)
173
- --decider-llm [--intent "<one line>"] answer LIVE questions with a small model (the ergonomic
173
+ --decider-llm [--intent "<one line>"] answer LIVE questions with a model (Sonnet default; the ergonomic
174
174
  default for agent-driven runs: state the test's intent once instead of
175
175
  writing a helper). The model replies with the option NUMBER (code maps it
176
176
  to the exact label); an out-of-set answer FAILS LOUD. NON-deterministic —
177
177
  the footer flags the run so a green isn't mistaken for a scripted pass;
178
178
  pin with --answer for CI.
179
179
  --decider-model <id> override the --decider-llm answering model (flag > env
180
- COWORK_HARNESS_DECIDER_MODEL > Haiku default). Use a stronger model for
181
- genuinely ambiguous judgment gates; it won't make an under-specified gate
182
- deterministic. Requires --decider-llm.
180
+ COWORK_HARNESS_DECIDER_MODEL > Sonnet default). Pin a cheaper model
181
+ (e.g. a Haiku id) for simple gates to cut cost; it won't make an
182
+ under-specified gate deterministic. Requires --decider-llm.
183
183
  --decider-cmd '<helper>' answer the LIVE question via a spawned helper (for custom logic). The
184
184
  helper reads a {"type":"decision_request",…} line on stdin and writes
185
185
  back {"answers":{"<q>":"<label or 1-based index>"}} (MUST flush per
@@ -231,7 +231,7 @@ Input policy:
231
231
  (run omits --decider-llm by design — scenarios pin answers for reproducibility; a scenario may still
232
232
  opt into the model with 'on_unanswered: llm' in its YAML, which flags the run non-deterministic)
233
233
  --decider-model <id> override the answering model for 'on_unanswered: llm' scenarios
234
- (flag > env COWORK_HARNESS_DECIDER_MODEL > Haiku default); no-op for
234
+ (flag > env COWORK_HARNESS_DECIDER_MODEL > Sonnet default); no-op for
235
235
  scenarios that don't use the model terminal.
236
236
  (per-scenario answers/on_unanswered in the YAML take precedence where set)
237
237
 
@@ -821,7 +821,7 @@ async function cmdRun(rawArgs) {
821
821
  if (hasHelp(rawArgs))
822
822
  return void log(RUN_HELP);
823
823
  // --decider-model overrides the LLM decider's answering model for scenarios that use `on_unanswered: llm`
824
- // (flag > env COWORK_HARNESS_DECIDER_MODEL > Haiku default). `takeCommonFlags` doesn't know it (it's not a
824
+ // (flag > env COWORK_HARNESS_DECIDER_MODEL > Sonnet default). `takeCommonFlags` doesn't know it (it's not a
825
825
  // common flag), so pull it out of argv first — otherwise it would land as an "unexpected argument".
826
826
  let deciderModel;
827
827
  const preArgs = [];
@@ -383,7 +383,7 @@ export function coerceWebFetchGrant(answer) {
383
383
  throw new UnansweredError(`web_fetch approval answer "${answer.trim().slice(0, 60)}" is not a valid grant option`, "expected one of: Allow once | Allow all for website | Deny");
384
384
  }
385
385
  /**
386
- * `agent` policy — the LLM decider. Per live question, asks a small model to pick ONE option (by label,
386
+ * `agent` policy — the LLM decider. Per live question, asks the answering model to pick ONE option (by label,
387
387
  * never index — options reorder run-to-run), optionally steered by a one-line `--intent`. The ergonomic
388
388
  * default for agent-driven runs: state the test's meaning once instead of hand-writing a `--decider-cmd`
389
389
  * helper. NON-DETERMINISTIC by nature — `executeScenario` flags the run so a green LLM-steered pass can't
@@ -394,7 +394,7 @@ export class LlmDecider {
394
394
  intent;
395
395
  model;
396
396
  secrets;
397
- constructor(complete, intent, model = process.env.COWORK_HARNESS_DECIDER_MODEL || "claude-haiku-4-5-20251001",
397
+ constructor(complete, intent, model = process.env.COWORK_HARNESS_DECIDER_MODEL || "claude-sonnet-4-5",
398
398
  // #62: the default transport spawns `claude -p <prompt>` with the prompt as ARGV (process-table visible).
399
399
  // The prompt embeds an unscrubbed transcript tail, so without these a tracked secret in the last ~1000
400
400
  // chars lands in `ps`/`/proc/<pid>/cmdline`. Scrub the prompt at each complete() call — symmetric with
@@ -413,7 +413,14 @@ export class LlmDecider {
413
413
  return ABSTAIN;
414
414
  const labels = req.options.map((o) => o.label);
415
415
  const raw = await this.complete(scrub(this.permPrompt(req, ctx), this.secrets), this.model);
416
- const pick = matchLabel(raw, labels);
416
+ // Echo backstop, at parity with the question path's `echoPrefixMatch` tier: the model often
417
+ // parrots the offered option plus a self-glossed tail past a `:` boundary ("Allow once: fetch
418
+ // this URL one time"). Bind the echoed label instead of failing loud. The OTHER:/suffix tiers are
419
+ // inapplicable to a web_fetch grant (a closed set — no free-text, no "(Recommended)" labels), so
420
+ // only the echo tier is added; the bound value is a bare canonical label coerceWebFetchGrant accepts.
421
+ let pick = matchLabel(raw, labels);
422
+ if (!pick)
423
+ pick = echoPrefixMatch(raw, labels);
417
424
  if (!pick)
418
425
  throw new UnansweredError(`LLM decider answer "${raw.trim().slice(0, 60)}" is not one of the options for "${req.tool}"`, `options were: ${labels.join(" | ")}`);
419
426
  const { behavior, grant } = coerceWebFetchGrant(pick);
@@ -520,7 +527,10 @@ export class LlmDecider {
520
527
  pick = echoPrefixMatch(raw, labels);
521
528
  if (!pick) {
522
529
  const near = nearestLabel(raw, labels);
523
- throw new UnansweredError(`LLM decider answer "${raw.trim().slice(0, 60)}" is not one of the options for "${text}"`, `options were: ${labels.join(" | ")}${near ? ` — closest: ${JSON.stringify(near)}` : ""}. Reply the option NUMBER, or \`OTHER: <value>\` for a custom answer.`);
530
+ throw new UnansweredError(`LLM decider answer "${raw.trim().slice(0, 60)}" is not one of the options for "${text}"`, `options were: ${labels.join(" | ")}${near ? ` — closest: ${JSON.stringify(near)}` : ""}. Reply the option NUMBER, or \`OTHER: <value>\` for a custom answer. ` +
531
+ `If the model declined in prose instead of picking an option, the answering model is set by ` +
532
+ `\`--decider-model\` / \`COWORK_HARNESS_DECIDER_MODEL\` (a different/more capable model may bind it; ` +
533
+ `on a gate whose answer isn't in the inputs it may guess rather than truly know).`);
524
534
  }
525
535
  process.stderr.write(`[llm-decider] "${text}" → "${pick}"${this.intent ? ` (intent: ${this.intent})` : ""}\n`);
526
536
  answers[text] = pick;
@@ -30,7 +30,7 @@ function spawnOnce(bin, prompt, model, timeoutMs, maxBytes) {
30
30
  /* already gone */
31
31
  }
32
32
  // NOT a TransportExit → not retried: a child that ate the whole timeout budget is not a quick transient.
33
- reject(new Error(`LLM decider transport (${bin} -p) timed out after ${timeoutMs}ms`));
33
+ reject(new Error(`LLM decider transport (${bin} -p) timed out after ${timeoutMs}ms — raise COWORK_HARNESS_LLM_TIMEOUT_MS to allow a longer gate`));
34
34
  }, timeoutMs);
35
35
  // Bound stdout too — the wall-clock timeout above caps a fully-hung child, but not one that is
36
36
  // actively spewing. Past the cap, SIGKILL and reject loud rather than growing the buffer unbounded.
@@ -52,7 +52,7 @@ function spawnOnce(bin, prompt, model, timeoutMs, maxBytes) {
52
52
  }
53
53
  clearTimeout(timer);
54
54
  // NOT a TransportExit → not retried: a spewing child would just spew again.
55
- reject(new Error(`LLM decider transport (${bin} -p) exceeded ${maxBytes} bytes — aborting`));
55
+ reject(new Error(`LLM decider transport (${bin} -p) exceeded ${maxBytes} bytes — aborting; raise COWORK_HARNESS_LLM_MAX_BYTES if this output is legitimately large`));
56
56
  return;
57
57
  }
58
58
  chunks.push(d);
@@ -65,7 +65,7 @@ function spawnOnce(bin, prompt, model, timeoutMs, maxBytes) {
65
65
  child.on("error", (e) => {
66
66
  clearTimeout(timer);
67
67
  // NOT a TransportExit → not retried: a spawn failure (e.g. ENOENT — `claude` not on PATH) is deterministic.
68
- reject(new Error(`LLM decider transport (${bin} -p) failed to spawn: ${e.message}`));
68
+ reject(new Error(`LLM decider transport (${bin} -p) failed to spawn: ${e.message} — ensure 'claude' is installed and on PATH, or set COWORK_HARNESS_CLAUDE_BIN to its path`));
69
69
  });
70
70
  child.on("close", (code) => {
71
71
  clearTimeout(timer);
@@ -88,7 +88,7 @@ function spawnOnce(bin, prompt, model, timeoutMs, maxBytes) {
88
88
  * over a direct `POST /v1/messages` (Opus H1): the harness PROCESS is not behind the egress proxy
89
89
  * (only the spawned agent child is), so a direct API call would bypass the very allowlist the harness
90
90
  * enforces. `claude -p` reuses the run's own auth path and is dogfood-consistent. One short, tool-less
91
- * call per gate on a small model — bounded cost/latency, no recursion into the harness.
91
+ * call per gate (one call, no recursion into the harness; model is the decider default or --decider-model).
92
92
  *
93
93
  * Non-zero-exit retry: a single `claude -p` spawn can exit non-zero on a TRANSIENT upstream hiccup
94
94
  * (rate-limit/overload/network) during a long back-to-back batch — observed live, not reproducible on demand.
@@ -222,7 +222,7 @@ export function runDoctorChecks(tier, probe = realProbe) {
222
222
  remedy: token
223
223
  ? undefined
224
224
  : keychainOnly
225
- ? "copy your Keychain token into ./.env so the in-Docker agent can read it: echo CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token) >> .env"
225
+ ? "copy your Keychain token into ./.env so the in-Docker agent can read it: echo CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token) >> .env — or, if the token is already in another file, point at it: cowork-harness --dotenv <path> <cmd> (the global --dotenv is honored by doctor too)"
226
226
  : worktreeEnv
227
227
  ? `the main checkout has a .env — point at it: cowork-harness --dotenv ${worktreeEnv} <cmd> (or set CLAUDE_CODE_OAUTH_TOKEN)`
228
228
  : "export CLAUDE_CODE_OAUTH_TOKEN=$(claude setup-token), put it in ./.env, or point at another file: cowork-harness --dotenv <path> <cmd>",
package/dist/run/run.js CHANGED
@@ -220,17 +220,27 @@ export class Run {
220
220
  this.session.close();
221
221
  }
222
222
  this.rec.transcript = transcript.join("\n");
223
- // H2: stall-on-question detection. A turn that ends on a plain-text re-ask ("which file?") gets
223
+ // H2/H3: stall-on-question detection. A turn that ends on a plain-text re-ask ("which file?") gets
224
224
  // is_error:false → result:"success" (a false-green — the SDK turn didn't error, but the task didn't
225
225
  // complete). Flag it (computeVerdict turns it into a `stalled` fail unless allow_stall). Conservative
226
226
  // conjunction to keep the default-fail safe: (1) the run cleanly succeeded, (2) the FINAL top-level
227
- // assistant message is a question, (3) the agent took NO action the whole run (no tool_use — a Write/Bash
228
- // that produced an artifact is in toolLog, so this subsumes "wrote nothing"), and (4) NO structured
229
- // AskUserQuestion gate ever occurred (those are already on_unanswered's job — `rec.questions` is the
230
- // reliable signal, NOT event adjacency). Reads only `rec`/local state, so it re-derives identically on the
231
- // replay re-drive. Scenario-lane only (a single oneShot turn); not exercised by the multi-turn chat driver.
227
+ // assistant message is a question, and (3) NO productive tool ran AFTER the last gate.
228
+ //
229
+ // (3) is keyed on toolLog position, not "no tools at all": an AskUserQuestion gate arrives as a real
230
+ // assistant tool_use block (→ toolLog; see toolCounts in any gated result.json), so a naive
231
+ // `toolLog.length === 0` would miss H3 — the founder repro where the agent answered a gate, then re-asked
232
+ // in plain text and stalled, having done productive work BEFORE the gate. So we slice toolLog AFTER the
233
+ // last AskUserQuestion and count non-gate calls: zero ⇒ the agent waited on input and did nothing further.
234
+ // With no gate, lastIndexOf === -1 → the slice is the whole log → this reduces to H2's exact "no tools at
235
+ // all" behavior. The count includes parented(subagent)/synthetic entries (toolLog pushes unconditionally)
236
+ // — they only RAISE the count, never a new false positive. on_unanswered owns the *unanswered* gate; this
237
+ // owns the agent stalling AFTER an answered one. Reads only local/rec state, so it re-derives identically
238
+ // on the replay re-drive. Scenario-lane only (one oneShot turn); the chat driver runs it but never reads
239
+ // the flag (no computeVerdict), so it is inert there.
232
240
  const lastText = transcript.length > 0 ? transcript[transcript.length - 1].trim() : "";
233
- if (this.rec.result === "success" && lastText.endsWith("?") && this.toolLog.length === 0 && this.rec.questions.length === 0) {
241
+ const lastGateIdx = this.toolLog.map((t) => t.name).lastIndexOf("AskUserQuestion");
242
+ const productiveAfterGate = this.toolLog.slice(lastGateIdx + 1).filter((t) => t.name !== "AskUserQuestion").length;
243
+ if (this.rec.result === "success" && lastText.endsWith("?") && productiveAfterGate === 0) {
234
244
  this.rec.stalledOnQuestion = true;
235
245
  }
236
246
  // Part 3: pair each answered gate with its tool_result (by toolUseId). delivered=true iff a non-error
package/dist/types.js CHANGED
@@ -201,7 +201,7 @@ export const Assertion = z.object({
201
201
  allow_stall: z
202
202
  .boolean()
203
203
  .optional()
204
- .describe("(verdict modifier) suppress the default-fail when a run ends on an unanswered question (the agent asked for input and stopped) — assert this only when ending on a question is the intended terminal state; otherwise script the answer (answer:/--answer/decider)"),
204
+ .describe("(verdict modifier) suppress the default-fail when a run ends on a question having done no productive tool work after its last gate (the agent asked for input and stopped — incl. re-asking in plain text after answering an AskUserQuestion) — assert this only when ending on a question is the intended terminal state; otherwise script the answer (answer:/--answer/decider)"),
205
205
  replay_protocol_fidelity: z
206
206
  .boolean()
207
207
  .optional()
package/docs/cassette.md CHANGED
@@ -38,7 +38,7 @@ Use a live `run` for filesystem/egress assertions; use `replay` for the token-fr
38
38
  { "path": "outputs/x.json", "bytes": 24, "sha256": "…", "body": "{…}" }, // body inlined ≤ 64 KiB
39
39
  { "path": "outputs/big.bin", "bytes": 9e6, "sha256": "…", "truncated": true } // oversized → hash-only
40
40
  ],
41
- "fingerprint": { "baseline": "1.15962.0", "skillHash": "…", "mode": "git", "contentSig": "…", "fileSigs": [["skills/x/SKILL.md", "…"]], "skillSources": ["…"] }, // staleness tripwire (v5: fileSigs only; v6: mode + git default; v7: NUL-delimited hash entries)
41
+ "fingerprint": { "baseline": "1.15962.1", "skillHash": "…", "mode": "git", "contentSig": "…", "fileSigs": [["skills/x/SKILL.md", "…"]], "skillSources": ["…"] }, // staleness tripwire (v5: fileSigs only; v6: mode + git default; v7: NUL-delimited hash entries)
42
42
  "authoring": { "nonDeterministic": true, "channel": "decider-dir" } // present ONLY when a live decider answered ≥1 gate (see §Answering gates during recording); re-record may drift, replay is still deterministic
43
43
  }
44
44
  ```
@@ -1,6 +1,6 @@
1
1
  # Cowork spawn contract — app.asar 1.12603.1 / agent ELF 2.1.170
2
2
 
3
- > **Applicability note:** verified on `desktop-1.12603.1`. The current baseline is `desktop-1.15962.0`
3
+ > **Applicability note:** verified on `desktop-1.12603.1`. The current baseline is `desktop-1.15962.1`
4
4
  > (agent ELF 2.1.187). Control-protocol fields documented here (spawn flags, handshake envelope,
5
5
  > permission/question shapes) are **unchanged**; the **mount layout changed at ≥1.14271.0** (work folders
6
6
  > now mount at `mnt/<name>`, not `mnt/.projects/<id>` — see CHANGELOG). Volatile fields — `agentVersion`,
@@ -6,7 +6,7 @@ stays live. There is no resume, no re-worded question: the harness blocks on eac
6
6
  reads it, writes an answer, and the run continues from exactly where it paused.
7
7
 
8
8
  This is the fourth answer channel, alongside scripted `--answer`/`--answer-policy` (deterministic),
9
- `--decider-llm` (a small model picks), and `--decider-cmd '<helper>'` (a spawned helper). Unlike the
9
+ `--decider-llm` (a model picks — defaults to Sonnet), and `--decider-cmd '<helper>'` (a spawned helper). Unlike the
10
10
  scripted channels, `--decider-dir` answers questions you don't know in advance — so the harness
11
11
  **flags the run non-deterministic** (the footer says so), and a fresh, empty directory is **required**
12
12
  per run. Like every channel, it keeps the CLI's stdout free, so it composes with `--output-format json`.
package/docs/scenario.md CHANGED
@@ -116,12 +116,14 @@ the CLI's `--decider-llm`). It is **non-deterministic** by construction, so a ru
116
116
  `nonDeterministic` in the record — keep it out of deterministic CI regressions; prefer scripted answers +
117
117
  `fail` there. See the determinism note above and the decider flags in the [README](../README.md).
118
118
 
119
- > **For large unattended batches, script the stable gates.** A pure live decider re-asks a small model
119
+ > **For large unattended batches, script the stable gates.** A pure live decider re-asks the model
120
120
  > once per gate; across a back-to-back batch that is more wall-clock, more paid calls, and more exposure to
121
121
  > a transient `claude -p` exit (now bounded-retried, but not free). For unattended multi-doc completion
122
- > prefer scripted `--answer` / `--answer-policy` on the gates you can name (a stronger `--decider-model`
123
- > only for the genuinely ambiguous *judgment* gates), and keep `--decider-llm` for exploration. The partial
124
- > run on a stall already echoes the gate + numbered options — paste them straight into `--answer`.
122
+ > prefer scripted `--answer` / `--answer-policy` on the gates you can name, and keep `--decider-llm` for
123
+ > exploration. **Also script any gate whose answer feeds a *semantic* assertion:** a decided answer can be a
124
+ > confident guess — the decider sees only the transcript tail, not the mounted documents, so it can get a
125
+ > doc-answerable fact wrong (a stronger model included) — and a green run resting on it is a false pass.
126
+ > The partial run on a stall already echoes the gate + numbered options — paste them straight into `--answer`.
125
127
 
126
128
  > **Where scripted answers hold up — and where they don't.** The `when_question` regex absorbs *wording*
127
129
  > drift (an LLM phrases "confirm the stage" many ways), so scripting is robust for skills whose gates are
@@ -264,7 +266,7 @@ if *every* key passes (don't rely on the first; keep one concern per item unless
264
266
  | `allow_permissive_auto_allow: true` | verdict modifier — suppresses the default-fail when the run recorded a cowork-parity permissive auto-allow; use this for tests that **deliberately** assert Cowork's permissive behavior rather than strict scripted coverage |
265
267
  | `allow_missing_capability: true` | verdict modifier (**live tiers only**) — suppresses the default-fail when the lean/`core` agent image omits a capability the skill used but real Cowork ships (OCR/LibreOffice/markitdown/opencv/PDF-tables); assert only when the skill's fallback is genuinely equivalent, else rebuild full parity (`--build-arg COWORK_FULL_PARITY=1`). Also opts out of the `requires_capabilities` declared-need check below. |
266
268
  | `allow_l0_plugin_divergence: true` | verdict modifier — opts into L0/protocol plugin divergence, suppressing the plugin-fidelity default-fail |
267
- | `allow_stall: true` | verdict modifier — suppresses the default-fail when a run ends on an unanswered plain-text question (the agent asked for input and stopped); assert only when ending on a question is the intended terminal state, otherwise script the answer (`answer:` / `--answer` / a decider) |
269
+ | `allow_stall: true` | verdict modifier — suppresses the default-fail when a run ends on a question having done no productive tool work after its last gate (the agent asked for input and stopped — incl. re-asking in plain text *after* answering an `AskUserQuestion`); assert only when ending on a question is the intended terminal state, otherwise script the answer (`answer:` / `--answer` / a decider) |
268
270
  | `transcript_no_host_path: true` | no host path (`/Users/`, `/opt/cowork/`, `/home/`, `/root/`) leaked into model-visible text |
269
271
  | `egress_denied: <host>` | the host was blocked by the egress proxy |
270
272
  | `egress_allowed: <host>` | the host was allowed through |
@@ -449,7 +451,7 @@ in the `--output-format json` envelope.
449
451
  `--verbose`/`-V` shows the transcript for every scenario, `--quiet` shows only the verdict. `--output-format
450
452
  json` emits the machine envelope `{tool, version, command, ok, results[], error}` on stdout (one
451
453
  `RunResult` per scenario; overall pass = `result==="success" && assertions.every(pass)` **AND a clean
452
- `computeVerdict`** — a verdict signal like `stalled` (ended on an unanswered question), `transport_error`, or a
454
+ `computeVerdict`** — a verdict signal like `stalled` (ended on a question with no productive work after its last gate), `transport_error`, or a
453
455
  missing-capability/boundary signal can still fail a run whose `result` is `success` and whose assertions all
454
456
  pass, unless the matching `allow_*` modifier is asserted) — full schema
455
457
  in [SPEC §11](../SPEC.md). Human output is stderr; stdout stays machine-only under `--output-format json`.
@@ -13,7 +13,7 @@ replay path end-to-end, including:
13
13
 
14
14
  Run it with:
15
15
 
16
- > Assumes the `cowork-harness` CLI is available — from a source checkout run `npm ci && npm run build && npm link` first, or `npm i -g cowork-harness@>=0.18.0`. (`replay` itself needs nothing else — no token, no Docker.)
16
+ > Assumes the `cowork-harness` CLI is available — from a source checkout run `npm ci && npm run build && npm link` first, or `npm i -g cowork-harness@>=0.19.0`. (`replay` itself needs nothing else — no token, no Docker.)
17
17
 
18
18
  ```sh
19
19
  cowork-harness replay examples/replays/example-pdf-skill.cassette.json
@@ -92,7 +92,7 @@
92
92
  ],
93
93
  "scenarioSource": "../scenarios/example-pdf-skill.yaml",
94
94
  "fingerprint": {
95
- "baseline": "1.15962.0",
95
+ "baseline": "1.15962.1",
96
96
  "skillHash": "4db1ddd5d109abc71f9b28807d3e31729f9882d9b85d6d0a9dd7a1980bed3110",
97
97
  "contentSig": "e8022822156176fa10e04f0e31d1269719fd09890488db3dfd6351ea8c34431c",
98
98
  "skillSources": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-harness",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Scriptable, CI-friendly harness for Claude Cowork's runtime contract for testing skills across scenarios — same agent, mounts, egress allowlist, permission protocol, and sandbox limitations.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "stalledOnQuestion": {
22
22
  "type": "boolean",
23
- "description": "H2: the run ended on an unanswered plain-text question (the agent asked for input and stopped) while result==='success'. computeVerdict fails on this (a 'stalled' signal) unless the scenario asserts allow_stall."
23
+ "description": "H2/H3: the run ended on a question having done no productive tool work after its last gate (the agent asked for input and stopped — incl. re-asking in plain text after answering an AskUserQuestion) while result==='success'. computeVerdict fails on this (a 'stalled' signal) unless the scenario asserts allow_stall."
24
24
  },
25
25
  "capabilityProbe": {
26
26
  "type": "string",
@@ -230,7 +230,7 @@
230
230
  "type": "boolean"
231
231
  },
232
232
  "allow_stall": {
233
- "description": "(verdict modifier) suppress the default-fail when a run ends on an unanswered question (the agent asked for input and stopped) — assert this only when ending on a question is the intended terminal state; otherwise script the answer (answer:/--answer/decider)",
233
+ "description": "(verdict modifier) suppress the default-fail when a run ends on a question having done no productive tool work after its last gate (the agent asked for input and stopped — incl. re-asking in plain text after answering an AskUserQuestion) — assert this only when ending on a question is the intended terminal state; otherwise script the answer (answer:/--answer/decider)",
234
234
  "type": "boolean"
235
235
  },
236
236
  "replay_protocol_fidelity": {