cowork-harness 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/cowork-harness/scripts/assertion-keys.json +14 -1
- package/.claude/skills/cowork-harness/scripts/scenario.py +30 -2
- package/CHANGELOG.md +81 -0
- package/README.md +5 -4
- package/dist/cli.js +52 -15
- package/dist/decide/decider.js +20 -4
- package/dist/io.js +6 -5
- package/dist/run/cassette.js +55 -27
- package/dist/run/doctor.js +5 -1
- package/dist/run/execute.js +116 -5
- package/dist/run/inspect-view.js +95 -0
- package/dist/run/run.js +8 -0
- package/dist/run/runs-gc.js +8 -8
- package/dist/run/scaffold.js +17 -5
- package/dist/run/trace-view.js +1 -1
- package/dist/runtime/image-capabilities.js +23 -0
- package/docs/cassette.md +3 -2
- package/docs/scenario.md +12 -2
- package/package.json +1 -1
- package/schema/run-result.json +2 -0
- package/scripts/gen-schema.ts +10 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "GENERATED from the Zod
|
|
2
|
+
"$comment": "GENERATED from the Zod schemas (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
|
|
3
3
|
"keys": [
|
|
4
4
|
"allow_l0_plugin_divergence",
|
|
5
5
|
"allow_missing_capability",
|
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
"transcript_not_matches",
|
|
33
33
|
"user_visible_artifact"
|
|
34
34
|
],
|
|
35
|
+
"topLevelKeys": [
|
|
36
|
+
"answers",
|
|
37
|
+
"assert",
|
|
38
|
+
"baseline",
|
|
39
|
+
"expect_denied",
|
|
40
|
+
"fidelity",
|
|
41
|
+
"name",
|
|
42
|
+
"on_unanswered",
|
|
43
|
+
"prompt",
|
|
44
|
+
"requires_capabilities",
|
|
45
|
+
"session",
|
|
46
|
+
"skills"
|
|
47
|
+
],
|
|
35
48
|
"verdictModifierKeys": [
|
|
36
49
|
"allow_l0_plugin_divergence",
|
|
37
50
|
"allow_missing_capability",
|
|
@@ -116,8 +116,11 @@ if UNCLASSIFIED_KEYS:
|
|
|
116
116
|
"— add them to the linter's CONTENT/GATE/MANIFEST/LIVE_ONLY/VERDICT_MODIFIER sets.",
|
|
117
117
|
file=sys.stderr,
|
|
118
118
|
)
|
|
119
|
-
#
|
|
120
|
-
|
|
119
|
+
# Embedded fallback for the top-level scenario keys — kept EQUAL to the generated `topLevelKeys`
|
|
120
|
+
# (test-enforced, like _CLASSIFIED_KEYS for assert keys) so a missing assertion-keys.json can't silently
|
|
121
|
+
# reintroduce key drift. `profile`/`assertions` are NOT here — they are deprecated aliases handled by the
|
|
122
|
+
# special-cases in the unknown-key check below (`k != "assertions" and k != "profile"`).
|
|
123
|
+
_EMBEDDED_TOP_LEVEL_KEYS = {
|
|
121
124
|
"name",
|
|
122
125
|
"baseline",
|
|
123
126
|
"session",
|
|
@@ -128,7 +131,32 @@ TOP_LEVEL_KEYS = {
|
|
|
128
131
|
"expect_denied",
|
|
129
132
|
"assert",
|
|
130
133
|
"skills", # F-6: opt-in skill-staleness hash scope
|
|
134
|
+
"requires_capabilities", # Fix 4b: scenario-level required-capability declaration (pre-flight gate)
|
|
131
135
|
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_top_level_keys():
|
|
139
|
+
"""The authoritative top-level scenario-key set, generated from the Zod ScenarioObject schema into
|
|
140
|
+
`assertion-keys.json` (so the unknown-key check can't drift and false-flag a valid key). Falls back to
|
|
141
|
+
the embedded `_EMBEDDED_TOP_LEVEL_KEYS` (kept equal to the generated list) with a loud warning if the
|
|
142
|
+
file is missing or predates the `topLevelKeys` field."""
|
|
143
|
+
p = Path(__file__).resolve().parent / "assertion-keys.json"
|
|
144
|
+
try:
|
|
145
|
+
keys = json.loads(p.read_text(encoding="utf-8")).get("topLevelKeys")
|
|
146
|
+
if not keys:
|
|
147
|
+
raise KeyError("topLevelKeys")
|
|
148
|
+
return set(keys)
|
|
149
|
+
except Exception:
|
|
150
|
+
print(
|
|
151
|
+
f"::warning:: assertion-keys.json missing or has no topLevelKeys next to scenario.py ({p}) — "
|
|
152
|
+
"using a built-in top-level-key list that may be stale (run `npm run schema`).",
|
|
153
|
+
file=sys.stderr,
|
|
154
|
+
)
|
|
155
|
+
return set(_EMBEDDED_TOP_LEVEL_KEYS)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# every valid top-level scenario key (generated from the zod ScenarioObject schema; see _load_top_level_keys)
|
|
159
|
+
TOP_LEVEL_KEYS = _load_top_level_keys()
|
|
132
160
|
REGEX_KEYS = {
|
|
133
161
|
"transcript_matches",
|
|
134
162
|
"transcript_not_matches",
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,87 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The project uses
|
|
5
5
|
[Semantic Versioning](https://semver.org/); pre-1.0 minor versions may include breaking changes.
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.14.0] — 2026-06-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`replay` surfaces staleness + skipped assertions in JSON.** Each `--output-format json` result now carries
|
|
14
|
+
`staleness[]` (class-tagged: `baseline` / `skill` / `shared-root` / `format` / `unverifiable-baseline` /
|
|
15
|
+
`unverifiable-skill`) and `skippedAssertions` (`{full, partial}`), so a token-free CI gate can see a stale
|
|
16
|
+
cassette or live-only assertions it didn't evaluate WITHOUT the verdict changing — a stale but otherwise
|
|
17
|
+
passing replay stays `ok:true` by default. Previously these were stderr-only `::warning::` lines invisible
|
|
18
|
+
to a JSON consumer.
|
|
19
|
+
- **`replay --fail-on-skill-drift`.** A narrower release gate than `--strict`: fails only on skill-source drift
|
|
20
|
+
(`skill` / `shared-root` / `unverifiable-skill` — "can't verify the skill ⇒ not green"), while baseline /
|
|
21
|
+
format / environment-level staleness stays a non-failing warning. `--strict` remains the superset (fails on
|
|
22
|
+
every class).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **Linter no longer false-flags `requires_capabilities`.** `cowork-harness lint` warned `unknown-top-key` on
|
|
27
|
+
the valid scenario field `requires_capabilities` (its hand-maintained top-level-key list had drifted from the
|
|
28
|
+
schema). The list is now generated from the Zod `ScenarioObject` schema (like the assertion-key list), so it
|
|
29
|
+
can't drift; a parity test guards it.
|
|
30
|
+
- **SKILL.md capability pre-flight wording corrected.** It described `requires_capabilities` as skill-declared
|
|
31
|
+
and said the harness "warns before the run"; it is a **scenario field** and the harness **aborts before the
|
|
32
|
+
paid run (exit 3)** when the image omits a required capability (unless `allow_missing_capability: true`). The
|
|
33
|
+
authoritative `docs/scenario.md` was already correct.
|
|
34
|
+
|
|
35
|
+
## [0.13.0] — 2026-06-25
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`inspect <run-id | run-dir>` — see what a run produced.** Lists the run's artifacts and prints a shallow
|
|
40
|
+
field preview of each JSON artifact (scalars inline, arrays as a count, nested objects collapsed), so
|
|
41
|
+
"did it do the job?" is a first-class check instead of hand-parsing `…/mnt/outputs/...`. `--output-format
|
|
42
|
+
json` emits a structured digest. When the work dir was torn down (a non-`--keep` container/microvm run),
|
|
43
|
+
the artifact manifest still prints from `result.json` and the preview notes it's unavailable.
|
|
44
|
+
- **A whiffed gate no longer discards the paid run.** When a run exits on an unanswered gate
|
|
45
|
+
(`on_unanswered: fail`), the harness now salvages a **PARTIAL** `result.json` (+ `run.jsonl`/`trace.json`)
|
|
46
|
+
with the artifacts the agent wrote before the whiff, then still exits 2. The run dir is printed so you can
|
|
47
|
+
`inspect` it. `partial` and `unansweredGate` are new `RunResult` fields; `verify-run` and `scaffold`
|
|
48
|
+
refuse to treat a partial run's half-finished output as a passing result (scaffold still emits the gates,
|
|
49
|
+
drops the artifact/result asserts, and warns loudly).
|
|
50
|
+
- **Capability pre-flight — fail fast.** A skill that declares `requires_capabilities` against an image that
|
|
51
|
+
provably omits them now **aborts before the (paid) run** (`exit 3`) instead of running ~12 min to a verdict
|
|
52
|
+
that's already known — unless the scenario asserts `allow_missing_capability: true`, which downgrades it to
|
|
53
|
+
a notice and proceeds. `doctor` also surfaces the full-parity remedy on its agent-image line.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **LLM decider tolerates a near-miss label.** `--decider-llm` now binds a reply like `Confirmed.` or
|
|
58
|
+
`"Confirmed"` to the label `Confirmed` (trailing sentence punctuation / surrounding quotes trimmed before
|
|
59
|
+
matching) instead of failing loud — common on binary confirm gates. The `:` of the `OTHER:` free-text
|
|
60
|
+
sentinel is never stripped, and fuzzy substring matching stays off, so the change can't mis-bind. The
|
|
61
|
+
tolerance lives in `matchLabel` itself, so the web_fetch-approval path gets it too (a `Deny.` reply now
|
|
62
|
+
binds `Deny`).
|
|
63
|
+
- **`runs gc` → top-level `prune`; the `runs` namespace is dropped.** Pruning accumulated run dirs is now
|
|
64
|
+
`cowork-harness prune [--keep-last <n>] [--dry-run] [<runs-dir>]` (same flags). `runs` was a namespace with
|
|
65
|
+
a single member and collided confusingly with `run` (execute); removing it leaves `run` = execute,
|
|
66
|
+
inspection verbs (`trace`/`inspect`/`verify-run`/`scaffold`) top-level, and `prune` for cleanup.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- **Session-protocol over-cap test no longer flakes.** The over-cap control-out test read
|
|
71
|
+
`control-out.jsonl` synchronously, but the stream opens it asynchronously (`createWriteStream`, flags
|
|
72
|
+
`"a"`), so under load the file may not exist yet at the read — a flaky ENOENT. The over-cap frame is
|
|
73
|
+
rejected before any write, so a missing file means nothing was written; the test now treats absence as
|
|
74
|
+
empty.
|
|
75
|
+
|
|
76
|
+
### Docs
|
|
77
|
+
|
|
78
|
+
- **Documented the LLM-decider free-text path.** `--decider-llm` supplies free text via `OTHER: <value>`
|
|
79
|
+
on an options-bearing gate; a bare out-of-set answer (no matching label, no `OTHER:`) fails loud
|
|
80
|
+
(`UnansweredError` → exit 2), never stalling or guessing, and open-ended (no-option) gates need no
|
|
81
|
+
prefix. Noted in the decider section of `fidelity-and-answers.md` and cross-referenced from the lone
|
|
82
|
+
`OTHER:` mention in `SKILL.md` so the two don't drift.
|
|
83
|
+
- **Documented the live real-document validation workflow.** A new SKILL.md section covers driving a skill
|
|
84
|
+
against real input documents (not recording a cassette): explore with `--decider-llm --intent`, script the
|
|
85
|
+
load-bearing and binary-confirm gates, budget ~1 re-run per file (a whiffed gate now salvages a partial
|
|
86
|
+
run), and `inspect` the outputs to judge correctness.
|
|
87
|
+
|
|
7
88
|
## [0.12.0] — 2026-06-24
|
|
8
89
|
|
|
9
90
|
### Added
|
package/README.md
CHANGED
|
@@ -93,10 +93,11 @@ Skill testing is the headline use, but the tool is a general harness over the Co
|
|
|
93
93
|
| `skill <folder> "<prompt>"` | Run a local skill/plugin folder once against the staged agent | ad-hoc "is the skill alive / does it do X?" — the fast inner loop |
|
|
94
94
|
| `run <scenario.yaml \| dir/>` | Run authored scenarios with `assert:` + a CI-ready exit code | you want a repeatable, **asserted regression test** |
|
|
95
95
|
| `chat <folder> [prompt]` | Interactive multi-turn REPL against a skill (TTY); optional seed prompt is sent as the first turn. `--upload <file>` / `--folder <dir>` (repeatable) attach files/project folders; `--verbose` / `-V` shows thinking blocks + tool inputs; `--fidelity protocol\|container\|hostloop` | debugging a multi-turn flow by hand |
|
|
96
|
-
| `record` / `replay` | `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--concurrency <N>` records a batch N at a time — runs are fully isolated, so it just bounds Docker/API pressure; `--max-artifact-bytes` raises the 64 KiB inline-body cap; `--dry-run` previews the scenarios + token/binary checks without recording; answer gates **live during the recording** with `--decider-dir`/`--decider-llm`/`--on-unanswered first` instead of pre-scripting them — a live decider flags the cassette non-deterministic but it still replays deterministically); `replay <file.cassette.json \| dir/>` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`--strict` fails on
|
|
96
|
+
| `record` / `replay` | `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--concurrency <N>` records a batch N at a time — runs are fully isolated, so it just bounds Docker/API pressure; `--max-artifact-bytes` raises the 64 KiB inline-body cap; `--dry-run` previews the scenarios + token/binary checks without recording; answer gates **live during the recording** with `--decider-dir`/`--decider-llm`/`--on-unanswered first` instead of pre-scripting them — a live decider flags the cassette non-deterministic but it still replays deterministically); `replay <file.cassette.json \| dir/>` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`--strict` fails on any stale one, `--fail-on-skill-drift` only on skill-source drift; staleness is also reported per-result in `staleness[]` for a JSON gate; exits on the worst verdict) | **token-free, Docker-free CI** from a once-recorded run |
|
|
97
97
|
| `verify-cassettes <file\|dir>` | Token-free CI gate over committed cassettes: a privacy scan (email/currency/domain → exit 1; whole-token allows via `--allow` / class-scoped `--allow-domain` / `--allow-email` / `--allow-file`) + a staleness check (`--skip-privacy` or `--skip-staleness` to run only one half). A dir argument scans `*.cassette.json` in that dir only (**non-recursive**) | gating **committed cassettes** against PII leaks + "edited the skill, forgot to re-record" |
|
|
98
98
|
| `verify-run <run-dir> <scenario.yaml>` | Re-evaluate a scenario's `assert:` (and, when the scenario declares `answers:`, whether they still match the run's actual gates) against an already-kept run dir — **no live agent, no tokens, no Docker** (~1s) | iterating on a wrong assertion or a drifted `answer` without a full live re-record |
|
|
99
99
|
| `trace <run-id>` | Digest a run's `events.jsonl` (`--view tools\|questions\|dispatches`; legacy `--tools` / `--gates` / `--dispatches` aliases still accepted) | "how many sub-agents *actually* dispatched, and which?" |
|
|
100
|
+
| `inspect <run-id>` | Show what a run **produced**: the artifacts + a shallow field preview of each JSON artifact (`--output-format json` for a digest). Works on a salvaged partial run too | "did it do the job?" — without hand-parsing `…/mnt/outputs/…` |
|
|
100
101
|
| `scaffold <run-id>` | Turn a kept run into a starter scenario YAML (gates→answers, artifacts→`file_exists`); `--from-run <id>` is a deprecated alias for the positional | authoring a scenario from a real run instead of guessing |
|
|
101
102
|
| `python3 …/scenario.py scaffold --name <name> --skill <dir>` | Generate a starter scenario skeleton from scratch (the `…` is `.claude/skills/cowork-harness/scripts/`) | starting a new scenario when you have no prior run |
|
|
102
103
|
| `lint <scenario.yaml \| dir/>…` | Check scenarios for silent false-greens — assertions placed on the wrong CI lane, mixed content/live keys, missing `controlOut`-required keys (files or a directory of `*.yaml`/`*.yml`; bundled `scenario.py`; needs python3 — PyYAML is bundled) | before committing a new scenario or after changing assertions |
|
|
@@ -106,7 +107,7 @@ Skill testing is the headline use, but the tool is a general harness over the Co
|
|
|
106
107
|
| `boundary-check [baseline] [--session <file>]` | Prove the sandbox enforces Cowork's limitations; `--session` folds a session's `egress.extra_allow` into the probe allowlist | verifying the harness's own fidelity |
|
|
107
108
|
| `sync` / `list` | Derive/refresh & list platform baselines from the Desktop install | after Claude Desktop updates (baselines ship, so it's optional otherwise) |
|
|
108
109
|
| `doctor [--tier <t>]` | Read-only prerequisite check, per tier (Docker + agent image for `container`/`hostloop`; **Lima** for `microvm`; plus staged agent, token, baseline); prints the exact `docker build` line if the agent image is missing | "can I run the live tiers — what's missing?" before a first live run |
|
|
109
|
-
| `
|
|
110
|
+
| `prune [--keep-last <n>]` | Prune accumulated run dirs (keeps the N most recent per scenario; pinned `--session-id` runs are never pruned); `--dry-run` | the machine-global runs root has grown and you want space back |
|
|
110
111
|
| `rehash <dir/>` | Migrate cassette fingerprints to the current format version when the content is provably unchanged (`--dry-run`); no re-record needed | a cassette-format bump flagged committed fixtures as stale |
|
|
111
112
|
| `vm <init\|status\|delete\|prune>` | Manage the L2 Apple-VZ / Lima microVM (`prune` removes orphaned VMs left by config/agent-version changes) | running `--fidelity microvm` |
|
|
112
113
|
|
|
@@ -207,7 +208,7 @@ This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that te
|
|
|
207
208
|
/plugin install cowork-harness@cowork-harness
|
|
208
209
|
```
|
|
209
210
|
|
|
210
|
-
The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.
|
|
211
|
+
The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.14.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.
|
|
211
212
|
|
|
212
213
|
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.)
|
|
213
214
|
|
|
@@ -473,7 +474,7 @@ Unit tests cover the scripted-answer logic, the egress allowlist matcher, the se
|
|
|
473
474
|
- `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.
|
|
474
475
|
- `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).
|
|
475
476
|
- `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_DIALOG_TIMEOUT_MS` — override the 6 s dialog auto-cancel.
|
|
476
|
-
- `COWORK_HARNESS_RUNS_DIR` (or the `--run-dir <path>` flag) — 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 `
|
|
477
|
+
- `COWORK_HARNESS_RUNS_DIR` (or the `--run-dir <path>` flag) — 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.
|
|
477
478
|
- **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`).
|
|
478
479
|
- **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).
|
|
479
480
|
- **Staleness boundary:** the cassette-staleness skill hash uses the **git-tracked** file set of each skill/plugin source dir by default (a non-repo dir falls back to a raw walk; OS-junk like `.DS_Store` is always excluded). `COWORK_HARNESS_GITSET=0` opts out to the legacy raw walk for every dir. `COWORK_HARNESS_DEBUG_SKILLHASH=1` dumps the exact file set feeding the hash on a staleness mismatch (and flags OS-junk) so a drift source is one line. Declare per-plugin non-runtime paths in a `.cowork-hashignore` file (or the session `staleness.hash_ignore`). `COWORK_HARNESS_AGENT_SCOPE=skill` (opt-in) refines a scenario's `skills:` scope so a **skill-named** `agents/<name>.md` re-stales only that skill's cassettes instead of the whole fleet (generic agents stay shared; stamped into the fingerprint, so flipping it is a one-time re-record like `GITSET`).
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, writeSync, existsSync } from "node:fs";
|
|
3
|
-
import { join, basename, resolve, isAbsolute } from "node:path";
|
|
3
|
+
import { join, basename, resolve, isAbsolute, dirname } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { parse as parseYaml } from "yaml";
|
|
6
6
|
import { Scenario, AnswerRule, Assertion } from "./types.js";
|
|
@@ -24,6 +24,7 @@ import { loadDotenv } from "./dotenv.js";
|
|
|
24
24
|
import { makeRenderer, renderStart, renderFooter, startHeartbeat } from "./run/renderer.js";
|
|
25
25
|
import { resolveEventsFile, buildTrace, formatTrace, buildGateTrace, formatGateTrace, buildDispatchTree, formatDispatchTree, noteRunsLocation, } from "./run/trace-view.js";
|
|
26
26
|
import { buildScaffold } from "./run/scaffold.js";
|
|
27
|
+
import { buildInspectView } from "./run/inspect-view.js";
|
|
27
28
|
import { pkgVersion, jsonEnvelope, jsonError, parseOutputFormat } from "./run/envelope.js";
|
|
28
29
|
import { computeVerdict } from "./run/verdict.js";
|
|
29
30
|
import { evaluate, hostMatches } from "./assert.js";
|
|
@@ -71,13 +72,14 @@ const HELP = `cowork-harness <command> (v${"$VERSION"})
|
|
|
71
72
|
[--decider-llm [--intent "…"]] | [--on-unanswered fail|first] answer live via a model / auto-pick
|
|
72
73
|
(a live decider flags the cassette non-deterministic; it still replays deterministically)
|
|
73
74
|
replay <file|dir> deterministic protocol-replay of a cassette or a dir of them (no token, no Docker)
|
|
74
|
-
[--strict] fail (exit 1) on
|
|
75
|
+
[--strict] fail (exit 1) on ANY stale cassette instead of warning
|
|
76
|
+
[--fail-on-skill-drift] fail only on skill-source drift (skill/shared-root); baseline drift stays a warning
|
|
75
77
|
[--output-format json]
|
|
76
78
|
verify-cassettes <file|dir> CI gate (no token): privacy scan + staleness — exit 1 on finding or drift
|
|
77
79
|
[--skip-privacy|--skip-staleness] skip one check
|
|
78
80
|
[--allow <regex>]... [--allow-domain/-email <regex>]... [--allow-file <path>]... [--output-format json]
|
|
79
81
|
rehash <dir/> migrate cassette fingerprints to current version when content is provably unchanged (requires contentSig from v3+)
|
|
80
|
-
|
|
82
|
+
prune [--keep-last <n>] prune accumulated run dirs, keeping N most recent per scenario (default: 5)
|
|
81
83
|
|
|
82
84
|
── CI lint + assertion reference ──────────────────────────────────────────────
|
|
83
85
|
lint <scenario.yaml | dir/>… check scenarios for silent false-greens (bundled scenario.py; needs python3 — PyYAML is bundled)
|
|
@@ -92,6 +94,8 @@ const HELP = `cowork-harness <command> (v${"$VERSION"})
|
|
|
92
94
|
[--output-format json] structured rows
|
|
93
95
|
verify-run <run-dir> <scenario.yaml> re-evaluate assert: against a kept run dir (no live agent, ~1s)
|
|
94
96
|
[--output-format json]
|
|
97
|
+
inspect <run-id | run-dir> show what a run produced: artifacts + a shallow field preview of each JSON artifact
|
|
98
|
+
[--output-format json] structured digest
|
|
95
99
|
scaffold <run-id | run-dir> turn a kept run into a starter scenario YAML (gates→answers, artifacts→file_exists)
|
|
96
100
|
[--out <file.yaml>] write to a file (default: stdout)
|
|
97
101
|
|
|
@@ -259,18 +263,19 @@ const SUBCOMMAND_USAGE = {
|
|
|
259
263
|
' answer gates LIVE: [--decider-dir <dir>] (single scenario only) | [--decider-llm [--intent "<one line>"]] | [--on-unanswered fail|first]\n' +
|
|
260
264
|
" (a live decider flags the cassette non-deterministic — re-recording may drift; replay stays deterministic. --rerecord-stale rejects these flags.)\n" +
|
|
261
265
|
" NOTE: --allow-failing only relaxes the post-run VERDICT gate; it does NOT salvage an unanswered gate (that throws before any cassette is written — use --on-unanswered first / a decider).",
|
|
262
|
-
replay: "usage: replay <file.cassette.json | dir/> [--strict] [--output-format text|json]",
|
|
266
|
+
replay: "usage: replay <file.cassette.json | dir/> [--strict] [--fail-on-skill-drift] [--output-format text|json]",
|
|
263
267
|
"verify-cassettes": "usage: verify-cassettes <file|dir> [--skip-privacy|--skip-staleness] [--allow <regex>]... [--allow-domain <regex>]... [--allow-email <regex>]... [--allow-file <path>]... [--output-format json]",
|
|
264
|
-
trace: "usage: trace <run-id | run-dir | events.jsonl> [--view tools|questions|dispatches] [--output-format json]\n --view tools tool call / result rows\n --view questions gate lifecycle (question → answer → delivered)\n --view dispatches sub-agent dispatch tree + dispatch_count_max\n (default: all views)",
|
|
268
|
+
trace: "usage: trace <run-id | run-dir | events.jsonl> [--view tools|questions|dispatches] [--output-format json]\n --view tools tool call / result rows\n --view questions gate lifecycle (question → answer → delivered)\n --view dispatches sub-agent dispatch tree + dispatch_count_max\n (default: all views)\n (for what the run PRODUCED — artifacts — use `inspect`)",
|
|
265
269
|
assertions: "usage: assertions --list [--output-format json]",
|
|
266
270
|
scaffold: "usage: scaffold <run-id | run-dir> [--out <file.yaml>]\n Turns a kept run into a starter scenario YAML (gates→answers, artifacts→file_exists).\n Positional <run-id | run-dir> is the canonical form.",
|
|
267
271
|
decide: 'usage: decide [--question <q>] [--option <o>]... [--decider-cmd <cmd> | --decider-llm] [--answer "<q>=<label>"]... [--answer-policy <p>] [--intent <s>] [--output-format json]',
|
|
268
272
|
gates: "usage: gates <dir> [--follow] (stream pending in-band gates as JSON lines; pair with --decider-dir)",
|
|
269
273
|
answer: 'usage: answer <dir> --gate <N> (--choose <label> [--choose <label>…] | --answer "<q>=<label>") (write an in-band gate reply atomically; repeat --choose for a multiSelect gate)',
|
|
270
274
|
"verify-run": "usage: verify-run <run-dir> <scenario.yaml> [--output-format json] (re-evaluate a scenario's assert: against a kept run dir; no live agent)",
|
|
275
|
+
inspect: "usage: inspect <run-id | run-dir> [--output-format json] (show what a run PRODUCED: artifacts + a shallow preview of each JSON artifact's fields; for what HAPPENED — tools/decisions — use `trace`)",
|
|
271
276
|
doctor: "usage: doctor [--tier protocol|container|microvm|hostloop|cowork] [--output-format json] (read-only prerequisite check)",
|
|
272
277
|
rehash: "usage: rehash <dir/> [--dry-run] [--output-format text|json] (migrate cassettes across format bumps using contentSig verification; no re-record needed)",
|
|
273
|
-
|
|
278
|
+
prune: "usage: prune [--keep-last <n>] [--dry-run] [<runs-dir>] (prune accumulated run dirs; default --keep-last 5)",
|
|
274
279
|
};
|
|
275
280
|
// Known subcommands — used by the global value-flag parsers (`--dotenv`, `--run-dir`) to reject a command
|
|
276
281
|
// name mistaken as the flag's value (`--dotenv run x.yaml` would otherwise swallow `run`).
|
|
@@ -283,6 +288,7 @@ const COMMANDS = [
|
|
|
283
288
|
"verify-cassettes",
|
|
284
289
|
"verify-run",
|
|
285
290
|
"trace",
|
|
291
|
+
"inspect",
|
|
286
292
|
"assertions",
|
|
287
293
|
"scaffold",
|
|
288
294
|
"decide",
|
|
@@ -295,7 +301,7 @@ const COMMANDS = [
|
|
|
295
301
|
"lint",
|
|
296
302
|
"doctor",
|
|
297
303
|
"rehash",
|
|
298
|
-
"
|
|
304
|
+
"prune",
|
|
299
305
|
];
|
|
300
306
|
async function main() {
|
|
301
307
|
const argv = process.argv.slice(2);
|
|
@@ -414,6 +420,8 @@ async function main() {
|
|
|
414
420
|
return cmdVerifyRun(rest);
|
|
415
421
|
case "trace":
|
|
416
422
|
return cmdTrace(rest);
|
|
423
|
+
case "inspect":
|
|
424
|
+
return cmdInspect(rest);
|
|
417
425
|
case "assertions":
|
|
418
426
|
return cmdAssert(rest);
|
|
419
427
|
case "scaffold":
|
|
@@ -424,13 +432,8 @@ async function main() {
|
|
|
424
432
|
return cmdGates(rest);
|
|
425
433
|
case "answer":
|
|
426
434
|
return cmdAnswer(rest);
|
|
427
|
-
case "
|
|
428
|
-
|
|
429
|
-
if (sub === "gc")
|
|
430
|
-
return cmdRunsGc(rest.slice(1));
|
|
431
|
-
log(`runs: unknown subcommand "${sub ?? ""}" — available: gc`);
|
|
432
|
-
return process.exit(2);
|
|
433
|
-
}
|
|
435
|
+
case "prune":
|
|
436
|
+
return cmdRunsGc(rest); // top-level: no `gc` token to strip, so pass `rest` whole (NOT rest.slice(1))
|
|
434
437
|
default:
|
|
435
438
|
log(`unknown command: ${cmd}\n`);
|
|
436
439
|
printHelp();
|
|
@@ -656,7 +659,7 @@ function resolveExternal(command, flags) {
|
|
|
656
659
|
}
|
|
657
660
|
return flags.deciderCmd != null ? spawnChannel(flags.deciderCmd) : undefined;
|
|
658
661
|
}
|
|
659
|
-
/** The single error exit used by commands + the top-level catch.
|
|
662
|
+
/** The single error exit used by commands + the top-level catch. boundary → exit 3, every other category → exit 2. */
|
|
660
663
|
function fail(command, category, message, hint, json) {
|
|
661
664
|
if (json)
|
|
662
665
|
out(jsonError(command, category, message, hint));
|
|
@@ -1828,6 +1831,14 @@ async function cmdVerifyRun(args) {
|
|
|
1828
1831
|
log(`verify-run: cannot read ${resultPath}: ${e.message}`);
|
|
1829
1832
|
return process.exit(2);
|
|
1830
1833
|
}
|
|
1834
|
+
// A partial run did NOT complete (it exited on an unanswered gate). Its assertion outcome is empty and its
|
|
1835
|
+
// artifacts are pre-failure, so re-evaluating asserts against it would vouch for a run that never finished.
|
|
1836
|
+
// Refuse rather than false-fail or false-pass.
|
|
1837
|
+
if (result.partial) {
|
|
1838
|
+
log(`verify-run: ${runDir} is a PARTIAL run — it did not complete (exited on an unanswered gate). ` +
|
|
1839
|
+
`Re-run to completion before verifying. (can't verify ⇒ not green)`);
|
|
1840
|
+
return process.exit(2);
|
|
1841
|
+
}
|
|
1831
1842
|
let scenario;
|
|
1832
1843
|
try {
|
|
1833
1844
|
scenario = parseScenarioFile(scenarioFile);
|
|
@@ -2086,6 +2097,32 @@ function cmdTrace(args) {
|
|
|
2086
2097
|
else
|
|
2087
2098
|
out(formatTrace(rows));
|
|
2088
2099
|
}
|
|
2100
|
+
function cmdInspect(args) {
|
|
2101
|
+
if (hasHelp(args))
|
|
2102
|
+
return void log(SUBCOMMAND_USAGE.inspect);
|
|
2103
|
+
ensureOutputFormat("inspect", args);
|
|
2104
|
+
const json = isJsonOutput(args);
|
|
2105
|
+
rejectUnknownFlags("inspect", args, ["--output-format", "--output-format=json", "--output-format=text"], json);
|
|
2106
|
+
const allPositionals = positionals(args, ["--output-format"]);
|
|
2107
|
+
if (allPositionals.length !== 1)
|
|
2108
|
+
return void fail("inspect", "usage", SUBCOMMAND_USAGE.inspect, undefined, json);
|
|
2109
|
+
// Resolve a run-id or run-dir to its dir. A run dir already holding result.json is used directly; otherwise
|
|
2110
|
+
// reuse trace's resolver (run-id → events.jsonl) and take the parent.
|
|
2111
|
+
const target = allPositionals[0];
|
|
2112
|
+
let runDir;
|
|
2113
|
+
try {
|
|
2114
|
+
runDir = existsSync(join(target, "result.json")) ? target : dirname(resolveEventsFile(target));
|
|
2115
|
+
}
|
|
2116
|
+
catch (e) {
|
|
2117
|
+
return void fail("inspect", "usage", String(e.message), undefined, json);
|
|
2118
|
+
}
|
|
2119
|
+
try {
|
|
2120
|
+
out(buildInspectView(runDir, { json }));
|
|
2121
|
+
}
|
|
2122
|
+
catch (e) {
|
|
2123
|
+
return void fail("inspect", "usage", String(e.message), undefined, json);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2089
2126
|
function diff(a, b, path) {
|
|
2090
2127
|
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
|
|
2091
2128
|
for (const k of keys) {
|
package/dist/decide/decider.js
CHANGED
|
@@ -256,8 +256,9 @@ export class FirstOptionDecider {
|
|
|
256
256
|
return ABSTAIN;
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
|
-
/** Strict label match: exact → case-insensitive
|
|
260
|
-
* do NOT fall back to option 1
|
|
259
|
+
/** Strict label match: exact → case-insensitive → near-miss (surrounding quotes / trailing sentence
|
|
260
|
+
* punctuation trimmed). NULL on no match (caller fails loud — we deliberately do NOT fall back to option 1
|
|
261
|
+
* like `coerceLabel`, which would silently mis-answer).
|
|
261
262
|
* When `fuzzy` is true, also accept a substring match (ONLY when exactly one label appears in the reply
|
|
262
263
|
* to avoid ambiguity). Callers that consume human or LLM output should leave `fuzzy` at its default
|
|
263
264
|
* (false) to require an exact answer — the substring heuristic is opt-in only. */
|
|
@@ -266,6 +267,19 @@ export function matchLabel(raw, labels, fuzzy = false) {
|
|
|
266
267
|
const exact = labels.find((l) => l === r) ?? labels.find((l) => l.toLowerCase() === r.toLowerCase());
|
|
267
268
|
if (exact)
|
|
268
269
|
return exact;
|
|
270
|
+
// Near-miss tier: a model (or human) often replies `Confirmed.` or `"Confirmed"` for the label
|
|
271
|
+
// `Confirmed`, especially on binary confirm gates. Strip surrounding quotes + trailing `.!,;`/whitespace
|
|
272
|
+
// and re-run the exact/ci match. NEVER strip `:` — it is the `OTHER:` free-text sentinel callers rely on,
|
|
273
|
+
// and the trim must not let a near-miss swallow it. Strict (not fuzzy), so it can't mis-bind a substring.
|
|
274
|
+
const norm = r
|
|
275
|
+
.replace(/^["'`]+/, "")
|
|
276
|
+
.replace(/["'`]+$/, "")
|
|
277
|
+
.replace(/[.!,;\s]+$/, "");
|
|
278
|
+
if (norm && norm !== r) {
|
|
279
|
+
const near = labels.find((l) => l === norm) ?? labels.find((l) => l.toLowerCase() === norm.toLowerCase());
|
|
280
|
+
if (near)
|
|
281
|
+
return near;
|
|
282
|
+
}
|
|
269
283
|
if (!fuzzy)
|
|
270
284
|
return null;
|
|
271
285
|
// fuzzy=true: the substring tier fires ONLY when EXACTLY ONE label is contained in the reply. With
|
|
@@ -351,8 +365,10 @@ export class LlmDecider {
|
|
|
351
365
|
continue;
|
|
352
366
|
}
|
|
353
367
|
const raw = await this.complete(this.prompt(text, q.options, ctx), this.model);
|
|
354
|
-
// Match a LABEL first (matchLabel is fuzzy=false
|
|
355
|
-
//
|
|
368
|
+
// Match a LABEL first (matchLabel is fuzzy=false, but tolerates a quoted / trailing-punctuation
|
|
369
|
+
// near-miss): keeps the common path unchanged and means a real option literally named "OTHER: …" is
|
|
370
|
+
// selected as a label (exact match wins; the trim never strips `:`), not hijacked by the free-text
|
|
371
|
+
// branch below.
|
|
356
372
|
const pick = matchLabel(raw, labels);
|
|
357
373
|
if (pick) {
|
|
358
374
|
process.stderr.write(`[llm-decider] "${text}" → "${pick}"${this.intent ? ` (intent: ${this.intent})` : ""}\n`);
|
package/dist/io.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Emit a structured warning to stderr with the GitHub-actions `::warning::` annotation prefix — the one
|
|
3
|
-
* place warning formatting/severity lives, so call sites pass only the message body.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* place warning formatting/severity lives, so call sites pass only the message body. A message that ALREADY
|
|
4
|
+
* carries a GitHub annotation prefix (`::warning::`, `::notice::`, or `::error::`) is written as-is, so a
|
|
5
|
+
* call site that wants a softer/harder severity (e.g. `::notice:: …`) gets exactly that — NOT a doubled
|
|
6
|
+
* `::warning:: ::notice:: …`. Uses `process.stderr.write` (the mechanism the warnings always used) so test
|
|
7
|
+
* spies on it still observe warnings and the output is byte-identical for plain `::warning::` content.
|
|
7
8
|
*/
|
|
8
9
|
export function warn(message) {
|
|
9
|
-
const line =
|
|
10
|
+
const line = /^::(warning|notice|error)::/.test(message) ? message : `::warning:: ${message}`;
|
|
10
11
|
process.stderr.write(line.endsWith("\n") ? line : line + "\n");
|
|
11
12
|
}
|
package/dist/run/cassette.js
CHANGED
|
@@ -1520,7 +1520,7 @@ export async function cmdReplay(args) {
|
|
|
1520
1520
|
try {
|
|
1521
1521
|
p = parseArgs(args, {
|
|
1522
1522
|
// --quiet/--verbose accepted for flag consistency but currently no-op in replay (renderer plan is fixed).
|
|
1523
|
-
booleans: ["--strict", "--quiet", "--verbose"],
|
|
1523
|
+
booleans: ["--strict", "--fail-on-skill-drift", "--quiet", "--verbose"],
|
|
1524
1524
|
values: ["--output-format"],
|
|
1525
1525
|
enums: { "--output-format": ["text", "json"] },
|
|
1526
1526
|
aliases: { "-q": "--quiet", "-V": "--verbose" },
|
|
@@ -1532,7 +1532,7 @@ export async function cmdReplay(args) {
|
|
|
1532
1532
|
}
|
|
1533
1533
|
const target = p.positionals[0];
|
|
1534
1534
|
if (!target) {
|
|
1535
|
-
log("usage: replay <file.cassette.json | dir/> [--strict] [--output-format text|json]");
|
|
1535
|
+
log("usage: replay <file.cassette.json | dir/> [--strict] [--fail-on-skill-drift] [--output-format text|json]");
|
|
1536
1536
|
return process.exit(2);
|
|
1537
1537
|
}
|
|
1538
1538
|
if (p.positionals.length > 1) {
|
|
@@ -1540,7 +1540,10 @@ export async function cmdReplay(args) {
|
|
|
1540
1540
|
return process.exit(2);
|
|
1541
1541
|
}
|
|
1542
1542
|
const json = p.options["--output-format"] === "json";
|
|
1543
|
-
const strict = p.flags["--strict"] ?? false; // #1b: escalate staleness
|
|
1543
|
+
const strict = p.flags["--strict"] ?? false; // #1b: escalate ALL staleness findings to failures (release gate)
|
|
1544
|
+
const failOnSkillDrift = p.flags["--fail-on-skill-drift"] ?? false; // narrower gate: only skill-source drift fails
|
|
1545
|
+
if (strict && failOnSkillDrift)
|
|
1546
|
+
warn("::notice:: [replay] --strict and --fail-on-skill-drift both passed — --strict is the superset (fails on every class), so --fail-on-skill-drift is redundant here\n");
|
|
1544
1547
|
const resolved = resolveInputs(target, ".cassette.json");
|
|
1545
1548
|
if ("error" in resolved) {
|
|
1546
1549
|
log(`replay: ${resolved.error}`);
|
|
@@ -1564,7 +1567,7 @@ export async function cmdReplay(args) {
|
|
|
1564
1567
|
// fine" (false-green by abort). Wrap per-file: turn an unexpected throw into a tallied error result.
|
|
1565
1568
|
let result;
|
|
1566
1569
|
try {
|
|
1567
|
-
result = await replayCassette(rc.cassette, renderer ? [renderer] : [], { strict, cassetteDir: dirname(f) });
|
|
1570
|
+
result = await replayCassette(rc.cassette, renderer ? [renderer] : [], { strict, failOnSkillDrift, cassetteDir: dirname(f) });
|
|
1568
1571
|
}
|
|
1569
1572
|
catch (e) {
|
|
1570
1573
|
log(`replay: ${f}: ${e?.message ?? String(e)}`);
|
|
@@ -1860,7 +1863,10 @@ export function cmdRehash(args) {
|
|
|
1860
1863
|
}
|
|
1861
1864
|
/** Replay a cassette through Run and re-evaluate the content assertions. With a `cassette.artifacts`
|
|
1862
1865
|
* manifest (#1), filesystem assertions (file_exists/user_visible_artifact/artifact_json) ALSO run, against
|
|
1863
|
-
* the materialized snapshot. `opts.strict` (#1b) escalates staleness
|
|
1866
|
+
* the materialized snapshot. `opts.strict` (#1b) escalates ALL staleness findings to failing assertions;
|
|
1867
|
+
* `opts.failOnSkillDrift` escalates only the skill-source classes (`skill`/`shared-root`/`unverifiable-skill`),
|
|
1868
|
+
* leaving baseline drift a non-failing warning. Either way the findings are always surfaced in
|
|
1869
|
+
* `RunResult.staleness` for JSON consumers. */
|
|
1864
1870
|
export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
1865
1871
|
// Cassette format version: ABSENT = legacy (0); a FUTURE version means this harness may misread fields
|
|
1866
1872
|
// it doesn't know about. in strict mode this is a hard failure (future semantics may not be
|
|
@@ -1884,6 +1890,11 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
|
1884
1890
|
// unchanged (frozen-structure limit). The skill-hash recompute needs the local skill dirs to be resolvable
|
|
1885
1891
|
// from the cassette's session path; when they aren't (a moved/committed cassette), we say so rather than
|
|
1886
1892
|
// silently skipping.
|
|
1893
|
+
// Findings are pushed UNCONDITIONALLY (class-tagged) so they're surfaced in JSON (RunResult.staleness) even
|
|
1894
|
+
// on the default gate — a token-free consumer can then distinguish "verified clean" from "couldn't verify"
|
|
1895
|
+
// (the `unverifiable-*` classes) WITHOUT the verdict changing. The `--strict` / `--fail-on-skill-drift`
|
|
1896
|
+
// gates below are the ONLY place a finding becomes a failing assertion. The single `warn()` loop at the end
|
|
1897
|
+
// is the lone stderr emitter — no per-branch `warn()`, so a non-strict run never double-warns one cause.
|
|
1887
1898
|
const staleness = [];
|
|
1888
1899
|
if (cassette.fingerprint) {
|
|
1889
1900
|
const fp = cassette.fingerprint;
|
|
@@ -1894,48 +1905,54 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
|
1894
1905
|
}
|
|
1895
1906
|
catch {
|
|
1896
1907
|
baselineLoadFailed = true;
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
warn("cowork-harness: strict staleness check skipped — baseline could not be loaded\n");
|
|
1902
|
-
}
|
|
1908
|
+
staleness.push({
|
|
1909
|
+
class: "unverifiable-baseline",
|
|
1910
|
+
message: "baseline could not be loaded — cannot verify staleness (env/platform, not skill drift)",
|
|
1911
|
+
});
|
|
1903
1912
|
}
|
|
1904
1913
|
if (!baselineLoadFailed && liveBaseline && liveBaseline !== fp.baseline)
|
|
1905
|
-
staleness.push(
|
|
1914
|
+
staleness.push({
|
|
1915
|
+
class: "baseline",
|
|
1916
|
+
message: `baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record before trusting this replay`,
|
|
1917
|
+
});
|
|
1906
1918
|
if (fp.skillHash) {
|
|
1907
1919
|
const live = buildFingerprint(cassette.scenario.session, fp.baseline, opts.cassetteDir, cassette.scenario.skills);
|
|
1908
1920
|
if (live.skillHash === undefined) {
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
}
|
|
1914
|
-
else {
|
|
1915
|
-
warn("::warning:: [replay] skill fingerprint not re-checkable (local skill dirs not resolvable from this cassette location) — baseline check still applies\n");
|
|
1916
|
-
}
|
|
1921
|
+
staleness.push({
|
|
1922
|
+
class: "unverifiable-skill",
|
|
1923
|
+
message: "skill dirs not resolvable from this cassette location — cannot verify skill staleness (the baseline check still applies)",
|
|
1924
|
+
});
|
|
1917
1925
|
}
|
|
1918
1926
|
else if (live.skillHash !== fp.skillHash) {
|
|
1919
1927
|
const recordedVersion = cassette.cassetteVersion ?? 0;
|
|
1920
1928
|
if (recordedVersion < CASSETTE_VERSION) {
|
|
1921
|
-
staleness.push(
|
|
1929
|
+
staleness.push({
|
|
1930
|
+
class: "format",
|
|
1931
|
+
message: `recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`,
|
|
1932
|
+
});
|
|
1922
1933
|
}
|
|
1923
1934
|
else if (fp.sharedHash !== undefined && live.sharedHash !== undefined) {
|
|
1924
1935
|
const scope = fp.skillScope?.length ? fp.skillScope.map((s) => `skills/${s}`).join(", ") : "skill";
|
|
1925
1936
|
if (live.sharedHash !== fp.sharedHash) {
|
|
1926
|
-
staleness.push(
|
|
1937
|
+
staleness.push({
|
|
1938
|
+
class: "shared-root",
|
|
1939
|
+
message: `shared root changed since record (scope: ${scope}) — re-record before trusting this replay`,
|
|
1940
|
+
});
|
|
1927
1941
|
}
|
|
1928
1942
|
else {
|
|
1929
|
-
staleness.push(`${scope} changed since record — re-record before trusting this replay`);
|
|
1943
|
+
staleness.push({ class: "skill", message: `${scope} changed since record — re-record before trusting this replay` });
|
|
1930
1944
|
}
|
|
1931
1945
|
}
|
|
1932
1946
|
else {
|
|
1933
|
-
staleness.push(
|
|
1947
|
+
staleness.push({
|
|
1948
|
+
class: "skill",
|
|
1949
|
+
message: "local skill/plugin dir contents changed since record — re-record before trusting this replay",
|
|
1950
|
+
});
|
|
1934
1951
|
}
|
|
1935
1952
|
}
|
|
1936
1953
|
}
|
|
1937
1954
|
for (const s of staleness)
|
|
1938
|
-
warn(`::warning:: [replay] cassette stale: ${s}\n`);
|
|
1955
|
+
warn(`::warning:: [replay] cassette stale: ${s.message}\n`);
|
|
1939
1956
|
}
|
|
1940
1957
|
// §2.5 backward compat: warn loudly when controlOut is absent so the user knows question/gate
|
|
1941
1958
|
// assertions are being EXCLUDED (not vacuously evaluated) from this run.
|
|
@@ -2085,10 +2102,17 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
|
2085
2102
|
toolResultTexts: rec.toolResults.map((r) => r.assertText ?? r.text),
|
|
2086
2103
|
truncatedPaths: replayTruncatedPaths,
|
|
2087
2104
|
});
|
|
2088
|
-
// #1b: under --strict,
|
|
2105
|
+
// #1b: under --strict, EVERY staleness finding becomes a failing assertion (non-zero exit), not just a
|
|
2106
|
+
// warning. --fail-on-skill-drift is the narrower gate: only the skill-source classes fail (incl.
|
|
2107
|
+
// `unverifiable-skill` — can't verify skill staleness ⇒ not green), while baseline / format / env-level
|
|
2108
|
+
// findings stay non-failing. `else if` makes --strict the superset when both are passed.
|
|
2109
|
+
const SKILL_DRIFT_CLASSES = new Set(["skill", "shared-root", "unverifiable-skill"]);
|
|
2089
2110
|
if (opts.strict)
|
|
2090
2111
|
for (const s of staleness)
|
|
2091
|
-
assertions.push({ assertion: {}, pass: false, message: `cassette stale (--strict): ${s}` });
|
|
2112
|
+
assertions.push({ assertion: {}, pass: false, message: `cassette stale (--strict): ${s.message}` });
|
|
2113
|
+
else if (opts.failOnSkillDrift)
|
|
2114
|
+
for (const s of staleness.filter((s) => SKILL_DRIFT_CLASSES.has(s.class)))
|
|
2115
|
+
assertions.push({ assertion: {}, pass: false, message: `skill-source drift (--fail-on-skill-drift): ${s.message}` });
|
|
2092
2116
|
// future cassette version — hard failure under --strict (forward semantics may not be
|
|
2093
2117
|
// correctly interpreted here, so a green replay would be a false-green).
|
|
2094
2118
|
if (futureVersionMsg && opts.strict)
|
|
@@ -2175,6 +2199,10 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
|
2175
2199
|
subagents: rec.subagents,
|
|
2176
2200
|
unanswered: rec.unanswered,
|
|
2177
2201
|
outDir: "(replay)",
|
|
2202
|
+
// Class-tagged staleness + skip counts, surfaced to JSON callers (the gate decision already happened
|
|
2203
|
+
// above via failing assertions; these fields are pure data so a green stays green by default).
|
|
2204
|
+
staleness: staleness.length ? staleness : undefined,
|
|
2205
|
+
skippedAssertions: { full: fullSkipCount, partial: partialSkipCount },
|
|
2178
2206
|
// A cassette freezes the answer path: the replay itself is deterministic regardless of how the
|
|
2179
2207
|
// original run was answered. Always explicit (never undefined) so renderer.ts:146 treats it
|
|
2180
2208
|
// correctly — undefined would silently render as "deterministic" (#47 C1 [review-2]).
|
package/dist/run/doctor.js
CHANGED
|
@@ -174,7 +174,11 @@ export function runDoctorChecks(tier, probe = realProbe) {
|
|
|
174
174
|
id: "image",
|
|
175
175
|
title: "Agent image",
|
|
176
176
|
status: present ? "ok" : up ? "fail" : "skip",
|
|
177
|
-
detail: present
|
|
177
|
+
detail: present
|
|
178
|
+
? `${image} present — lean core; OCR / PDF-table skills need the full-parity image (--build-arg COWORK_FULL_PARITY=1)`
|
|
179
|
+
: up
|
|
180
|
+
? `${image} missing`
|
|
181
|
+
: `(skipped — ${runtime} not reachable)`,
|
|
178
182
|
remedy: present || !up ? undefined : `build it: ${agentBuildLine(runtime, image)}`,
|
|
179
183
|
required: up, // only gate on the image once the runtime is actually reachable
|
|
180
184
|
});
|
package/dist/run/execute.js
CHANGED
|
@@ -17,7 +17,7 @@ import { spawnProtocol } from "../runtime/protocol.js";
|
|
|
17
17
|
import { spawnContainer } from "../runtime/container.js";
|
|
18
18
|
import { spawnHostLoop } from "../runtime/hostloop.js";
|
|
19
19
|
import { spawnMicroVm } from "../runtime/microvm.js";
|
|
20
|
-
import { probeImageOmitted, probeMicrovmOmitted, detectCapabilityUse, CAPABILITY_FAMILIES } from "../runtime/image-capabilities.js";
|
|
20
|
+
import { probeImageOmitted, probeMicrovmOmitted, detectCapabilityUse, capabilityPreflightDecision, CAPABILITY_FAMILIES, } from "../runtime/image-capabilities.js";
|
|
21
21
|
import { instanceName } from "../runtime/lima.js";
|
|
22
22
|
import { decideLoopFromBaseline, readGateFlag } from "../loop-decision.js";
|
|
23
23
|
import { startEgressSidecar, registerCleanup } from "../egress/sidecar.js";
|
|
@@ -258,6 +258,7 @@ export async function executeScenario(scenario, opts = {}) {
|
|
|
258
258
|
let hostProxy;
|
|
259
259
|
let microvmProxyPort;
|
|
260
260
|
let record;
|
|
261
|
+
let unansweredErr; // set when a gate whiffs — drives the salvage branch below
|
|
261
262
|
let child; // hoisted so the finally can reap a crashed/orphaned container (F1)
|
|
262
263
|
let containerName;
|
|
263
264
|
let deregisterContainerReap; // 2a: Ctrl-C cleanup for the agent container
|
|
@@ -270,6 +271,29 @@ export async function executeScenario(scenario, opts = {}) {
|
|
|
270
271
|
const viaApiOn = readGateFlag(baseline, "1978029737", "coworkWebFetchViaApi");
|
|
271
272
|
const promptGateOn = readGateFlag(baseline, "1978029737", "coworkWebFetchPrompt");
|
|
272
273
|
const provenanceRef = {};
|
|
274
|
+
// Pre-flight: if the skill DECLARES required capabilities and the image provably omits one, FAIL FAST here
|
|
275
|
+
// — before any paid agent run — instead of burning ~12 min to reach a verdict the post-run guard already
|
|
276
|
+
// knows. The author can opt out with `allow_missing_capability: true` (the fallback is equivalent), which
|
|
277
|
+
// downgrades to a notice. The image probe is digest-cached, so it's shared with the post-run check (no
|
|
278
|
+
// second container spawn), and it spawns a throwaway `--network none` container with no model — zero tokens.
|
|
279
|
+
const declaredCaps = scenario.requires_capabilities ?? [];
|
|
280
|
+
if (declaredCaps.length &&
|
|
281
|
+
(effectiveFidelity === "container" || effectiveFidelity === "hostloop" || effectiveFidelity === "microvm") &&
|
|
282
|
+
process.env.COWORK_SKIP_CAPABILITY_PROBE !== "1") {
|
|
283
|
+
const omitted = effectiveFidelity === "microvm"
|
|
284
|
+
? probeMicrovmOmitted(instanceName(baseline))
|
|
285
|
+
: probeImageOmitted({
|
|
286
|
+
runtime: process.env.COWORK_CONTAINER_RUNTIME ?? "docker",
|
|
287
|
+
image: process.env.COWORK_AGENT_IMAGE ?? "cowork-agent-base:2",
|
|
288
|
+
tier: effectiveFidelity,
|
|
289
|
+
});
|
|
290
|
+
const allowMissing = scenario.assert.some((a) => a.allow_missing_capability === true);
|
|
291
|
+
const { abort, message } = capabilityPreflightDecision(declaredCaps, omitted, allowMissing);
|
|
292
|
+
if (abort)
|
|
293
|
+
throw new BoundaryError(`[capability] ${message}`);
|
|
294
|
+
if (message)
|
|
295
|
+
warn(`::notice:: [capability] (pre-flight) ${message} (allow_missing_capability asserted — proceeding)\n`);
|
|
296
|
+
}
|
|
273
297
|
try {
|
|
274
298
|
// acquire the egress sidecar / host proxy INSIDE the protected try so a throw in resource
|
|
275
299
|
// acquisition OR in renderPrompts below can't leak a Docker network / a bound proxy port — the `finally`
|
|
@@ -365,10 +389,22 @@ export async function executeScenario(scenario, opts = {}) {
|
|
|
365
389
|
permissiveMode: plan.permissionMode === "bypassPermissions",
|
|
366
390
|
};
|
|
367
391
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
392
|
+
try {
|
|
393
|
+
record = await run.drive(scenario.prompt, {
|
|
394
|
+
subagentAppend: prompts.subagentAppend,
|
|
395
|
+
sdkMcp,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
// An unanswered gate is recoverable: grab the in-progress record so the work done before the whiff can
|
|
400
|
+
// be salvaged to disk below. Any other error is a genuine fault — keep today's fail-fast behavior.
|
|
401
|
+
if (e instanceof UnansweredError) {
|
|
402
|
+
unansweredErr = e;
|
|
403
|
+
record = run.partial();
|
|
404
|
+
}
|
|
405
|
+
else
|
|
406
|
+
throw e;
|
|
407
|
+
}
|
|
372
408
|
}
|
|
373
409
|
finally {
|
|
374
410
|
// Reap the agent container FIRST (before the sidecar networks), so a crashed/unanswered run can't
|
|
@@ -402,6 +438,36 @@ export async function executeScenario(scenario, opts = {}) {
|
|
|
402
438
|
// are read-only inputs and are NOT visible roots. Persisted to RunResult so the plan-less lanes
|
|
403
439
|
// (verify reads result.json; replay reads the cassette) match this without rebuilding a LaunchPlan.
|
|
404
440
|
const userVisibleRoots = ["outputs", ...plan.mounts.filter((m) => m.kind === "folder").map((m) => m.mountPath)];
|
|
441
|
+
// Salvage path: the run exited on an unanswered gate. Persist a PARTIAL result.json (+ run.jsonl/trace) so
|
|
442
|
+
// the artifacts the agent wrote before the whiff survive for inspection, then re-throw so the CLI still
|
|
443
|
+
// exits 2. Skip assertion eval and the capability probe (a real container spawn) — a partial run has no
|
|
444
|
+
// meaningful assertion or verdict outcome.
|
|
445
|
+
if (unansweredErr) {
|
|
446
|
+
const partialResult = buildPartialResult({
|
|
447
|
+
scenarioName: scenario.name,
|
|
448
|
+
prompt: scenario.prompt,
|
|
449
|
+
fidelity: scenario.fidelity,
|
|
450
|
+
baseline: baseline.appVersion,
|
|
451
|
+
record,
|
|
452
|
+
outDir,
|
|
453
|
+
workRoot,
|
|
454
|
+
userVisibleRoots,
|
|
455
|
+
effectiveFidelity,
|
|
456
|
+
egress,
|
|
457
|
+
durationMs: Date.now() - startedAt,
|
|
458
|
+
unanswered: { message: unansweredErr.message, hint: unansweredErr.hint },
|
|
459
|
+
fingerprint: buildFingerprint(scenario.session, baseline.appVersion, undefined, scenario.skills),
|
|
460
|
+
});
|
|
461
|
+
writeFileSync(join(outDir, "result.json"), scrub(JSON.stringify(partialResult, null, 2), secrets));
|
|
462
|
+
writeRunJsonl(outDir, scenario, effectiveFidelity, record, egress, secrets);
|
|
463
|
+
writeTrace(outDir, record, egress, secrets, partialResult.durationMs);
|
|
464
|
+
scrubFileInPlace(join(outDir, "events.jsonl"), secrets);
|
|
465
|
+
scrubFileInPlace(join(outDir, "control-out.jsonl"), secrets);
|
|
466
|
+
// Loud PARTIAL marker so the populated artifacts are never misread as success (the no-false-green rule).
|
|
467
|
+
warn(`::notice:: [partial] run did NOT complete (unanswered gate) — salvaged the pre-failure work to:\n` +
|
|
468
|
+
` ${outDir}\n inspect it: cowork-harness inspect ${outDir}\n`);
|
|
469
|
+
throw unansweredErr;
|
|
470
|
+
}
|
|
405
471
|
const assertions = evaluate(scenario.assert, {
|
|
406
472
|
transcript: record.transcript,
|
|
407
473
|
toolsCalled: record.toolsCalled,
|
|
@@ -632,6 +698,51 @@ function writeRunJsonl(outDir, scenario, fidelity, rec, egress, secrets) {
|
|
|
632
698
|
];
|
|
633
699
|
writeFileSync(join(outDir, "run.jsonl"), scrub(lines.map((l) => JSON.stringify(l)).join("\n"), secrets));
|
|
634
700
|
}
|
|
701
|
+
/** Assemble a RunResult for a run that did NOT complete — it exited on an unanswered gate. The work the
|
|
702
|
+
* agent did before the whiff (artifacts on disk, the partial transcript, decisions/tool counts so far) is
|
|
703
|
+
* salvaged so the paid run is still inspectable instead of vanishing. Deliberately reduced: no assertion
|
|
704
|
+
* outcome (a partial run has none) and no capability/verdict fields (those would need a probe we skip).
|
|
705
|
+
* `partial:true` is the signal that lets consumers (verify-run, scaffold, the footer) refuse to read its
|
|
706
|
+
* populated `artifacts[]` as a passing run. */
|
|
707
|
+
export function buildPartialResult(args) {
|
|
708
|
+
const { record } = args;
|
|
709
|
+
return {
|
|
710
|
+
$schema: RUN_RESULT_SCHEMA_URL,
|
|
711
|
+
generator: "cowork-harness",
|
|
712
|
+
scenario: args.scenarioName,
|
|
713
|
+
prompt: args.prompt,
|
|
714
|
+
fidelity: args.fidelity,
|
|
715
|
+
baseline: args.baseline,
|
|
716
|
+
result: "error",
|
|
717
|
+
partial: true,
|
|
718
|
+
unansweredGate: { message: args.unanswered.message, ...(args.unanswered.hint ? { hint: args.unanswered.hint } : {}) },
|
|
719
|
+
decisions: record.decisions.map((d) => ({
|
|
720
|
+
kind: d.kind,
|
|
721
|
+
name: d.name,
|
|
722
|
+
decision: d.decision,
|
|
723
|
+
by: d.by,
|
|
724
|
+
detail: d.detail,
|
|
725
|
+
rationale: d.rationale,
|
|
726
|
+
})),
|
|
727
|
+
toolCounts: record.toolCounts,
|
|
728
|
+
gateDeliveries: record.gateDeliveries,
|
|
729
|
+
egress: args.egress,
|
|
730
|
+
assertions: [],
|
|
731
|
+
toolResults: record.toolResults,
|
|
732
|
+
subagents: record.subagents,
|
|
733
|
+
unanswered: record.unanswered,
|
|
734
|
+
usage: record.usage,
|
|
735
|
+
cost: record.cost,
|
|
736
|
+
durationMs: args.durationMs,
|
|
737
|
+
outDir: args.outDir,
|
|
738
|
+
workDir: args.workRoot,
|
|
739
|
+
outputsDir: join(args.workRoot, "outputs"),
|
|
740
|
+
userVisibleRoots: args.userVisibleRoots,
|
|
741
|
+
artifacts: collectArtifacts(args.workRoot, args.userVisibleRoots),
|
|
742
|
+
effectiveFidelity: args.effectiveFidelity,
|
|
743
|
+
...(args.fingerprint ? { fingerprint: args.fingerprint } : {}),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
635
746
|
/** B3: the structured run trace. */
|
|
636
747
|
/** ENV-MANIFEST: recursively list files under each user-visible prefix (relative path + byte size).
|
|
637
748
|
* Paths only — NO content snapshot (that is the cassette manifest, #1).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** A compact, depth-1 preview of a JSON artifact: scalars kept inline, arrays shown as a count, nested
|
|
4
|
+
* objects collapsed to `{…}` — enough to answer "did it produce the right fields?" without dumping blobs. */
|
|
5
|
+
function shallowPreview(doc) {
|
|
6
|
+
if (Array.isArray(doc))
|
|
7
|
+
return `[${doc.length} items]`;
|
|
8
|
+
if (!doc || typeof doc !== "object")
|
|
9
|
+
return { value: doc };
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const [k, v] of Object.entries(doc)) {
|
|
12
|
+
if (Array.isArray(v))
|
|
13
|
+
out[k] = `[${v.length} items]`;
|
|
14
|
+
else if (v && typeof v === "object")
|
|
15
|
+
out[k] = "{…}";
|
|
16
|
+
else
|
|
17
|
+
out[k] = v;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function humanMs(ms) {
|
|
22
|
+
if (!ms && ms !== 0)
|
|
23
|
+
return "?";
|
|
24
|
+
if (ms < 1000)
|
|
25
|
+
return `${ms}ms`;
|
|
26
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
27
|
+
}
|
|
28
|
+
/** Build the digest for one kept run dir: header facts + the artifacts manifest, with a shallow field
|
|
29
|
+
* preview for each JSON artifact (read from the work dir, which result.json records). The manifest
|
|
30
|
+
* (paths + sizes) lives in result.json and always survives; the content preview needs the work dir, which
|
|
31
|
+
* a non-`--keep` container/microvm run tears down. */
|
|
32
|
+
function digestFor(runDir) {
|
|
33
|
+
const resultPath = join(runDir, "result.json");
|
|
34
|
+
if (!existsSync(resultPath))
|
|
35
|
+
throw new Error(`no result.json under ${runDir} (is this a kept run dir? e.g. runs/<scenario>/<sessionId>/)`);
|
|
36
|
+
const result = JSON.parse(readFileSync(resultPath, "utf8"));
|
|
37
|
+
const workDir = result.workDir ?? "";
|
|
38
|
+
const workDirAvailable = !!workDir && existsSync(workDir);
|
|
39
|
+
const artifacts = (result.artifacts ?? []).map((a) => {
|
|
40
|
+
const entry = { path: a.path, bytes: a.bytes };
|
|
41
|
+
if (workDirAvailable && a.path.endsWith(".json")) {
|
|
42
|
+
const abs = join(workDir, a.path);
|
|
43
|
+
if (existsSync(abs)) {
|
|
44
|
+
try {
|
|
45
|
+
entry.preview = shallowPreview(JSON.parse(readFileSync(abs, "utf8")));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* not valid JSON after all — leave preview off */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return entry;
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
scenario: result.scenario,
|
|
56
|
+
fidelity: result.fidelity,
|
|
57
|
+
result: result.result,
|
|
58
|
+
...(result.partial ? { partial: true } : {}),
|
|
59
|
+
...(result.unansweredGate ? { unansweredGate: result.unansweredGate } : {}),
|
|
60
|
+
durationMs: result.durationMs,
|
|
61
|
+
...(result.cost ? { cost: result.cost } : {}),
|
|
62
|
+
workDirAvailable,
|
|
63
|
+
artifacts,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Render a run dir for the `inspect` command. `json` mode returns the structured digest; text mode returns
|
|
67
|
+
* a human summary: header, a PARTIAL banner if the run didn't complete, and each artifact with a shallow
|
|
68
|
+
* field preview (or a "work dir torn down" note when content can't be read). */
|
|
69
|
+
export function buildInspectView(runDir, opts = {}) {
|
|
70
|
+
const d = digestFor(runDir);
|
|
71
|
+
if (opts.json)
|
|
72
|
+
return JSON.stringify(d, null, 2);
|
|
73
|
+
const lines = [];
|
|
74
|
+
lines.push(`run: ${d.scenario} (${d.fidelity}) result: ${d.result} ${humanMs(d.durationMs)}`);
|
|
75
|
+
if (d.partial) {
|
|
76
|
+
lines.push(`⚠ PARTIAL — this run did NOT complete (exited on an unanswered gate); artifacts below are pre-failure.`);
|
|
77
|
+
if (d.unansweredGate)
|
|
78
|
+
lines.push(` gate: ${d.unansweredGate.message.split("\n")[0]}`);
|
|
79
|
+
}
|
|
80
|
+
lines.push(`artifacts (${d.artifacts.length}):`);
|
|
81
|
+
for (const a of d.artifacts) {
|
|
82
|
+
lines.push(` ${a.path} (${a.bytes} B)`);
|
|
83
|
+
if (a.preview && typeof a.preview === "object") {
|
|
84
|
+
for (const [k, v] of Object.entries(a.preview))
|
|
85
|
+
lines.push(` ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
|
|
86
|
+
}
|
|
87
|
+
else if (typeof a.preview === "string") {
|
|
88
|
+
lines.push(` ${a.preview}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!d.workDirAvailable && d.artifacts.some((a) => a.path.endsWith(".json"))) {
|
|
92
|
+
lines.push(` (work dir torn down — re-run with --keep to preview artifact contents)`);
|
|
93
|
+
}
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
package/dist/run/run.js
CHANGED
|
@@ -73,6 +73,14 @@ export class Run {
|
|
|
73
73
|
ctx() {
|
|
74
74
|
return { task: this.rec.transcript, transcript: () => this.rec.transcript, toolLog: () => this.toolLog, runId: this.rec.runId };
|
|
75
75
|
}
|
|
76
|
+
/** The in-progress record. When `drive()` throws on an unanswered gate, this holds everything accumulated
|
|
77
|
+
* up to the whiff (transcript, decisions, tool counts, artifacts-on-disk are separate) — the caller uses
|
|
78
|
+
* it to salvage a partial run instead of discarding the work. Fully initialized in the constructor, so
|
|
79
|
+
* it's a usable shell even on a very-early throw. The post-loop gate-delivery pairing has NOT run on the
|
|
80
|
+
* throw path, so `gateDeliveries` is empty here. */
|
|
81
|
+
partial() {
|
|
82
|
+
return this.rec;
|
|
83
|
+
}
|
|
76
84
|
/** Drive one-shot (string) or multi-turn (async iterable) and return the record. */
|
|
77
85
|
async drive(turns, startOpts) {
|
|
78
86
|
const turnIter = typeof turns === "string" ? oneShot(turns) : turns[Symbol.asyncIterator]();
|
package/dist/run/runs-gc.js
CHANGED
|
@@ -10,16 +10,16 @@ const DEFAULT_KEEP_LAST = 5;
|
|
|
10
10
|
* dir has neither → it is what GC should drop first. `events.jsonl` exists from session start, so an
|
|
11
11
|
* in-flight run is protected without a wall-clock guard. */
|
|
12
12
|
const isRealRun = (dir) => existsSync(join(dir, "result.json")) || existsSync(join(dir, "events.jsonl"));
|
|
13
|
-
/** `cowork-harness
|
|
13
|
+
/** `cowork-harness prune [--keep-last <n>] [--dry-run] [<runs-dir>]`
|
|
14
14
|
*
|
|
15
15
|
* For each scenario directory under the runs root, ranks EPHEMERAL run dirs by (1) real-run first (has
|
|
16
16
|
* result.json OR events.jsonl), (2) mtime descending, (3) name — then keeps the N most recent of that order
|
|
17
17
|
* and removes the rest. So an older COMPLETED run beats a newer empty scaffold dir for a keep slot, but
|
|
18
18
|
* `--keep-last` stays a HARD CAP (the ranking only decides WHICH N survive — never grows the kept count).
|
|
19
|
-
* Do NOT run `
|
|
19
|
+
* Do NOT run `prune` against an actively-writing runs root.
|
|
20
20
|
* Pinned `sess-*` dirs (persisted, resumable `--session-id` sessions) are retained unconditionally.
|
|
21
21
|
* The default root is the flat, machine-global `~/.cowork-harness/runs` (shared across projects), so a
|
|
22
|
-
* bare `
|
|
22
|
+
* bare `prune` prunes ephemeral runs from ALL projects; pass an explicit <runs-dir> to scope it.
|
|
23
23
|
* Safe by default (dry-run-able). */
|
|
24
24
|
export function cmdRunsGc(args) {
|
|
25
25
|
let p;
|
|
@@ -34,19 +34,19 @@ export function cmdRunsGc(args) {
|
|
|
34
34
|
return process.exit(2);
|
|
35
35
|
}
|
|
36
36
|
if (p.positionals.length > 1) {
|
|
37
|
-
log(`
|
|
37
|
+
log(`prune takes an optional <runs-dir> (got ${p.positionals.length}: ${p.positionals.join(", ")})`);
|
|
38
38
|
return process.exit(2);
|
|
39
39
|
}
|
|
40
40
|
const rawKeep = p.options["--keep-last"];
|
|
41
41
|
const keepLast = rawKeep !== undefined ? Number(rawKeep) : DEFAULT_KEEP_LAST;
|
|
42
42
|
if (!Number.isInteger(keepLast) || keepLast < 1) {
|
|
43
|
-
log(`
|
|
43
|
+
log(`prune: --keep-last must be a positive integer (got ${rawKeep})`);
|
|
44
44
|
return process.exit(2);
|
|
45
45
|
}
|
|
46
46
|
const dryRun = p.flags["--dry-run"] ?? false;
|
|
47
47
|
const runsRoot = p.positionals[0] ?? runsWriteRoot();
|
|
48
48
|
if (!existsSync(runsRoot)) {
|
|
49
|
-
log(`✓
|
|
49
|
+
log(`✓ prune: ${runsRoot} does not exist — nothing to prune`);
|
|
50
50
|
return process.exit(0);
|
|
51
51
|
}
|
|
52
52
|
let deleted = 0;
|
|
@@ -115,7 +115,7 @@ export function cmdRunsGc(args) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
log(deleted > 0
|
|
118
|
-
? `✓
|
|
119
|
-
: `✓
|
|
118
|
+
? `✓ prune: pruned ${deleted} run dir(s), kept ${kept}${dryRun ? " (dry-run — nothing deleted)" : ""}`
|
|
119
|
+
: `✓ prune: nothing to prune (${kept} run dir(s) within --keep-last ${keepLast})`);
|
|
120
120
|
return process.exit(0);
|
|
121
121
|
}
|
package/dist/run/scaffold.js
CHANGED
|
@@ -46,13 +46,20 @@ export function buildScaffold(eventsFile) {
|
|
|
46
46
|
answers.push({ when_question: escapeRx(q), choose: val });
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
// A partial run did NOT complete (it exited on an unanswered gate). Its artifacts are pre-failure and its
|
|
50
|
+
// result is "error", so neither should become an assertion the author trusts — that would scaffold a
|
|
51
|
+
// scenario asserting the half-finished output. Keep the gate answers (still worth locking) but drop the
|
|
52
|
+
// artifact/result asserts and warn loudly.
|
|
53
|
+
const partial = result.partial === true;
|
|
49
54
|
// Observed artifacts → file_exists; sub-agent count → dispatch_count_max; final result.
|
|
50
55
|
const assert = [];
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
if (!partial) {
|
|
57
|
+
for (const art of result.artifacts ?? [])
|
|
58
|
+
assert.push({ file_exists: art.path });
|
|
59
|
+
if ((result.subagents ?? []).length)
|
|
60
|
+
assert.push({ dispatch_count_max: (result.subagents ?? []).length });
|
|
61
|
+
assert.push({ result: result.result ?? "success" });
|
|
62
|
+
}
|
|
56
63
|
const scenario = {
|
|
57
64
|
// `result.baseline` is the recorded appVersion; a scenario pins `latest` (or a `desktop-<ver>`) — default
|
|
58
65
|
// to latest and let the author pin if they need a specific release.
|
|
@@ -70,8 +77,13 @@ export function buildScaffold(eventsFile) {
|
|
|
70
77
|
// Provenance signature — mirrors the cassette's `generator`/`cassetteVersion` fields. The scenario
|
|
71
78
|
// schema is `additionalProperties:false`, so it rides as a YAML comment (survives parse + re-serialize)
|
|
72
79
|
// rather than a structured field. Tool + version let a reader trace a committed scenario to its origin.
|
|
80
|
+
const partialMarker = partial
|
|
81
|
+
? `# PARTIAL run — this run did NOT complete (it exited on an unanswered gate). The artifact and result\n` +
|
|
82
|
+
`# assertions were omitted (the outputs are pre-failure). Re-run to completion and re-scaffold before trusting.\n`
|
|
83
|
+
: "";
|
|
73
84
|
return (`# generated by cowork-harness v${pkgVersion()} (scaffold) from ${runDir}\n` +
|
|
74
85
|
`# REVIEW before committing: tighten the when_question regexes to stable fragments, prune asserts you don't need.\n` +
|
|
86
|
+
partialMarker +
|
|
75
87
|
multiSelectMarker +
|
|
76
88
|
stringify(scenario));
|
|
77
89
|
}
|
package/dist/run/trace-view.js
CHANGED
|
@@ -9,7 +9,7 @@ import { parseMessage } from "../agent/session.js";
|
|
|
9
9
|
* convention used by the VM work dir (`lima.ts` `VM_WORK_HOST`). FLAT (shared across all projects on the
|
|
10
10
|
* machine): ephemeral `local_*` run dirs carry a unique hrtime id so they never path-collide; pinned
|
|
11
11
|
* `sess-*` dirs are deterministic, so a cross-project collision is guarded in `execute.ts` (the writer
|
|
12
|
-
* errors rather than overwriting another project's session) and `
|
|
12
|
+
* errors rather than overwriting another project's session) and `prune` never prunes them.
|
|
13
13
|
*/
|
|
14
14
|
export function defaultRunsHome() {
|
|
15
15
|
return join(homedir(), ".cowork-harness", "runs");
|
|
@@ -210,6 +210,29 @@ function readWorkspaceScript(workRoot, rel) {
|
|
|
210
210
|
* error (the module may simply be present, or the script may swallow the failure). Script reads are
|
|
211
211
|
* read-only, containment-checked, and bounded; missing files are skipped.
|
|
212
212
|
*/
|
|
213
|
+
/** Pre-run check: a skill that DECLARES the capability families its core path needs, run against an image
|
|
214
|
+
* that OMITS one, will hard-fail only after the (paid, ~12-min) run. Surface the build remedy up front so
|
|
215
|
+
* the operator fixes it before paying. Returns null when nothing is declared, all caps are present, or the
|
|
216
|
+
* probe could not run (`omitted` null) — i.e. only warns on a definite, actionable mismatch. */
|
|
217
|
+
export function capabilityPreflightWarning(declared, omitted) {
|
|
218
|
+
if (!declared.length || !omitted)
|
|
219
|
+
return null;
|
|
220
|
+
const missing = declared.filter((c) => omitted.includes(c));
|
|
221
|
+
if (!missing.length)
|
|
222
|
+
return null;
|
|
223
|
+
// State the gap + remedy only; the caller frames the consequence (abort pre-run, or a notice when
|
|
224
|
+
// allow_missing_capability is asserted) — so this string stays accurate in both contexts.
|
|
225
|
+
return (`skill declares requires_capabilities [${missing.join(", ")}] but the agent image omits them — ` +
|
|
226
|
+
`rebuild full parity (--build-arg COWORK_FULL_PARITY=1) and point COWORK_AGENT_IMAGE at it.`);
|
|
227
|
+
}
|
|
228
|
+
/** The pre-run capability decision: a DEFINITE gap (a declared family the image omits) **fails fast** before
|
|
229
|
+
* the paid run, unless the scenario opted out with `allow_missing_capability: true` (then it's a notice and
|
|
230
|
+
* the run proceeds — the author has declared the fallback equivalent). No gap / indefinite probe → no
|
|
231
|
+
* action. Keeping this a pure function makes the fail-fast-by-default policy unit-testable. */
|
|
232
|
+
export function capabilityPreflightDecision(declared, omitted, allowMissing) {
|
|
233
|
+
const message = capabilityPreflightWarning(declared, omitted);
|
|
234
|
+
return { abort: message !== null && !allowMissing, message };
|
|
235
|
+
}
|
|
213
236
|
export function detectCapabilityUse(eventsFile, omitted, workRoot) {
|
|
214
237
|
if (!omitted.length)
|
|
215
238
|
return [];
|
package/docs/cassette.md
CHANGED
|
@@ -211,8 +211,9 @@ with `record --max-artifact-bytes <n>` (or `COWORK_HARNESS_MAX_ARTIFACT_BYTES`)
|
|
|
211
211
|
stays replay-checkable, and `record` fails fast if an `artifact_json` targets an artifact it had to truncate (that
|
|
212
212
|
would pass at record but fail at replay). Without a manifest (older
|
|
213
213
|
cassettes), these are skipped. A green replay re-confirms *record-time* artifacts, **not** that the current
|
|
214
|
-
skill still produces them — `replay --strict` fails the run when the `fingerprint` shows
|
|
215
|
-
|
|
214
|
+
skill still produces them — `replay --strict` fails the run when the `fingerprint` shows ANY skill/baseline
|
|
215
|
+
drift, or `replay --fail-on-skill-drift` fails only on skill-source drift (leaving baseline drift a warning).
|
|
216
|
+
Either way, every replay result also reports the drift in `staleness[]` (class-tagged) for a JSON gate to read.
|
|
216
217
|
|
|
217
218
|
### Still skipped on replay (no filesystem/network in a cassette)
|
|
218
219
|
|
package/docs/scenario.md
CHANGED
|
@@ -261,6 +261,13 @@ fires even when the skill's fallback masks the gap. The check is computed at run
|
|
|
261
261
|
`verify-run`/`replay` honor the recorded outcome — a clean full-parity run records nothing and never
|
|
262
262
|
false-fails later. Opt out with `allow_missing_capability: true` when the fallback is genuinely equivalent.
|
|
263
263
|
|
|
264
|
+
When `requires_capabilities` is declared, the harness probes the image **before** driving and, if a declared
|
|
265
|
+
family is omitted, **fails fast — it aborts the run (exit 3) before spending a single token**, instead of
|
|
266
|
+
running ~12 min to a post-run hard-fail that's already known. Rebuild full parity
|
|
267
|
+
(`--build-arg COWORK_FULL_PARITY=1`) and point `COWORK_AGENT_IMAGE` at it, or assert
|
|
268
|
+
`allow_missing_capability: true` (which downgrades the abort to a notice and proceeds, same as it opts out of
|
|
269
|
+
the post-run check).
|
|
270
|
+
|
|
264
271
|
```yaml
|
|
265
272
|
requires_capabilities: [office_convert, pdf_tables] # fail unless the tier provides (and can verify) these
|
|
266
273
|
```
|
|
@@ -333,7 +340,9 @@ small JSON bodies) into the cassette, and `replay` materializes that snapshot to
|
|
|
333
340
|
`artifact_json` needs the JSON body inlined (small files); a hash-only (oversized) entry still satisfies
|
|
334
341
|
`file_exists` but not `artifact_json`. Without a manifest (older cassettes), they are **skipped** (loud).
|
|
335
342
|
A green `replay` re-confirms *record-time* artifacts, **not** that the current skill still produces them —
|
|
336
|
-
that needs a live `run` (the cassette's
|
|
343
|
+
that needs a live `run` (the cassette's staleness fingerprint warns when the skill/baseline drifted; `replay
|
|
344
|
+
--strict` fails on any drift, `--fail-on-skill-drift` on skill-source drift only, and every result reports it
|
|
345
|
+
in `staleness[]` for a JSON gate).
|
|
337
346
|
|
|
338
347
|
**Egress + other filesystem** assertions (`no_delete_in_outputs`, `self_heal_ran`,
|
|
339
348
|
`transcript_no_host_path`, `egress_*`/`expect_denied`) are still **skipped** on `replay` — they only run on
|
|
@@ -382,7 +391,8 @@ are) or need a real predicate over a skill's **structured JSON output**:
|
|
|
382
391
|
full Python callable with autocomplete and `print(d)`, strictly richer than anything a YAML string can
|
|
383
392
|
express. **If you're checking structured JSON content and already write Python, prefer the pytest lambda**
|
|
384
393
|
(a YAML content-predicate would be equal power with worse tooling). Find an artifact's real field paths by
|
|
385
|
-
running once with `--keep`
|
|
394
|
+
running once with `--keep`, then `cowork-harness inspect <run-dir>` (a shallow field preview of each JSON
|
|
395
|
+
artifact) or by reading the JSON under the run's `…/mnt/outputs/…` directly.
|
|
386
396
|
|
|
387
397
|
## Output
|
|
388
398
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cowork-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
"workDir": { "type": "string" },
|
|
50
50
|
"outputsDir": { "type": "string" },
|
|
51
51
|
"artifacts": { "type": "array" },
|
|
52
|
+
"partial": { "type": "boolean" },
|
|
53
|
+
"unansweredGate": { "type": "object" },
|
|
52
54
|
"nonDeterministic": { "type": "boolean" },
|
|
53
55
|
"nonDeterministicTerminal": { "type": "boolean" },
|
|
54
56
|
"permissiveAutoAllow": {
|
package/scripts/gen-schema.ts
CHANGED
|
@@ -19,14 +19,21 @@ export const SCHEMA_DIR = join(REPO_ROOT, "schema");
|
|
|
19
19
|
* is. Writer + the drift-guard test both reference this one constant. */
|
|
20
20
|
export const ASSERTION_KEYS_PATH = join(REPO_ROOT, ".claude/skills/cowork-harness/scripts/assertion-keys.json");
|
|
21
21
|
|
|
22
|
-
/** The authoritative
|
|
23
|
-
* `assert --list` reads). Generating
|
|
22
|
+
/** The authoritative key lists `scenario.py` reads — derived from the Zod schemas (the same source
|
|
23
|
+
* `assert --list` reads). Generating them keeps the linter's unknown-key checks from drifting: `keys` is
|
|
24
|
+
* the `assert:` catalog, `topLevelKeys` the scenario top-level catalog (an earlier hand-maintained copy
|
|
25
|
+
* drifted and false-flagged the valid `requires_capabilities`). `profile`/`assertions` are NOT here — they
|
|
26
|
+
* are deprecated aliases handled by scenario.py's special-cases (the Zod `Scenario` preprocess remaps
|
|
27
|
+
* `profile`; `assertions` is a hard error), so they are intentionally absent from the schema shape. */
|
|
24
28
|
export function buildAssertionKeys(): string {
|
|
25
29
|
return (
|
|
26
30
|
JSON.stringify(
|
|
27
31
|
{
|
|
28
|
-
$comment: "GENERATED from the Zod
|
|
32
|
+
$comment: "GENERATED from the Zod schemas (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
|
|
29
33
|
keys: Object.keys(Assertion.shape).sort(),
|
|
34
|
+
// Every valid top-level scenario key, from the ScenarioObject strictObject shape (NOT the `Scenario`
|
|
35
|
+
// preprocess wrapper). scenario.py keeps an embedded fallback parity-tested against this.
|
|
36
|
+
topLevelKeys: Object.keys(ScenarioObject.shape).sort(),
|
|
30
37
|
// The verdict-modifier subset (no-op assertions that suppress a default-fail). scenario.py keeps a
|
|
31
38
|
// hardcoded copy parity-tested against this; see VERDICT_MODIFIER_KEYS in src/types.ts.
|
|
32
39
|
verdictModifierKeys: [...VERDICT_MODIFIER_KEYS].sort(),
|