cowork-harness 0.13.0 → 0.15.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,54 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.15.0] — 2026-06-25
10
+
11
+ ### Fixed
12
+
13
+ - **Staleness no longer masks a scoped skill's own drift behind a co-occurring shared change.** For a
14
+ `skills:`-scoped cassette, when BOTH the shared roots AND the scoped skill's own files changed, the
15
+ diagnosis previously reported only `shared root changed` and never the skill — the two buckets were
16
+ mutually exclusive (shared tested first). It now attributes drift per-bucket by the actual changed paths and
17
+ emits BOTH a `shared-root` and a `skill` finding when both moved, each naming its own files. The same
18
+ diagnosis (per-file `[N changed (…)]` detail and the `COWORK_HARNESS_DEBUG_SKILLHASH` hook) now also runs on
19
+ the `replay` lane, which previously had a separate, less-detailed copy. With `COWORK_HARNESS_AGENT_SCOPE=skill`
20
+ a changed `agents/<skill>.md` is attributed to that skill, matching the hash boundary.
21
+
22
+ ### Changed
23
+
24
+ - **`replay --fail-on-skill-drift` no longer fails on a `COWORK_HARNESS_GITSET` / `COWORK_HARNESS_AGENT_SCOPE`
25
+ flip.** A record-vs-verify mismatch in either setting is now classed `format` ("re-record under the same
26
+ mode") rather than misattributed to skill/shared drift, so it is a non-failing warning under
27
+ `--fail-on-skill-drift` (which targets skill-source drift only). It still fails under `--strict` and still
28
+ reds `verify-cassettes`. Previously the `replay` lane mislabeled such a flip as `shared-root`/`skill` and
29
+ failed the skill-drift gate.
30
+
31
+ ## [0.14.0] — 2026-06-25
32
+
33
+ ### Added
34
+
35
+ - **`replay` surfaces staleness + skipped assertions in JSON.** Each `--output-format json` result now carries
36
+ `staleness[]` (class-tagged: `baseline` / `skill` / `shared-root` / `format` / `unverifiable-baseline` /
37
+ `unverifiable-skill`) and `skippedAssertions` (`{full, partial}`), so a token-free CI gate can see a stale
38
+ cassette or live-only assertions it didn't evaluate WITHOUT the verdict changing — a stale but otherwise
39
+ passing replay stays `ok:true` by default. Previously these were stderr-only `::warning::` lines invisible
40
+ to a JSON consumer.
41
+ - **`replay --fail-on-skill-drift`.** A narrower release gate than `--strict`: fails only on skill-source drift
42
+ (`skill` / `shared-root` / `unverifiable-skill` — "can't verify the skill ⇒ not green"), while baseline /
43
+ format / environment-level staleness stays a non-failing warning. `--strict` remains the superset (fails on
44
+ every class).
45
+
46
+ ### Fixed
47
+
48
+ - **Linter no longer false-flags `requires_capabilities`.** `cowork-harness lint` warned `unknown-top-key` on
49
+ the valid scenario field `requires_capabilities` (its hand-maintained top-level-key list had drifted from the
50
+ schema). The list is now generated from the Zod `ScenarioObject` schema (like the assertion-key list), so it
51
+ can't drift; a parity test guards it.
52
+ - **SKILL.md capability pre-flight wording corrected.** It described `requires_capabilities` as skill-declared
53
+ and said the harness "warns before the run"; it is a **scenario field** and the harness **aborts before the
54
+ paid run (exit 3)** when the image omits a required capability (unless `allow_missing_capability: true`). The
55
+ authoritative `docs/scenario.md` was already correct.
56
+
9
57
  ## [0.13.0] — 2026-06-25
10
58
 
11
59
  ### 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.15.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
  }
@@ -22,7 +22,7 @@ import { jsonEnvelope } from "./envelope.js";
22
22
  import { parseArgs } from "../cli-args.js";
23
23
  import { resolveInputs } from "./inputs.js";
24
24
  import { realProbe } from "./doctor.js";
25
- import { hashSkillDirs, hashSharedOnly, computeContentSig, skillHashEntries, OS_JUNK_PATTERN } from "./skill-hash.js";
25
+ import { hashSkillDirs, hashSharedOnly, computeContentSig, skillHashEntries, OS_JUNK_PATTERN, agentSkillName } from "./skill-hash.js";
26
26
  import { computeVerdict } from "./verdict.js";
27
27
  import { redactJsonLine, redactText, redactStructural, loadRedactionPolicy } from "../redact.js";
28
28
  import { collectSecrets, scrubField } from "../secrets.js";
@@ -424,12 +424,8 @@ function debugSkillHashMismatch(cassette, cassetteDir, fp, live) {
424
424
  `contentSig ${sigVerdict}${junk ? ` · ${junk} OS-junk file(s) flagged above` : ""}. ` +
425
425
  `Note: this lists the CURRENT tree; a true per-file diff vs record needs the record-time set (re-record after excluding junk, then compare).\n`);
426
426
  }
427
- /** v5: diff two per-file manifests (recorded vs live) into an exact "what changed" SUMMARY — the actionable
428
- * upgrade over the bucket-level "something changed". Returns just the summary (no prefix/suffix), so the
429
- * caller can append it to the bucket/generic message (preserving the G-4 shared-vs-scoped semantic). Samples
430
- * up to 3 paths per category. Null when the manifests are equal (hashes differ but files don't — caller
431
- * falls back to its bucket message). */
432
- function diffFileSigs(recorded, live) {
427
+ /** v5: diff two per-file manifests (recorded vs live) into the exact changed/added/removed path lists. */
428
+ function diffFileSigsPaths(recorded, live) {
433
429
  const rec = new Map(recorded);
434
430
  const liv = new Map(live);
435
431
  const added = [];
@@ -444,26 +440,67 @@ function diffFileSigs(recorded, live) {
444
440
  for (const [p] of rec)
445
441
  if (!liv.has(p))
446
442
  removed.push(p);
447
- if (!added.length && !removed.length && !changed.length)
443
+ return { added, removed, changed };
444
+ }
445
+ /** Format a {@link FileSigDiff} into an actionable summary (samples up to 3 paths per category). Null when
446
+ * the diff is empty (hashes differ but files don't — a structure-only change; caller falls back to its
447
+ * bucket message). */
448
+ function summarizeFileSigDiff(diff) {
449
+ if (!diff.added.length && !diff.removed.length && !diff.changed.length)
448
450
  return null;
449
451
  const sample = (a) => `${a.slice(0, 3).join(", ")}${a.length > 3 ? `, +${a.length - 3} more` : ""}`;
450
452
  const parts = [];
451
- if (changed.length)
452
- parts.push(`${changed.length} changed (${sample(changed)})`);
453
- if (added.length)
454
- parts.push(`${added.length} added (${sample(added)})`);
455
- if (removed.length)
456
- parts.push(`${removed.length} removed (${sample(removed)})`);
453
+ if (diff.changed.length)
454
+ parts.push(`${diff.changed.length} changed (${sample(diff.changed)})`);
455
+ if (diff.added.length)
456
+ parts.push(`${diff.added.length} added (${sample(diff.added)})`);
457
+ if (diff.removed.length)
458
+ parts.push(`${diff.removed.length} removed (${sample(diff.removed)})`);
457
459
  return parts.join("; ");
458
460
  }
459
- /** B3 staleness GATE: recompute the fingerprint and report drift. Unlike `replayCassette` (which WARNS),
460
- * the gate treats an unresolvable skillHash as a failure — can't verify ⇒ not green. No fingerprint → nothing
461
- * to check (legacy cassette). */
462
- export function checkStaleness(cassette, cassetteDir) {
461
+ /** Convenience: diff + summarize in one call (the non-scoped/whole-tree caller wants only the summary). */
462
+ function diffFileSigs(recorded, live) {
463
+ return summarizeFileSigDiff(diffFileSigsPaths(recorded, live));
464
+ }
465
+ /** Finding 1 (G-4, path-accurate): partition a manifest diff into the shared-root bucket vs the
466
+ * skill-private bucket, EXACTLY mirroring `scopedAccept`/`sharedOnlyAccept` in skill-hash.ts so attribution
467
+ * matches the hash boundary. A path under `skills/<name>/` is skill-private when `<name>` is in scope; with
468
+ * agent-scoping ON, a skill-named `agents/<name>.md` is also skill-private. Everything else is shared. This
469
+ * lets `computeStaleness` emit a `skill` finding AND a `shared-root` finding when BOTH buckets drift, so a
470
+ * co-occurring shared change can no longer mask the skill's own drift (the original bug). */
471
+ function partitionDriftBuckets(diff, scope, scopeAgents) {
472
+ const shared = { added: [], removed: [], changed: [] };
473
+ const skill = { added: [], removed: [], changed: [] };
474
+ const isSkillPrivate = (relPath) => {
475
+ const parts = relPath.split("/");
476
+ if (parts[0] === "skills" && parts.length >= 2)
477
+ return scope.has(parts[1]);
478
+ if (scopeAgents) {
479
+ const an = agentSkillName(parts);
480
+ if (an !== null && scope.has(an))
481
+ return true;
482
+ }
483
+ return false;
484
+ };
485
+ for (const cat of ["added", "removed", "changed"])
486
+ for (const p of diff[cat])
487
+ (isSkillPrivate(p) ? skill : shared)[cat].push(p);
488
+ return { shared, skill };
489
+ }
490
+ /** The SINGLE staleness diagnosis (unifies what used to be two divergent copies: `checkStaleness` and the
491
+ * inline block in `replayCassette`). Recompute the fingerprint and report drift as class-tagged findings;
492
+ * each CALLER applies its own gate-vs-warn policy:
493
+ * - `verify-cassettes` / the re-record work-list go through the `checkStaleness` string adapter and treat
494
+ * ANY finding as "stale ⇒ re-record" (so `unverifiable-*` stays a hard fail there — can't verify ⇒ not
495
+ * green). The adapter MUST be class-blind (forward every finding) or that gate false-greens.
496
+ * - `replayCassette` consumes the findings directly: warn by default, `--strict` fails on all,
497
+ * `--fail-on-skill-drift` fails only on `SKILL_DRIFT_CLASSES`.
498
+ * No fingerprint → nothing to check (legacy cassette). */
499
+ export function computeStaleness(cassette, cassetteDir) {
463
500
  const fp = cassette.fingerprint;
464
501
  if (!fp)
465
502
  return [];
466
- const msgs = [];
503
+ const findings = [];
467
504
  let liveBaseline;
468
505
  try {
469
506
  liveBaseline = loadBaseline("latest").appVersion;
@@ -471,54 +508,114 @@ export function checkStaleness(cassette, cassetteDir) {
471
508
  catch {
472
509
  /* baseline not loadable */
473
510
  }
474
- // Gate mode: can't verify ⇒ not green. The cassette carries a baseline-of-record but we can't load the
475
- // current one to compare — a fail, not a silent skip (baselines ship with the package, so this is rare).
511
+ // The cassette carries a baseline-of-record but we can't load the current one to compare. Surfaced as
512
+ // `unverifiable-baseline` (env/platform, not skill drift): a non-failing warning on the default replay gate,
513
+ // but a hard fail for `verify-cassettes`/the work-list via the class-blind string adapter (can't verify ⇒
514
+ // not green). baselines ship with the package, so this is rare.
476
515
  if (liveBaseline === undefined)
477
- msgs.push("cannot load the latest baseline to verify staleness — run `cowork-harness sync` or ship baselines/ (can't verify ⇒ not green)");
516
+ findings.push({
517
+ class: "unverifiable-baseline",
518
+ message: "cannot load the latest baseline to verify staleness — run `cowork-harness sync` or ship baselines/ (env/platform, not skill drift; can't verify ⇒ not green)",
519
+ });
478
520
  else if (liveBaseline !== fp.baseline)
479
- msgs.push(`baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record`);
521
+ findings.push({ class: "baseline", message: `baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record` });
480
522
  if (fp.skillHash) {
481
523
  const live = buildFingerprint(cassette.scenario.session, fp.baseline, cassetteDir, cassette.scenario.skills);
482
524
  const recMode = fp.mode ?? "raw";
483
525
  const liveMode = live.mode ?? "raw";
484
526
  if (live.skillHash === undefined)
485
- msgs.push("skill dirs not resolvable from the cassette location — cannot verify staleness (gate fails: can't verify ⇒ not green)");
527
+ findings.push({
528
+ class: "unverifiable-skill",
529
+ message: "skill dirs not resolvable from the cassette location — cannot verify skill staleness (can't verify ⇒ not green)",
530
+ });
486
531
  else if (recMode !== liveMode)
487
- // Phase C: a hash from a different boundary mode is not comparable — don't emit a misleading content diff.
488
- msgs.push(`recorded in '${recMode}' file-set mode, verifying in '${liveMode}' (COWORK_HARNESS_GITSET) — re-record under the same mode`);
532
+ // Phase C: a hash from a different boundary mode is not comparable — re-record, don't emit a misleading
533
+ // content diff. Classed `format` (not skill drift): a mode flip is an env/config mismatch, not source drift.
534
+ findings.push({
535
+ class: "format",
536
+ message: `recorded in '${recMode}' file-set mode, verifying in '${liveMode}' (COWORK_HARNESS_GITSET) — re-record under the same mode`,
537
+ });
489
538
  else if ((fp.agentScope ?? "off") !== (live.agentScope ?? "off"))
490
539
  // Agent-scoping flip (COWORK_HARNESS_AGENT_SCOPE): the scoped hash covers a different file set, so it's
491
540
  // not comparable — re-record under the same setting (mirrors the GITSET mode flip above).
492
- msgs.push(`recorded with agent-scope '${fp.agentScope ?? "off"}', verifying with '${live.agentScope ?? "off"}' (COWORK_HARNESS_AGENT_SCOPE) — re-record under the same setting`);
541
+ findings.push({
542
+ class: "format",
543
+ message: `recorded with agent-scope '${fp.agentScope ?? "off"}', verifying with '${live.agentScope ?? "off"}' (COWORK_HARNESS_AGENT_SCOPE) — re-record under the same setting`,
544
+ });
493
545
  else if (live.skillHash !== fp.skillHash) {
494
- debugSkillHashMismatch(cassette, cassetteDir, fp, live); // H9: surface WHICH files drifted (or a hint to enable it)
546
+ debugSkillHashMismatch(cassette, cassetteDir ?? "", fp, live); // H9 (D2): surface WHICH files drifted
495
547
  const recordedVersion = cassette.cassetteVersion ?? 0;
496
- // v5: name the EXACT changed/added/removed file(s) from the per-file manifest, APPENDED to the
497
- // bucket/generic message so the G-4 shared-vs-scoped semantic is preserved AND the file is named.
498
- const summary = fp.fileSigs && live.fileSigs ? diffFileSigs(fp.fileSigs, live.fileSigs) : null;
499
- const detail = summary ? ` [${summary}]` : "";
500
548
  if (recordedVersion < CASSETTE_VERSION) {
501
- msgs.push(`recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`);
549
+ findings.push({
550
+ class: "format",
551
+ message: `recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`,
552
+ });
502
553
  }
503
554
  else if (fp.sharedHash !== undefined && live.sharedHash !== undefined) {
504
- // G-4: bucket-level diagnosis — which component of the scoped hash changed?
505
- const scope = fp.skillScope.map((s) => `skills/${s}`).join(", ");
506
- if (live.sharedHash !== fp.sharedHash) {
507
- msgs.push(`shared root changed since record (scope: ${scope})${detail} — re-record`);
555
+ // G-4 + Finding 1: attribute drift to the shared and/or skill bucket(s). D6: `skillScope` is always
556
+ // non-empty when `sharedHash` is set (single assignment site under the same guard in buildFingerprint);
557
+ // the `?? []` is a defensive guard only — the on-disk cassette shape is not schema-validated.
558
+ const scopeArr = fp.skillScope ?? [];
559
+ const scopeLabel = scopeArr.map((s) => `skills/${s}`).join(", ") || "skill";
560
+ const scopeSet = new Set(scopeArr);
561
+ const scopeAgents = (live.agentScope ?? "off") === "skill";
562
+ if (fp.fileSigs && live.fileSigs) {
563
+ // Path-accurate: emit a finding per bucket that ACTUALLY changed, so a co-occurring shared change can
564
+ // never mask the skill's own drift (the original bug) and vice-versa.
565
+ const { shared, skill } = partitionDriftBuckets(diffFileSigsPaths(fp.fileSigs, live.fileSigs), scopeSet, scopeAgents);
566
+ const sharedSummary = summarizeFileSigDiff(shared);
567
+ const skillSummary = summarizeFileSigDiff(skill);
568
+ if (sharedSummary)
569
+ findings.push({
570
+ class: "shared-root",
571
+ message: `shared root changed since record (scope: ${scopeLabel}) [${sharedSummary}] — re-record`,
572
+ });
573
+ if (skillSummary)
574
+ findings.push({ class: "skill", message: `${scopeLabel} changed since record [${skillSummary}] — re-record` });
575
+ if (!sharedSummary && !skillSummary) {
576
+ // Hashes differ but the per-file manifest shows no path change (structure-only: an empty dir or a
577
+ // symlink re-point). Fall back to the hash buckets; emit BOTH classes if the shared hash moved so
578
+ // neither bucket is masked.
579
+ if (live.sharedHash !== fp.sharedHash)
580
+ findings.push({ class: "shared-root", message: `shared root changed since record (scope: ${scopeLabel}) — re-record` });
581
+ findings.push({ class: "skill", message: `${scopeLabel} changed since record — re-record` });
582
+ }
508
583
  }
509
584
  else {
510
- msgs.push(`${scope} changed since record${detail} — re-record`);
585
+ // Pre-detail cassette (no per-file manifest, e.g. > MANIFEST_MAX_FILES): can't isolate the bucket.
586
+ // Emit BOTH classes when the shared hash moved so the skill's own drift is never masked (over-warns,
587
+ // but the gate is already red and both classes are in the fail set).
588
+ if (live.sharedHash !== fp.sharedHash) {
589
+ findings.push({ class: "shared-root", message: `shared root changed since record (scope: ${scopeLabel}) — re-record` });
590
+ findings.push({
591
+ class: "skill",
592
+ message: `${scopeLabel} may also have changed since record (no per-file manifest to isolate) — re-record`,
593
+ });
594
+ }
595
+ else {
596
+ findings.push({ class: "skill", message: `${scopeLabel} changed since record — re-record` });
597
+ }
511
598
  }
512
599
  }
513
- else if (summary) {
514
- msgs.push(`skill files changed since record — ${summary} — re-record`);
515
- }
516
600
  else {
517
- msgs.push("local skill/plugin dir contents changed since record — re-record");
601
+ // Non-scoped (whole-tree) cassette: name the changed files when the per-file manifest is present (D5),
602
+ // else the generic fallback.
603
+ const summary = fp.fileSigs && live.fileSigs ? diffFileSigs(fp.fileSigs, live.fileSigs) : null;
604
+ if (summary)
605
+ findings.push({ class: "skill", message: `skill files changed since record — ${summary} — re-record` });
606
+ else
607
+ findings.push({ class: "skill", message: "local skill/plugin dir contents changed since record — re-record" });
518
608
  }
519
609
  }
520
610
  }
521
- return msgs;
611
+ return findings;
612
+ }
613
+ /** B3 staleness GATE adapter for the string consumers (`verify-cassettes`, the re-record work-list). Returns
614
+ * the unified findings as plain reason strings. MUST stay class-blind (forward EVERY finding) so an
615
+ * `unverifiable-baseline` / `unverifiable-skill` still reds those gates — filtering a class here would
616
+ * false-green `verify-cassettes` on a cassette it can't verify. */
617
+ export function checkStaleness(cassette, cassetteDir) {
618
+ return computeStaleness(cassette, cassetteDir).map((f) => f.message);
522
619
  }
523
620
  /** A minimal RunRecord for a truncated-cassette replay — empty collections so downstream evaluate()/the
524
621
  * mismatch loops don't NPE; result:"error" because the cassette could not be driven to completion. */
@@ -1520,7 +1617,7 @@ export async function cmdReplay(args) {
1520
1617
  try {
1521
1618
  p = parseArgs(args, {
1522
1619
  // --quiet/--verbose accepted for flag consistency but currently no-op in replay (renderer plan is fixed).
1523
- booleans: ["--strict", "--quiet", "--verbose"],
1620
+ booleans: ["--strict", "--fail-on-skill-drift", "--quiet", "--verbose"],
1524
1621
  values: ["--output-format"],
1525
1622
  enums: { "--output-format": ["text", "json"] },
1526
1623
  aliases: { "-q": "--quiet", "-V": "--verbose" },
@@ -1532,7 +1629,7 @@ export async function cmdReplay(args) {
1532
1629
  }
1533
1630
  const target = p.positionals[0];
1534
1631
  if (!target) {
1535
- log("usage: replay <file.cassette.json | dir/> [--strict] [--output-format text|json]");
1632
+ log("usage: replay <file.cassette.json | dir/> [--strict] [--fail-on-skill-drift] [--output-format text|json]");
1536
1633
  return process.exit(2);
1537
1634
  }
1538
1635
  if (p.positionals.length > 1) {
@@ -1540,7 +1637,10 @@ export async function cmdReplay(args) {
1540
1637
  return process.exit(2);
1541
1638
  }
1542
1639
  const json = p.options["--output-format"] === "json";
1543
- const strict = p.flags["--strict"] ?? false; // #1b: escalate staleness warnings to failures (release gate)
1640
+ const strict = p.flags["--strict"] ?? false; // #1b: escalate ALL staleness findings to failures (release gate)
1641
+ const failOnSkillDrift = p.flags["--fail-on-skill-drift"] ?? false; // narrower gate: only skill-source drift fails
1642
+ if (strict && failOnSkillDrift)
1643
+ 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
1644
  const resolved = resolveInputs(target, ".cassette.json");
1545
1645
  if ("error" in resolved) {
1546
1646
  log(`replay: ${resolved.error}`);
@@ -1564,7 +1664,7 @@ export async function cmdReplay(args) {
1564
1664
  // fine" (false-green by abort). Wrap per-file: turn an unexpected throw into a tallied error result.
1565
1665
  let result;
1566
1666
  try {
1567
- result = await replayCassette(rc.cassette, renderer ? [renderer] : [], { strict, cassetteDir: dirname(f) });
1667
+ result = await replayCassette(rc.cassette, renderer ? [renderer] : [], { strict, failOnSkillDrift, cassetteDir: dirname(f) });
1568
1668
  }
1569
1669
  catch (e) {
1570
1670
  log(`replay: ${f}: ${e?.message ?? String(e)}`);
@@ -1860,7 +1960,10 @@ export function cmdRehash(args) {
1860
1960
  }
1861
1961
  /** Replay a cassette through Run and re-evaluate the content assertions. With a `cassette.artifacts`
1862
1962
  * 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. */
1963
+ * the materialized snapshot. `opts.strict` (#1b) escalates ALL staleness findings to failing assertions;
1964
+ * `opts.failOnSkillDrift` escalates only the skill-source classes (`skill`/`shared-root`/`unverifiable-skill`),
1965
+ * leaving baseline drift a non-failing warning. Either way the findings are always surfaced in
1966
+ * `RunResult.staleness` for JSON consumers. */
1864
1967
  export async function replayCassette(cassette, hooks = [], opts = {}) {
1865
1968
  // Cassette format version: ABSENT = legacy (0); a FUTURE version means this harness may misread fields
1866
1969
  // it doesn't know about. in strict mode this is a hard failure (future semantics may not be
@@ -1884,59 +1987,16 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
1884
1987
  // unchanged (frozen-structure limit). The skill-hash recompute needs the local skill dirs to be resolvable
1885
1988
  // from the cassette's session path; when they aren't (a moved/committed cassette), we say so rather than
1886
1989
  // silently skipping.
1887
- const staleness = [];
1888
- if (cassette.fingerprint) {
1889
- const fp = cassette.fingerprint;
1890
- let liveBaseline;
1891
- let baselineLoadFailed = false;
1892
- try {
1893
- liveBaseline = loadBaseline("latest").appVersion;
1894
- }
1895
- catch {
1896
- 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
- }
1903
- }
1904
- if (!baselineLoadFailed && liveBaseline && liveBaseline !== fp.baseline)
1905
- staleness.push(`baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record before trusting this replay`);
1906
- if (fp.skillHash) {
1907
- const live = buildFingerprint(cassette.scenario.session, fp.baseline, opts.cassetteDir, cassette.scenario.skills);
1908
- 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
- }
1917
- }
1918
- else if (live.skillHash !== fp.skillHash) {
1919
- const recordedVersion = cassette.cassetteVersion ?? 0;
1920
- if (recordedVersion < CASSETTE_VERSION) {
1921
- staleness.push(`recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`);
1922
- }
1923
- else if (fp.sharedHash !== undefined && live.sharedHash !== undefined) {
1924
- const scope = fp.skillScope?.length ? fp.skillScope.map((s) => `skills/${s}`).join(", ") : "skill";
1925
- if (live.sharedHash !== fp.sharedHash) {
1926
- staleness.push(`shared root changed since record (scope: ${scope}) — re-record before trusting this replay`);
1927
- }
1928
- else {
1929
- staleness.push(`${scope} changed since record — re-record before trusting this replay`);
1930
- }
1931
- }
1932
- else {
1933
- staleness.push("local skill/plugin dir contents changed since record — re-record before trusting this replay");
1934
- }
1935
- }
1936
- }
1937
- for (const s of staleness)
1938
- warn(`::warning:: [replay] cassette stale: ${s}\n`);
1939
- }
1990
+ // Findings are surfaced UNCONDITIONALLY (class-tagged) in JSON (RunResult.staleness) even on the default
1991
+ // gate — a token-free consumer can distinguish "verified clean" from "couldn't verify" (the `unverifiable-*`
1992
+ // classes) WITHOUT the verdict changing. The `--strict` / `--fail-on-skill-drift` gates below are the ONLY
1993
+ // place a finding becomes a failing assertion. The single `warn()` loop is the lone stderr emitter — no
1994
+ // per-branch `warn()`, so a non-strict run never double-warns one cause. Uses the SHARED `computeStaleness`
1995
+ // (no longer a forked copy), so it inherits the per-file detail, the `debugSkillHashMismatch` hook, the
1996
+ // GITSET/agent-scope flip buckets, and the both-buckets attribution fix for free.
1997
+ const staleness = computeStaleness(cassette, opts.cassetteDir);
1998
+ for (const s of staleness)
1999
+ warn(`::warning:: [replay] cassette stale: ${s.message}\n`);
1940
2000
  // §2.5 backward compat: warn loudly when controlOut is absent so the user knows question/gate
1941
2001
  // assertions are being EXCLUDED (not vacuously evaluated) from this run.
1942
2002
  if (!session.hasControlOut) {
@@ -2085,10 +2145,17 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
2085
2145
  toolResultTexts: rec.toolResults.map((r) => r.assertText ?? r.text),
2086
2146
  truncatedPaths: replayTruncatedPaths,
2087
2147
  });
2088
- // #1b: under --strict, a staleness mismatch is a failing assertion (non-zero exit), not just a warning.
2148
+ // #1b: under --strict, EVERY staleness finding becomes a failing assertion (non-zero exit), not just a
2149
+ // warning. --fail-on-skill-drift is the narrower gate: only the skill-source classes fail (incl.
2150
+ // `unverifiable-skill` — can't verify skill staleness ⇒ not green), while baseline / format / env-level
2151
+ // findings stay non-failing. `else if` makes --strict the superset when both are passed.
2152
+ const SKILL_DRIFT_CLASSES = new Set(["skill", "shared-root", "unverifiable-skill"]);
2089
2153
  if (opts.strict)
2090
2154
  for (const s of staleness)
2091
- assertions.push({ assertion: {}, pass: false, message: `cassette stale (--strict): ${s}` });
2155
+ assertions.push({ assertion: {}, pass: false, message: `cassette stale (--strict): ${s.message}` });
2156
+ else if (opts.failOnSkillDrift)
2157
+ for (const s of staleness.filter((s) => SKILL_DRIFT_CLASSES.has(s.class)))
2158
+ assertions.push({ assertion: {}, pass: false, message: `skill-source drift (--fail-on-skill-drift): ${s.message}` });
2092
2159
  // future cassette version — hard failure under --strict (forward semantics may not be
2093
2160
  // correctly interpreted here, so a green replay would be a false-green).
2094
2161
  if (futureVersionMsg && opts.strict)
@@ -2175,6 +2242,10 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
2175
2242
  subagents: rec.subagents,
2176
2243
  unanswered: rec.unanswered,
2177
2244
  outDir: "(replay)",
2245
+ // Class-tagged staleness + skip counts, surfaced to JSON callers (the gate decision already happened
2246
+ // above via failing assertions; these fields are pure data so a green stays green by default).
2247
+ staleness: staleness.length ? staleness : undefined,
2248
+ skippedAssertions: { full: fullSkipCount, partial: partialSkipCount },
2178
2249
  // A cassette freezes the answer path: the replay itself is deterministic regardless of how the
2179
2250
  // original run was answered. Always explicit (never undefined) so renderer.ts:146 treats it
2180
2251
  // correctly — undefined would silently render as "deterministic" (#47 C1 [review-2]).
@@ -211,8 +211,9 @@ function agentScopeEnabled() {
211
211
  }
212
212
  /** Map the first path segment under `agents/` to the skill name it would belong to: a FILE strips its
213
213
  * extension (`agents/cap-table.md` → `cap-table`), a DIR is used as-is (`agents/cap-table/x.md` → `cap-table`).
214
- * Returns null for non-`agents/` paths or a bare `agents` marker. */
215
- function agentSkillName(parts) {
214
+ * Returns null for non-`agents/` paths or a bare `agents` marker. Exported so staleness attribution can
215
+ * classify a changed `agents/<name>.md` exactly as the hash walk does (agent-scoped → skill-private). */
216
+ export function agentSkillName(parts) {
216
217
  if (parts[0] !== "agents" || parts.length < 2)
217
218
  return null;
218
219
  return parts.length === 2 ? parts[1].replace(/\.[^.]+$/, "") : parts[1];
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
 
@@ -320,9 +321,12 @@ possible.
320
321
  - **`recorded under an older hash format (vN → vM)`** — format upgrade; re-record once and the message
321
322
  goes away. (Cassettes recorded before format **v6** all need one re-record — see the boundary note below.)
322
323
  - **`skill files changed since record — N changed (path, …)`** — the **exact** changed/added/removed file(s),
323
- from the per-file manifest (`fileSigs`). For a scoped cassette it is prefixed with the bucket diagnosis
324
- (`shared root changed (scope: skills/x) [N changed (…)]`) so you still see whether the change was in your
325
- scoped skill or a shared dependency.
324
+ from the per-file manifest (`fileSigs`). For a scoped cassette the drift is attributed **per bucket** by the
325
+ actual changed paths: a `shared root changed (scope: skills/x) [N changed (…)]` message for shared-dependency
326
+ changes and a `skills/x changed since record [N changed (…)]` message for the scoped skill's own files. When
327
+ **both** buckets change you get **both** messages — a co-occurring shared change no longer masks the skill's
328
+ own drift. (With `COWORK_HARNESS_AGENT_SCOPE=skill`, a changed `agents/<x>.md` is attributed to skill `x`,
329
+ matching the hash boundary.)
326
330
  - **`recorded in '<mode>' file-set mode, verifying in '<mode>'`** — the staleness boundary differs between
327
331
  record and verify (e.g. recorded in a git work tree but verified from a non-repo copy); the hashes are not
328
332
  comparable, so re-record under the same mode.
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.15.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(),