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 +54 -0
- package/README.md +3 -3
- package/baselines/desktop-1.15962.1.json +220 -0
- package/dist/cli.js +6 -6
- package/dist/decide/decider.js +14 -4
- package/dist/decide/llm-transport.js +4 -4
- package/dist/run/doctor.js +1 -1
- package/dist/run/run.js +17 -7
- package/dist/types.js +1 -1
- package/docs/cassette.md +1 -1
- package/docs/cowork-spawn-contract-1.12603.1.md +1 -1
- package/docs/decider-dir.md +1 -1
- package/docs/scenario.md +8 -6
- package/examples/replays/README.md +1 -1
- package/examples/replays/example-pdf-skill.cassette.json +1 -1
- package/package.json +1 -1
- package/schema/run-result.json +1 -1
- package/schema/scenario.schema.json +1 -1
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.
|
|
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
|
|
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
|
|
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
|
|
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 >
|
|
181
|
-
|
|
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 >
|
|
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 >
|
|
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 = [];
|
package/dist/decide/decider.js
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
-
|
|
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
|
|
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.
|
package/dist/run/doctor.js
CHANGED
|
@@ -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)
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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`,
|
package/docs/decider-dir.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
123
|
-
>
|
|
124
|
-
>
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/schema/run-result.json
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"stalledOnQuestion": {
|
|
22
22
|
"type": "boolean",
|
|
23
|
-
"description": "H2: the run ended on
|
|
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
|
|
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": {
|