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.
- package/.claude/skills/cowork-harness/SKILL.md +31 -12
- package/.claude/skills/cowork-harness/references/ci-recipe.md +15 -13
- package/.claude/skills/cowork-harness/references/critique.md +1 -1
- package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
- package/.claude/skills/cowork-harness/references/scenario-schema.md +14 -12
- package/.claude/skills/cowork-harness/references/task-recipes.md +2 -2
- package/.claude/skills/cowork-harness/scripts/assertion-keys.json +1 -0
- package/.claude/skills/cowork-harness/scripts/scenario.py +8 -1
- package/CHANGELOG.md +236 -0
- package/DESIGN.md +3 -3
- package/README.md +41 -11
- package/baselines/desktop-1.37937.1.json +816 -0
- package/baselines/prompts/cowork-system-prompt-fingerprints.json +27 -0
- package/dist/assert.js +77 -0
- package/dist/cli.js +15 -4
- package/dist/run/cassette.js +255 -77
- package/dist/run/execute.js +59 -0
- package/dist/run/trace-view.js +39 -1
- package/dist/sync/cowork-sync.js +207 -8
- package/dist/types.js +39 -9
- package/docs/README.md +3 -0
- package/docs/cassette.md +36 -11
- package/docs/debugging.md +25 -0
- package/docs/fidelity-gaps.md +5 -1
- package/docs/maintenance.md +15 -1
- package/docs/scenario.md +13 -10
- package/examples/replays/README.md +1 -1
- package/examples/replays/example-multiselect-gate.cassette.json +41 -46
- package/examples/replays/example-pdf-skill.cassette.json +90 -90
- package/examples/replays/hostloop-computer-links.cassette.json +62 -68
- package/llms.txt +3 -1
- package/package.json +1 -1
- package/schema/scenario.schema.json +28 -9
package/dist/run/cassette.js
CHANGED
|
@@ -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.
|
|
2722
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
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 }, {
|
|
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
|
|
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
|
-
//
|
|
3493
|
-
//
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
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
|
-
//
|
|
3747
|
-
//
|
|
3748
|
-
//
|
|
3749
|
-
//
|
|
3750
|
-
|
|
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
|
-
|
|
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-
|
|
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",
|
package/dist/run/execute.js
CHANGED
|
@@ -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
|
}
|