cowork-harness 2.2.0 → 2.3.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.
Files changed (33) hide show
  1. package/.claude/skills/cowork-harness/SKILL.md +31 -12
  2. package/.claude/skills/cowork-harness/references/ci-recipe.md +15 -13
  3. package/.claude/skills/cowork-harness/references/critique.md +1 -1
  4. package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
  5. package/.claude/skills/cowork-harness/references/scenario-schema.md +14 -12
  6. package/.claude/skills/cowork-harness/references/task-recipes.md +2 -2
  7. package/.claude/skills/cowork-harness/scripts/assertion-keys.json +1 -0
  8. package/.claude/skills/cowork-harness/scripts/scenario.py +8 -1
  9. package/CHANGELOG.md +236 -0
  10. package/DESIGN.md +3 -3
  11. package/README.md +41 -11
  12. package/baselines/desktop-1.37937.1.json +816 -0
  13. package/baselines/prompts/cowork-system-prompt-fingerprints.json +27 -0
  14. package/dist/assert.js +77 -0
  15. package/dist/cli.js +15 -4
  16. package/dist/run/cassette.js +255 -77
  17. package/dist/run/execute.js +59 -0
  18. package/dist/run/trace-view.js +39 -1
  19. package/dist/sync/cowork-sync.js +207 -8
  20. package/dist/types.js +39 -9
  21. package/docs/README.md +3 -0
  22. package/docs/cassette.md +36 -11
  23. package/docs/debugging.md +25 -0
  24. package/docs/fidelity-gaps.md +5 -1
  25. package/docs/maintenance.md +15 -1
  26. package/docs/scenario.md +13 -10
  27. package/examples/replays/README.md +1 -1
  28. package/examples/replays/example-multiselect-gate.cassette.json +41 -46
  29. package/examples/replays/example-pdf-skill.cassette.json +90 -90
  30. package/examples/replays/hostloop-computer-links.cassette.json +62 -68
  31. package/llms.txt +3 -1
  32. package/package.json +1 -1
  33. package/schema/scenario.schema.json +28 -9
@@ -2718,8 +2718,10 @@ export function hostInventoryPreflight(scenario, plannedCassettePath, allowed) {
2718
2718
  const blocked = HOSTLOOP_ONLY_KEYS.filter((k) => (scenario.assert ?? []).some((a) => a[k] !== undefined));
2719
2719
  const fix = blocked.length
2720
2720
  ? ` Fix: this scenario asserts ${blocked.join(", ")}, which only evaluate at hostloop — 'container' is not ` +
2721
- `available to it. Audit the session (personal MCP servers, plugins, account metadata) and re-run with ` +
2722
- `--allow-host-inventory-fixture once you're satisfied the recording carries none.\n`
2721
+ `available to it. Re-run with --allow-host-inventory-fixture to get past THIS check — you do not have ` +
2722
+ `to audit the session by hand: the finished recording is still scanned, and a real finding still ` +
2723
+ `refuses the write and quarantines it (--allow-host-inventory-findings is the separate consent to ` +
2724
+ `write a flagged one).\n`
2723
2725
  : ` Fix: record at 'container' fidelity (sealed, HOME=/tmp) — it inherits nothing from this machine, so the ` +
2724
2726
  `cassette stays committable AND verifiable.\n`;
2725
2727
  return {
@@ -2731,7 +2733,10 @@ export function hostInventoryPreflight(scenario, plannedCassettePath, allowed) {
2731
2733
  `relative to its own directory, so one written outside the tree can never resolve them again — ` +
2732
2734
  `verify-cassettes reports it 'unverifiable' for staleness (can't verify ⇒ not green) and only a ` +
2733
2735
  `re-record recovers.\n` +
2734
- ` Override with --allow-host-inventory-fixture if this session has no personal MCP servers or plugins.`,
2736
+ ` Override with --allow-host-inventory-fixture to proceed to the recording. That is a bypass of THIS ` +
2737
+ `check only: the finished cassette is still scanned for real host-inventory findings before it is ` +
2738
+ `written, and a finding still refuses the write and quarantines the recording. You do not have to ` +
2739
+ `audit the session by hand to pass this flag — the scan is the actual gate.`,
2735
2740
  };
2736
2741
  }
2737
2742
  export function redactionPreflightMessage(items) {
@@ -2847,6 +2852,7 @@ export const RECORD_BOOLEAN_FLAGS = [
2847
2852
  "--dry-run",
2848
2853
  "--decider-llm",
2849
2854
  "--allow-host-inventory-fixture",
2855
+ "--allow-host-inventory-findings",
2850
2856
  ];
2851
2857
  export const RECORD_VALUE_FLAGS = [
2852
2858
  "--out",
@@ -2885,8 +2891,10 @@ export const RECORD_ALLOWLIST = [
2885
2891
  // `replay` and `verify-cassettes` are unified onto (a) below, replacing the two independently-drifted
2886
2892
  // copies each one had (cli.ts's was already a superset for `replay`; `verify-cassettes`' two copies had
2887
2893
  // textually diverged --margins prose — the cli.ts wording is kept as the single source).
2888
- export const RECORD_USAGE = "usage: record <scenario.yaml | dir/> [--out <file>] [--output-format text|json] [--rerecord-stale] [--from-embedded] [--force] [--no-redact] [--allow-failing] [--max-artifact-bytes <n>] [--dry-run] [--concurrency <N>] [--max-budget-usd <x>] [--allow-host-inventory-fixture]\n" +
2889
- " --allow-host-inventory-fixture: proceed when recording at protocol/hostloop into a repo-visible path. Those tiers inherit the host env, so the cassette would freeze THIS machine's MCP servers/agents/account into a committed fixture; the record is refused by default. Use only when the session has no personal MCP servers or plugins.\n" +
2894
+ export const RECORD_USAGE = "usage: record <scenario.yaml | dir/> [--out <file>] [--output-format text|json] [--rerecord-stale] [--from-embedded] [--force] [--no-redact] [--allow-failing] [--max-artifact-bytes <n>] [--dry-run] [--concurrency <N>] [--max-budget-usd <x>] [--allow-host-inventory-fixture] [--allow-host-inventory-findings]\n" +
2895
+ " --allow-host-inventory-fixture: proceed PAST THE PRE-FLIGHT when recording at protocol/hostloop into a repo-visible path. Those tiers inherit the host env, so the cassette could freeze THIS machine's MCP servers/agents/account into a committed fixture; the record is refused by default. This bypasses that pre-flight only — the finished recording is still scanned, and a real finding still refuses the write and quarantines it.\n" +
2896
+ " --dry-run: resolve and CHECK without recording. A single scenario file runs every pre-spend refusal the real record runs (prompt policy, assert contradictions, host-inventory, slug collision) and refuses identically — same --out, same flags, so the verdict is binding. A DIRECTORY reports the path-dependent ones as advisory 'would-refuse'/'would-warn' notes instead, labelled by verdict kind (a dir target takes no --out, so the destination is a guess), and gates only on the path-independent ones.\n" +
2897
+ " --allow-host-inventory-findings: write a recording the scan DID flag. The separate, louder decision; needed only when the captured inventory is genuinely part of the fixture.\n" +
2890
2898
  " --concurrency <N>: record a dir/ batch (or --rerecord-stale) N at a time (default 1, max 8). Runs are fully isolated; the bound is for Docker address pool + API rate limits.\n" +
2891
2899
  " --max-budget-usd <x>: refuse before spending if prior-run history says this scenario (or, on a batch, the whole batch) has cost more than x.\n" +
2892
2900
  " At --concurrency 1 a running total also stops the batch once x is reached; above that it is a pre-flight estimate only.\n" +
@@ -2929,6 +2937,7 @@ export async function cmdRecord(args) {
2929
2937
  }
2930
2938
  const noRedact = p.flags["--no-redact"] ?? false;
2931
2939
  const allowHostInventoryFixture = p.flags["--allow-host-inventory-fixture"] ?? false;
2940
+ const allowHostInventoryFindings = p.flags["--allow-host-inventory-findings"] ?? false;
2932
2941
  if (noRedact)
2933
2942
  log("record: --no-redact — content redaction is OFF; the cassette is written verbatim, so ensure inputs are synthetic.");
2934
2943
  const allowFailing = p.flags["--allow-failing"] ?? false;
@@ -3048,6 +3057,15 @@ export async function cmdRecord(args) {
3048
3057
  // one scenario-level refusal but not its sibling.
3049
3058
  const parsedNames = [];
3050
3059
  const refusals = [];
3060
+ // Path-dependent verdicts are ADVISORY on this arm, never refusals. Measured on a real consumer:
3061
+ // running the host-inventory pre-flight here against `defaultCassettePath(sc.name)` refused 26 of 27
3062
+ // scenarios that the real record accepts — because their CI dry-runs the directory from the repo root
3063
+ // while the real record writes elsewhere with `--out`, and decides the override PER SCENARIO from the
3064
+ // filesystem (`[ -f cassettes/$n… ] || --allow-host-inventory-fixture`). This arm knows neither the
3065
+ // `--out` path (dir targets reject it) nor the per-item flags, so its verdict is a GUESS — and a guess
3066
+ // must not gate. A preview that refuses what the real path allows is worse than one that stays quiet:
3067
+ // it teaches operators to stop trusting the preview.
3068
+ const notes = [];
3051
3069
  for (const f of disc.scenarios) {
3052
3070
  let sc;
3053
3071
  try {
@@ -3057,10 +3075,32 @@ export async function cmdRecord(args) {
3057
3075
  continue; // already classified as `broken` by discoverScenarios
3058
3076
  }
3059
3077
  parsedNames.push(sc.name);
3078
+ // Path-INDEPENDENT: these are the real verdicts on any path, so they still refuse.
3060
3079
  const why = promptPolicyRejection(sc) ?? assertContradiction(sc);
3061
3080
  if (why)
3062
3081
  refusals.push({ file: f, message: why });
3082
+ // Path-dependent: reported, never gating. `preSpendVerdicts` re-runs promptPolicyRejection, which is
3083
+ // already covered above — drop that one here rather than reporting it twice.
3084
+ for (const v of preSpendVerdicts(sc, defaultCassettePath(sc.name), {
3085
+ allowHostInventoryFixture,
3086
+ scenarioSourceFile: f,
3087
+ })) {
3088
+ if (v.message === why)
3089
+ continue;
3090
+ // Carry the VERDICT KIND, not just the message. Labelling every note "would-refuse" was a false
3091
+ // claim on the one arm whose design principle is "do not assert what you cannot know":
3092
+ // `cassettePortabilityPreflight` never returns `refuse` at all (only `ok`/`warn`), and
3093
+ // `hostInventoryPreflight` returns `warn` whenever the target cassette already exists — which is
3094
+ // every re-record corpus sitting at the default path. Those were announced as refusals the real
3095
+ // record would make, and it would not.
3096
+ notes.push({ file: f, message: v.message, kind: v.kind });
3097
+ }
3063
3098
  }
3099
+ // The real dir-batch refuses this before its first spawn (see duplicateCassetteTargets). It is
3100
+ // path-independent on both arms — a dir target rejects `--out` — so the preview refuses too rather
3101
+ // than merely noting it.
3102
+ for (const d of duplicateCassetteTargets(disc.scenarios))
3103
+ refusals.push({ file: d.split(" ↔ ")[0], message: `two scenarios share a cassette output path: ${d}` });
3064
3104
  // Free by construction (a run-history lookup), so it is reported whether or not a cap was passed.
3065
3105
  const estimate = estimateBatchCost(parsedNames);
3066
3106
  if (asJson) {
@@ -3071,6 +3111,9 @@ export async function cmdRecord(args) {
3071
3111
  skipped: disc.skipped,
3072
3112
  broken: disc.broken,
3073
3113
  refusals,
3114
+ // Advisory; deliberately a separate key from `refusals` so automation cannot mistake a guess
3115
+ // for a verdict, and does not gate on it.
3116
+ notes,
3074
3117
  estimatedCostUsd: estimate.known,
3075
3118
  unpricedScenarios: estimate.unpriced,
3076
3119
  // The basis for `estimatedCostUsd`, so automation need not treat it as a bound. It is
@@ -3089,6 +3132,22 @@ export async function cmdRecord(args) {
3089
3132
  // loud on failure" and must survive it, exactly as `broken:` does.
3090
3133
  for (const r of refusals)
3091
3134
  log(`✗ refused: ${r.file}: ${r.message}`);
3135
+ // Advisory only, and SUPPRESSED BY --quiet: unlike a refusal, a note is part of the preview, and a
3136
+ // batch of 26 would otherwise bury a real refusal in a CI log that asked for quiet. (Measured on a
3137
+ // real consumer: their `--dry-run --quiet` CI step produces exactly that many.)
3138
+ //
3139
+ // The underlying messages are written for the REAL path, so they say "refusing". Prefixed here to
3140
+ // say what this arm actually knows — otherwise the reader sees "refusing" beside exit 0 and cannot
3141
+ // tell which one is true.
3142
+ if (!quiet) {
3143
+ for (const n of notes)
3144
+ log(`⚠ would-${n.kind === "warn" ? "warn" : "refuse"} (advisory): ${n.file}: ${n.message}`);
3145
+ if (notes.length)
3146
+ log(` ↑ ADVISORY, not this run's verdict: computed against the DEFAULT cassette path because a ` +
3147
+ `directory target takes no --out. A real record's --out/--force/--allow-host-inventory-fixture ` +
3148
+ `may change every one of them, so they do NOT affect this exit code. Dry-run a single scenario ` +
3149
+ `file with the real flags to get a binding answer.`);
3150
+ }
3092
3151
  }
3093
3152
  if (disc.scenarios.length === 0) {
3094
3153
  if (disc.broken.length === 0) {
@@ -3131,17 +3190,36 @@ export async function cmdRecord(args) {
3131
3190
  catch (e) {
3132
3191
  return fail("record", "usage", `record --dry-run: cannot parse scenario: ${e.message}`, undefined, asJson);
3133
3192
  }
3134
- const promptReject = promptPolicyRejection(scenario);
3135
- if (promptReject)
3136
- return fail("record", "usage", promptReject, undefined, asJson);
3137
- // Same rule as promptPolicyRejection directly above, and as the budget gate below: a refusal the
3138
- // real `record` would raise has to raise here too, or this preview is false. Pure and spend-free.
3193
+ // `assertContradiction` is NOT in preSpendVerdicts: on the real path it fires from inside
3194
+ // `executeScenario` (execute.ts), which every lane funnels through — including library callers that
3195
+ // never touch this file. Checked here explicitly so the preview matches.
3139
3196
  const contradiction = assertContradiction(scenario);
3140
3197
  if (contradiction)
3141
3198
  return fail("record", "usage", contradiction, undefined, asJson);
3142
3199
  // mirror the EXACT default cassette path recordScenarioObject uses (slugForPath via the shared
3143
3200
  // defaultCassettePath helper) so a name with spaces/separators reports the same path it writes.
3144
3201
  const cassettePath = p.options["--out"] ?? defaultCassettePath(scenario.name);
3202
+ // THE SHARED BLOCK, on the arm that can preview it faithfully. This arm knows the exact `--out` path
3203
+ // and the exact flags the real record will get, so its verdicts ARE the real ones — it refuses.
3204
+ // (The BATCH arm cannot; see the advisory note there.) `target` IS the scenario source here, so the
3205
+ // portability reference root resolves exactly as it will on the real record.
3206
+ //
3207
+ // Computed BEFORE the payload envelope below: emitting `ok: true` and then a refusal would put two
3208
+ // envelopes on stdout with the first one green — a false green for any JSON consumer that reads the
3209
+ // first line. (Caught by writing it the other way round first.)
3210
+ const verdicts = preSpendVerdicts(scenario, cassettePath, {
3211
+ force,
3212
+ explicitOutPath: p.options["--out"] !== undefined,
3213
+ allowHostInventoryFixture,
3214
+ scenarioSourceFile: target,
3215
+ });
3216
+ const dryRefusal = verdicts.find((v) => v.kind === "refuse");
3217
+ // Warnings ride along with the preview in both modes; a refusal replaces it.
3218
+ if (!dryRefusal)
3219
+ for (const v of verdicts)
3220
+ warn(v.message);
3221
+ if (dryRefusal)
3222
+ return fail("record", "usage", dryRefusal.message, undefined, asJson);
3145
3223
  if (asJson) {
3146
3224
  out(jsonPayloadEnvelope("record", true, {
3147
3225
  dryRun: true,
@@ -3163,12 +3241,6 @@ export async function cmdRecord(args) {
3163
3241
  log(tokenLine);
3164
3242
  log(agentLine);
3165
3243
  }
3166
- // Part of the preview for the same reason the budget gate is: a rehearsal whose whole job is "tell me
3167
- // what this would do before I spend" must surface the thing that cannot be undone afterwards. `target`
3168
- // IS the scenario source here, so the reference root resolves exactly as it will on the real record.
3169
- const dryPort = cassettePortabilityPreflight(scenario, cassettePath, target);
3170
- if (dryPort.kind === "warn")
3171
- warn(dryPort.message);
3172
3244
  // See the dir branch above: the budget gate is part of the preview, not skipped by it.
3173
3245
  if (maxBudgetUsd !== undefined)
3174
3246
  preflightBudget("record", scenario.name, maxBudgetUsd, asJson);
@@ -3264,6 +3336,7 @@ export async function cmdRecord(args) {
3264
3336
  maxArtifactBytes,
3265
3337
  skipRedactionPreflight: true,
3266
3338
  allowHostInventoryFixture,
3339
+ allowHostInventoryFindings,
3267
3340
  });
3268
3341
  }
3269
3342
  else if (!fromEmbedded) {
@@ -3279,7 +3352,15 @@ export async function cmdRecord(args) {
3279
3352
  // --from-embedded: explicitly re-record from the embedded snapshot (edits to the YAML won't apply).
3280
3353
  log(` ⚠ ${tag} --from-embedded: re-recording "${cassette.scenario.name}" from the embedded snapshot (YAML edits won't apply)`);
3281
3354
  const sessionRef = cassette.scenario.session === "(inline)" ? "(inline)" : join(dirname(cp), cassette.scenario.session);
3282
- r = await recordScenarioObject({ ...cassette.scenario, session: sessionRef }, { noRedact, allowFailing, cassettePath: cp, maxArtifactBytes, skipRedactionPreflight: true, allowHostInventoryFixture });
3355
+ r = await recordScenarioObject({ ...cassette.scenario, session: sessionRef }, {
3356
+ noRedact,
3357
+ allowFailing,
3358
+ cassettePath: cp,
3359
+ maxArtifactBytes,
3360
+ skipRedactionPreflight: true,
3361
+ allowHostInventoryFixture,
3362
+ allowHostInventoryFindings,
3363
+ });
3283
3364
  }
3284
3365
  staleBudget.add(budgetFields(r.result).costUsd);
3285
3366
  log(` ✓ ${tag} ${cp} (${r.result.result})`);
@@ -3310,22 +3391,7 @@ export async function cmdRecord(args) {
3310
3391
  // (last-wins sequentially; a write RACE under --concurrency). Detect up front and fail loud — applies at
3311
3392
  // any concurrency since the sequential clobber is itself a latent bug. (`--out` is dir-rejected above, so
3312
3393
  // every item uses its default path.)
3313
- const targets = new Map();
3314
- const dupes = [];
3315
- for (const f of disc.scenarios) {
3316
- let cp;
3317
- try {
3318
- cp = defaultCassettePath(parseScenarioFile(f).name);
3319
- }
3320
- catch {
3321
- continue; // unparseable here would have been classified `broken`; let the record path report it
3322
- }
3323
- const prev = targets.get(cp);
3324
- if (prev)
3325
- dupes.push(`${f} ↔ ${prev} → ${cp}`);
3326
- else
3327
- targets.set(cp, f);
3328
- }
3394
+ const dupes = duplicateCassetteTargets(disc.scenarios);
3329
3395
  if (dupes.length) {
3330
3396
  return fail("record", "usage", `record: ${dupes.length} scenario(s) share a cassette output path (their \`name:\` slugifies identically) — would clobber/race; give them distinct \`name:\`:\n` +
3331
3397
  dupes.map((d) => ` ✗ ${d}`).join("\n"), undefined, asJson);
@@ -3385,6 +3451,7 @@ export async function cmdRecord(args) {
3385
3451
  maxArtifactBytes,
3386
3452
  skipRedactionPreflight: true,
3387
3453
  allowHostInventoryFixture,
3454
+ allowHostInventoryFindings,
3388
3455
  ...liveDecider,
3389
3456
  });
3390
3457
  batchBudget.add(budgetFields(r.result).costUsd);
@@ -3432,6 +3499,7 @@ export async function cmdRecord(args) {
3432
3499
  cassettePath,
3433
3500
  maxArtifactBytes,
3434
3501
  allowHostInventoryFixture,
3502
+ allowHostInventoryFindings,
3435
3503
  externalChannel: channel,
3436
3504
  ...liveDecider,
3437
3505
  });
@@ -3478,6 +3546,85 @@ export function nullOutScrubbedPreRunHashes(preRunHashes, scrubbedPaths) {
3478
3546
  /** The live-record TAIL shared by the file (batch/single) and in-memory (re-record) paths: run live, refuse
3479
3547
  * a failing run unless opted in, snapshot + secret-scrub bodies, opt-in redact + verdict-preserve,
3480
3548
  * then write. `extraPolicyDirs` adds the scenario-file dir to the .cowork-redact.json search. */
3549
+ /** Scenario files in a batch whose `name:` slugifies to the SAME default cassette path — they would clobber
3550
+ * each other (last-wins sequentially, a write RACE under `--concurrency`).
3551
+ *
3552
+ * Shared by the real dir-batch and its `--dry-run` preview. Path-INDEPENDENT in the sense that matters: a
3553
+ * dir target rejects `--out`, so every item genuinely uses its default path on both arms — which is why
3554
+ * this one REFUSES in the preview while the host-inventory/portability notes only advise. */
3555
+ function duplicateCassetteTargets(files) {
3556
+ const targets = new Map();
3557
+ const dupes = [];
3558
+ for (const f of files) {
3559
+ let cp;
3560
+ try {
3561
+ cp = defaultCassettePath(parseScenarioFile(f).name);
3562
+ }
3563
+ catch {
3564
+ continue; // unparseable here would have been classified `broken`; let the record path report it
3565
+ }
3566
+ const prev = targets.get(cp);
3567
+ if (prev)
3568
+ dupes.push(`${f} ↔ ${prev} → ${cp}`);
3569
+ else
3570
+ targets.set(cp, f);
3571
+ }
3572
+ return dupes;
3573
+ }
3574
+ /** Every pre-spend check `record` applies to ONE scenario, in one place, returning verdicts instead of
3575
+ * acting on them.
3576
+ *
3577
+ * WHY THIS EXISTS. `record --dry-run` is the token-free rehearsal, and it used to re-implement this list
3578
+ * by hand — so it drifted. `hostInventoryPreflight` shipped 2026-08-04; `bbd5bf5` (2026-08-07), whose
3579
+ * commit title is literally "make --dry-run refuse what the real record refuses", swept in the two checks
3580
+ * that returned `string | undefined` and missed the one returning a `{kind}` verdict, which was already
3581
+ * wired into `recordScenarioObject`. It stayed missing for 19 days.
3582
+ *
3583
+ * A registry of check objects was designed and rejected: it guarantees every REGISTERED check runs
3584
+ * everywhere, but nothing stops the next check being called inline and never registered — which is
3585
+ * exactly how the gap happened. One function that both the real path and the preview call cannot have
3586
+ * that failure mode, and `test/pre-spend-parity.test.ts` scans this file to keep it the only site.
3587
+ *
3588
+ * ORDER IS PRESERVED from the historical inline sequence, because the FIRST refusal is the one the
3589
+ * operator sees and changing which one that is changes the error they get.
3590
+ *
3591
+ * NOT here, deliberately — these are whole-BATCH checks, and forcing them per-scenario would emit N
3592
+ * warnings where the real path emits one: `redactionPreflightMessage` (batch-shaped, and gated on
3593
+ * `!skipRedactionPreflight && !noRedact`), `preflightBatchBudget`, and the duplicate-slug batch guard. */
3594
+ export function preSpendVerdicts(scenario, cassettePath, opts) {
3595
+ const out = [];
3596
+ const promptReject = promptPolicyRejection(scenario);
3597
+ if (promptReject)
3598
+ out.push({ kind: "refuse", message: promptReject });
3599
+ // A host-inheriting tier freezes the recording machine's own inventory into the transcript, so writing
3600
+ // that to a repo-tracked path publishes the operator's tool stack (this has happened).
3601
+ const inv = hostInventoryPreflight(scenario, cassettePath, opts.allowHostInventoryFixture === true);
3602
+ if (inv.kind !== "ok")
3603
+ out.push(inv);
3604
+ // Warn-only: after the run the tokens are gone and the only remedy is to spend them again at the right path.
3605
+ const port = cassettePortabilityPreflight(scenario, cassettePath, opts.scenarioSourceFile);
3606
+ if (port.kind === "warn")
3607
+ out.push(port);
3608
+ // Slug collision. Two DIFFERENT scenario names can slugify to the same default path and silently clobber
3609
+ // each other. `--out`/`--force` opt out — a preview that ignored either would refuse what the real path
3610
+ // allows, which is worse than not previewing at all.
3611
+ if (!opts.explicitOutPath && !opts.force && existsSync(cassettePath)) {
3612
+ try {
3613
+ const existing = JSON.parse(readFileSync(cassettePath, "utf8"));
3614
+ const existingName = existing.scenario?.name;
3615
+ if (existingName && existingName !== scenario.name)
3616
+ out.push({
3617
+ kind: "refuse",
3618
+ message: `refusing to overwrite ${cassettePath}: it belongs to scenario "${existingName}", but this record is "${scenario.name}" ` +
3619
+ `(their names slugify to the same default path — pass --out <file> to disambiguate, or --force to overwrite).`,
3620
+ });
3621
+ }
3622
+ catch {
3623
+ /* an unreadable/malformed existing cassette is not a collision signal — let the write proceed */
3624
+ }
3625
+ }
3626
+ return out;
3627
+ }
3481
3628
  /** `on_unanswered: prompt` blocks on a TTY. `run` rejects it outright (it breaks determinism), and
3482
3629
  * `record`'s own `--on-unanswered` enum excludes `prompt` for the same reason — but the SCENARIO field
3483
3630
  * outranks the flag (`scenario.on_unanswered ?? opts.onUnanswered` in executeScenario), so the enum
@@ -3489,15 +3636,31 @@ export function promptPolicyRejection(scenario) {
3489
3636
  : undefined;
3490
3637
  }
3491
3638
  async function recordScenarioObject(scenario, opts, extraPolicyDirs = []) {
3492
- // Same guard as the --dry-run path, on the funnel every real record passes through (dir batches and
3493
- // --rerecord-stale never touch the dry-run branch).
3494
- const promptReject = promptPolicyRejection(scenario);
3495
- if (promptReject)
3496
- throw new Error(promptReject);
3639
+ // ONE pre-spend block, shared with `record --dry-run` via `preSpendVerdicts` — see its doc comment for
3640
+ // why the preview must not re-implement this list.
3641
+ //
3642
+ // Every refusal THROWS. It used to be split: `promptPolicyRejection` threw while `hostInventoryPreflight`
3643
+ // called `fail()`, which `process.exit`s. The dir-batch loop catches a throw per item and continues, so
3644
+ // the exiting one abandoned concurrent runs already paid for, mid-batch, uncatchably. Throwing uniformly
3645
+ // keeps "report every offender" true for the whole set.
3646
+ {
3647
+ const plannedCassettePath = opts.cassettePath ?? defaultCassettePath(scenario.name);
3648
+ for (const v of preSpendVerdicts(scenario, plannedCassettePath, {
3649
+ force: opts.force,
3650
+ explicitOutPath: opts.cassettePath !== undefined,
3651
+ allowHostInventoryFixture: opts.allowHostInventoryFixture,
3652
+ scenarioSourceFile: opts.scenarioSourceFile,
3653
+ })) {
3654
+ if (v.kind === "refuse")
3655
+ throw new Error(v.message);
3656
+ warn(v.message);
3657
+ }
3658
+ }
3497
3659
  // Redaction preflight — MUST fire BEFORE the (paid) agent spawn below; the historical policy-load
3498
3660
  // point after the live run is exactly the after-the-fact discovery this exists to prevent. Same search
3499
3661
  // set as the post-run load. `--no-redact` skips it (explicit known-synthetic opt-out); the batch paths
3500
- // skip it here because they preflight once for the whole batch (skipRedactionPreflight).
3662
+ // skip it here because they preflight once for the whole batch (skipRedactionPreflight). BATCH-shaped,
3663
+ // so it stays out of preSpendVerdicts — see that function's doc comment.
3501
3664
  if (!opts.skipRedactionPreflight && !opts.noRedact) {
3502
3665
  const plannedCassettePath = opts.cassettePath ?? defaultCassettePath(scenario.name);
3503
3666
  const preflight = redactionPreflightMessage([
@@ -3506,23 +3669,6 @@ async function recordScenarioObject(scenario, opts, extraPolicyDirs = []) {
3506
3669
  if (preflight)
3507
3670
  warn(preflight);
3508
3671
  }
3509
- // Host-inventory preflight — ALSO before the paid spawn. A host-inheriting tier freezes the recording
3510
- // machine's own inventory into the transcript, so writing that to a repo-tracked path publishes the
3511
- // operator's tool stack (this has happened). Refusing after the run would be strictly worse: the tokens
3512
- // are already spent and the tempting fix is to commit it anyway.
3513
- {
3514
- const plannedCassettePath = opts.cassettePath ?? defaultCassettePath(scenario.name);
3515
- const verdict = hostInventoryPreflight(scenario, plannedCassettePath, opts.allowHostInventoryFixture === true);
3516
- if (verdict.kind === "refuse")
3517
- return fail("record", "usage", verdict.message, undefined, isJsonOutput(process.argv));
3518
- if (verdict.kind === "warn")
3519
- warn(verdict.message);
3520
- // Portability — also pre-spend, and for the same reason: after the run the tokens are gone and the
3521
- // only remedy is to spend them again at the right path.
3522
- const port = cassettePortabilityPreflight(scenario, plannedCassettePath, opts.scenarioSourceFile);
3523
- if (port.kind === "warn")
3524
- warn(port.message);
3525
- }
3526
3672
  // Thread the live-decider opts. All undefined for a plain `record` → identical to the
3527
3673
  // previous opt-less call (executeScenario defaults onUnanswered to scenario.on_unanswered ?? "fail").
3528
3674
  const result = await executeScenario(scenario, {
@@ -3743,24 +3889,11 @@ async function recordScenarioObject(scenario, opts, extraPolicyDirs = []) {
3743
3889
  await assertRedactionVerdictPreserved(base, redacted, dirname(cassettePath));
3744
3890
  cassette = redacted;
3745
3891
  }
3746
- // Slug-collision guard (findings 19/20): a DEFAULT path is derived from `slugForPath(scenario.name)`, so
3747
- // two DIFFERENT scenario names that slugify identically would silently clobber the same cassette. Refuse to
3748
- // overwrite when the existing cassette on the default path was recorded for a DIFFERENT scenario name (a
3749
- // routine same-scenario re-record — or a moved scenario, same name — is unaffected). `--out`/`--force` opt out.
3750
- if (!opts.cassettePath && !opts.force && existsSync(cassettePath)) {
3751
- try {
3752
- const existing = JSON.parse(readFileSync(cassettePath, "utf8"));
3753
- const existingName = existing.scenario?.name;
3754
- if (existingName && existingName !== scenario.name)
3755
- throw new Error(`refusing to overwrite ${cassettePath}: it belongs to scenario "${existingName}", but this record is "${scenario.name}" ` +
3756
- `(their names slugify to the same default path — pass --out <file> to disambiguate, or --force to overwrite).`);
3757
- }
3758
- catch (e) {
3759
- if (e instanceof Error && e.message.startsWith("refusing to overwrite"))
3760
- throw e;
3761
- /* an unreadable/malformed existing cassette is not a collision signal — let the write proceed */
3762
- }
3763
- }
3892
+ // The slug-collision refusal used to live HERE, after the paid run. It moved into `preSpendVerdicts`
3893
+ // (pre-spawn) — it is a pure function of (path, --force, the existing cassette's scenario name), so
3894
+ // paying for a run and only then refusing to write it was the exact failure `hostInventoryPreflight`'s
3895
+ // own comment argues against. Nothing re-checks it here: a second process writing a colliding cassette
3896
+ // mid-run is not a case this ever detected (the old check read the file before the write too).
3764
3897
  // Behaviour delta vs the cassette this one REPLACES. Re-recording is the only moment where "did my edit
3765
3898
  // change what the agent does?" is observable at all — replay re-checks a frozen transcript and is
3766
3899
  // structurally blind to it. Without this the answer is discarded every time: you pay for a re-record and
@@ -3775,10 +3908,12 @@ async function recordScenarioObject(scenario, opts, extraPolicyDirs = []) {
3775
3908
  // RECORD-TIME PRIVACY SCAN. Scanned AFTER redaction, deliberately: redaction is the mechanism that is
3776
3909
  // supposed to remove this, so scanning the pre-redaction bytes would quarantine recordings that are clean.
3777
3910
  // The policy itself lives in `classifyRecordLeak` so it is testable without a paid run.
3778
- const leak = classifyRecordLeak(cassette, cassettePath, opts.allowHostInventoryFixture === true);
3911
+ // The WRITE-TIME override only. `allowHostInventoryFixture` bypasses the pre-flight (a precondition the
3912
+ // operator cannot check); it must NOT also wave through a measured finding.
3913
+ const leak = classifyRecordLeak(cassette, cassettePath, opts.allowHostInventoryFindings === true);
3779
3914
  if (leak.kind === "override") {
3780
3915
  warn(`::warning:: [record] host inventory PRESENT in the recording, written anyway because ` +
3781
- `--allow-host-inventory-fixture was passed:\n${leak.detail}`);
3916
+ `--allow-host-inventory-findings was passed:\n${leak.detail}`);
3782
3917
  }
3783
3918
  else if (leak.kind === "outside-repo") {
3784
3919
  warn(`::warning:: [record] this recording carries THIS MACHINE's inventory. Its path is not repo-visible, ` +
@@ -3795,7 +3930,8 @@ async function recordScenarioObject(scenario, opts, extraPolicyDirs = []) {
3795
3930
  : "") +
3796
3931
  `Fix it by re-recording at a SEALED tier (--fidelity container), or from an environment without your ` +
3797
3932
  `personal MCP servers/agents configured. If this inventory is genuinely part of the fixture, re-run ` +
3798
- `with --allow-host-inventory-fixture.`);
3933
+ `with --allow-host-inventory-findings (NOT --allow-host-inventory-fixture, which only bypasses the ` +
3934
+ `pre-flight and deliberately leaves this scan in force).`);
3799
3935
  }
3800
3936
  writeFileAtomic(cassettePath, JSON.stringify(cassette, null, 2)); // atomic — no partial cassette on a mid-write crash
3801
3937
  const delta = priorSummary ? describeBehaviourDelta(priorSummary, behaviourSummary(cassette)) : undefined;
@@ -5645,10 +5781,52 @@ export const ALWAYS_CONTENT_KEYS = [
5645
5781
  // VERDICT_MODIFIER_KEYS list (types.ts) — a newly-added modifier lands here automatically.
5646
5782
  ...VERDICT_MODIFIER_KEYS,
5647
5783
  ];
5784
+ /** Keys whose evidence CANNOT SEE A `tool_use`.
5785
+ *
5786
+ * They read `ctx.transcript`, which `run.ts` builds from top-level `assistant_text` events only — every
5787
+ * `tool_use` and `tool_result` is a different event `case` and contributes nothing. So a phrase the agent
5788
+ * only ever emitted inside a tool call (an `AskUserQuestion` gate, a `computer://` link in a tool result)
5789
+ * can never match, at any phrasing, no matter what the agent did.
5790
+ *
5791
+ * This cost a consumer a paid recording: a `transcript_matches` was written against text that lives in a
5792
+ * gate question, and it could not have matched. `semantic_matches` carried the ⚠️ in its docs row from the
5793
+ * start; the transcript and computer-link keys did not, and nothing enforced the asymmetry.
5794
+ *
5795
+ * Membership is by BLINDNESS, not by corpus. `semantic_matches` grades a strict superset (final message +
5796
+ * transcript + optional sub-agent text + authored files) and still belongs: the shared, guarded property is
5797
+ * that no member can see a tool call. `transcript_no_host_path` is deliberately ABSENT — it reads
5798
+ * `ctx.hostPathLeaked`, a post-run scan, not the transcript string.
5799
+ *
5800
+ * Consumed by `test/caveat-docs-sync.test.ts`, which requires every member's docs row, zod `.describe()` and
5801
+ * packaged skill-reference row to carry the exclusion sentence. That guard is satisfiable by pasting the
5802
+ * sentence onto a row — which is exactly the
5803
+ * intended action. The rot mode it exists to catch is a NEW blind key added with no caveat at all, and an
5804
+ * enumerable set catches that. */
5805
+ export const TOOL_USE_BLIND_KEYS = [
5806
+ "transcript_contains",
5807
+ "transcript_not_contains",
5808
+ "transcript_matches",
5809
+ "transcript_not_matches",
5810
+ "computer_links_resolve",
5811
+ "computer_links_resolve_if_present",
5812
+ "semantic_matches",
5813
+ ];
5814
+ /** Keys that match text the MODEL composed, and which therefore red on rewording alone.
5815
+ *
5816
+ * Gate question text and option labels/descriptions are written by the model per run. Measured across three
5817
+ * live runs of one scenario, the same producer-supplied sentence arrived verbatim in the question, reworded
5818
+ * inside it, and relocated into an option's `description` — three reds, one behaviour. The advice is to pin
5819
+ * producer-authored constants rather than model prose, and to prefer `question_context` when the placement
5820
+ * may move.
5821
+ *
5822
+ * The `choose:`/answers side of this already carried the caveat (stable leading anchor, 1-based index); the
5823
+ * ASSERT side carried it nowhere. Consumed by `test/caveat-docs-sync.test.ts`. */
5824
+ export const MODEL_AUTHORED_TEXT_KEYS = ["question_asked", "question_options", "question_context"];
5648
5825
  /** Assertion keys evaluated on replay only when `controlOut` (full-fidelity) is present. */
5649
5826
  export const QUESTION_GATE_KEYS = [
5650
5827
  "question_asked",
5651
5828
  "question_options",
5829
+ "question_context",
5652
5830
  "questions_count_max",
5653
5831
  "gate_answers_delivered",
5654
5832
  "gate_answer_count_min",
@@ -191,6 +191,8 @@ const CONTRADICTION_GROUPS = [
191
191
  // Same reasoning as question_asked: asserting WHICH options a gate offered requires a gate to have
192
192
  // fired, which contradicts requiring zero sub-questions.
193
193
  { label: "`question_options`", test: (a) => a.question_options !== undefined },
194
+ // Same again: matching text a gate SHOWED requires a gate to have fired.
195
+ { label: "`question_context`", test: (a) => a.question_context !== undefined },
194
196
  // `: false` asserts a CONFIRMED delivery failure, which needs a gate to have fired — a presence
195
197
  // requirement in disguise. `: true` is NOT one: it passes vacuously at zero gates, so it is merely
196
198
  // inert alongside the declaration (lint says so; not worth refusing a run over).
@@ -1541,6 +1543,53 @@ export function parseScenarioFile(path) {
1541
1543
  validateScenarioRegexes(scenario, path);
1542
1544
  return scenario;
1543
1545
  }
1546
+ /** Every nested `{ …: <regex> }` leaf in the Assertion schema, derived from zod rather than enumerated.
1547
+ *
1548
+ * A leaf qualifies when it is a string field of a nested object whose own `.describe()` says "regex" — the
1549
+ * same declaration the docs and JSON schema are generated from, so a new nested regex field is covered the
1550
+ * moment it is declared. `test/nested-regex-leaves.test.ts` pins the derivation against the evaluator: every
1551
+ * leaf `assert.ts` compiles with `compileUserRegex` must appear here, so a load-time gap cannot reopen.
1552
+ *
1553
+ * Computed once — walking the schema per assertion per run would be pointless work on a hot path. */
1554
+ /** Every nested `{ …: <regex> }` leaf in the Assertion schema.
1555
+ *
1556
+ * It is an explicit table on purpose, after a derivation from zod internals was tried and rejected: the
1557
+ * `.describe()` text is not reachable the same way across the src and dist builds, so the walk silently
1558
+ * returned ZERO leaves under one of them — validation that looks present and checks nothing, which is
1559
+ * strictly worse than the hand list it replaced.
1560
+ *
1561
+ * What makes the table safe is not how it was written but `test/nested-regex-leaves.test.ts`, which reads
1562
+ * `assert.ts` and fails if the evaluator compiles a nested leaf this table does not carry. The previous
1563
+ * version of this code was a hand list of THREE under a comment claiming to cover them all, with no guard —
1564
+ * the list was not the defect; the missing guard was.
1565
+ *
1566
+ * A bad regex in any of these used to surface inside the evaluator, i.e. after the paid spawn. */
1567
+ const NESTED_REGEX_LEAVES = [
1568
+ ["artifact_text", "matches"],
1569
+ ["artifact_text", "not_matches"],
1570
+ ["path_denied", "path_matches"],
1571
+ ["question_context", "matches"],
1572
+ ["question_context", "when_question"],
1573
+ ["question_options", "when_question"],
1574
+ ["skill_tool_used", "skill"],
1575
+ ["skill_tool_used", "tool"],
1576
+ ["subagent_dispatch_healthy", "type"],
1577
+ ["subagent_output_contains", "match"],
1578
+ ["task_status", "match"],
1579
+ ];
1580
+ /** The `[label, pattern]` pairs to validate for one authored assertion. Exported for the coverage test. */
1581
+ export function nestedRegexLeaves(a) {
1582
+ const out = [];
1583
+ for (const [parent, child] of NESTED_REGEX_LEAVES) {
1584
+ const holder = a[parent];
1585
+ if (holder === undefined || holder === null || typeof holder !== "object")
1586
+ continue;
1587
+ const v = holder[child];
1588
+ if (typeof v === "string")
1589
+ out.push([`${parent}.${child}`, v]);
1590
+ }
1591
+ return out;
1592
+ }
1544
1593
  /** Validate all user-supplied regex patterns in a scenario at load time. Throws on the first bad pattern. */
1545
1594
  function validateScenarioRegexes(scenario, scenarioPath) {
1546
1595
  const context = `scenario "${scenario.name ?? scenarioPath}"`;
@@ -1589,6 +1638,16 @@ function validateScenarioRegexes(scenario, scenarioPath) {
1589
1638
  throw new Error(`bad regex in ${key} in ${context}: ${c.error}`);
1590
1639
  }
1591
1640
  }
1641
+ // Nested regex leaves — DERIVED from the schema, never hand-listed. The flat `a[key]` loop above
1642
+ // reaches only top-level string keys, so every regex that lives one level down (`artifact_text.matches`,
1643
+ // `path_denied.path_matches`, `question_context.matches`, …) was compiled for the first time inside the
1644
+ // evaluator, i.e. AFTER the paid spawn. A hand list is how the previous version of this got it wrong: it
1645
+ // covered 3 of the 11 leaves while its comment claimed to cover them all.
1646
+ for (const [label, pattern] of nestedRegexLeaves(a)) {
1647
+ const c = compileUserRegex(pattern);
1648
+ if ("error" in c)
1649
+ throw new Error(`bad regex in ${label} in ${context}: ${c.error}`);
1650
+ }
1592
1651
  // (Empty / regex-ish / brace-expansion tool globs are rejected by the `toolGlob` schema in types.ts —
1593
1652
  // enforced on EVERY parse path including a recorded cassette's frozen asserts, not just here. )
1594
1653
  }