cowork-harness 0.13.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 +26 -0
- package/README.md +2 -2
- package/dist/cli.js +3 -2
- package/dist/io.js +6 -5
- package/dist/run/cassette.js +55 -27
- package/docs/cassette.md +3 -2
- package/docs/scenario.md +3 -1
- package/package.json +1 -1
- 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
|
@@ -6,6 +6,32 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
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
|
+
|
|
9
35
|
## [0.13.0] — 2026-06-25
|
|
10
36
|
|
|
11
37
|
### Added
|
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ 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?" |
|
|
@@ -208,7 +208,7 @@ This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that te
|
|
|
208
208
|
/plugin install cowork-harness@cowork-harness
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
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.
|
|
212
212
|
|
|
213
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.)
|
|
214
214
|
|
package/dist/cli.js
CHANGED
|
@@ -72,7 +72,8 @@ const HELP = `cowork-harness <command> (v${"$VERSION"})
|
|
|
72
72
|
[--decider-llm [--intent "…"]] | [--on-unanswered fail|first] answer live via a model / auto-pick
|
|
73
73
|
(a live decider flags the cassette non-deterministic; it still replays deterministically)
|
|
74
74
|
replay <file|dir> deterministic protocol-replay of a cassette or a dir of them (no token, no Docker)
|
|
75
|
-
[--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
|
|
76
77
|
[--output-format json]
|
|
77
78
|
verify-cassettes <file|dir> CI gate (no token): privacy scan + staleness — exit 1 on finding or drift
|
|
78
79
|
[--skip-privacy|--skip-staleness] skip one check
|
|
@@ -262,7 +263,7 @@ const SUBCOMMAND_USAGE = {
|
|
|
262
263
|
' answer gates LIVE: [--decider-dir <dir>] (single scenario only) | [--decider-llm [--intent "<one line>"]] | [--on-unanswered fail|first]\n' +
|
|
263
264
|
" (a live decider flags the cassette non-deterministic — re-recording may drift; replay stays deterministic. --rerecord-stale rejects these flags.)\n" +
|
|
264
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).",
|
|
265
|
-
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]",
|
|
266
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]",
|
|
267
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`)",
|
|
268
269
|
assertions: "usage: assertions --list [--output-format json]",
|
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/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
|
@@ -340,7 +340,9 @@ small JSON bodies) into the cassette, and `replay` materializes that snapshot to
|
|
|
340
340
|
`artifact_json` needs the JSON body inlined (small files); a hash-only (oversized) entry still satisfies
|
|
341
341
|
`file_exists` but not `artifact_json`. Without a manifest (older cassettes), they are **skipped** (loud).
|
|
342
342
|
A green `replay` re-confirms *record-time* artifacts, **not** that the current skill still produces them —
|
|
343
|
-
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).
|
|
344
346
|
|
|
345
347
|
**Egress + other filesystem** assertions (`no_delete_in_outputs`, `self_heal_ran`,
|
|
346
348
|
`transcript_no_host_path`, `egress_*`/`expect_denied`) are still **skipped** on `replay` — they only run on
|
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/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(),
|