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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "GENERATED from the Zod Assertion schema (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
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
- # every valid top-level scenario key
120
- TOP_LEVEL_KEYS = {
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 a stale one; exits on the worst verdict) | **token-free, Docker-free CI** from a once-recorded run |
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.13.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
+ 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 a stale cassette instead of warning
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. Accepts a string
4
- * that already carries the prefix (written as-is) so migrating existing `::warning:: …` writes is a
5
- * no-op on content. Uses `process.stderr.write` (the mechanism the warnings always used) so test spies
6
- * on it still observe warnings and the output is byte-identical.
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 = message.startsWith("::warning::") ? message : `::warning:: ${message}`;
10
+ const line = /^::(warning|notice|error)::/.test(message) ? message : `::warning:: ${message}`;
10
11
  process.stderr.write(line.endsWith("\n") ? line : line + "\n");
11
12
  }
@@ -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 warnings to failures (release gate)
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 warnings to failing assertions. */
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
- if (opts.strict) {
1898
- staleness.push("baseline could not be loaded — cannot verify staleness (--strict: treating as stale)");
1899
- }
1900
- else {
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(`baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record before trusting this replay`);
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
- // under --strict, unresolvable skill dirs are a failure (can't verify ⇒ not green).
1910
- // Without --strict, warn but continue (the baseline check still applies).
1911
- if (opts.strict) {
1912
- staleness.push("skill dirs not resolvable from this cassette location — cannot verify skill staleness (--strict: treating as stale)");
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(`recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`);
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(`shared root changed since record (scope: ${scope}) — re-record before trusting this replay`);
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("local skill/plugin dir contents changed since record — re-record before trusting this replay");
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, a staleness mismatch is a failing assertion (non-zero exit), not just a warning.
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 the skill/baseline
215
- drifted.
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 `--strict` staleness fingerprint warns when the skill/baseline drifted).
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.13.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",
@@ -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 assertion-key list, derived from the Zod `Assertion` schema (the same source
23
- * `assert --list` reads). Generating it keeps `scenario.py`'s unknown-key check from drifting. */
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 Assertion schema (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
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(),