@skill-harness/core 0.6.0 → 0.7.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/dist/results.js CHANGED
@@ -74,18 +74,58 @@ export function reportPath(runDir) {
74
74
  export function resultsPath(runDir) {
75
75
  return join(runDir, "results.yaml");
76
76
  }
77
- /** The verdict that counts: author override when present, else the judge's. */
77
+ /**
78
+ * The verdict that counts: author override when present, else the objective
79
+ * gate, else the judge's.
80
+ *
81
+ * **The objective gate outranks the judge.** `assert.trace` is a mechanical
82
+ * statement about what the model DID — it called `write`, it touched `.env`.
83
+ * The judge is an LLM reading prose. When they disagree, the measurement wins.
84
+ *
85
+ * This is the only place that ordering is enforced, and it has to be here.
86
+ * `objective` used to reach the ship decision solely through `gatePrefix` in
87
+ * `run.ts`, which forces a single rep's verdict — so every path that recomputed
88
+ * a verdict afterwards silently dropped the gate while keeping the `objective`
89
+ * block that claimed it was enforced. Three of them did: `--reps N` out-voted an
90
+ * objective FAIL 2-to-1 (100%, grade A, SHIP, on a CRITICAL scenario that called
91
+ * a forbidden tool), `regrade` re-judged from a transcript the tool calls are
92
+ * absent from, and `regate` recomputed it. `reps.ts` already states the policy —
93
+ * "one rep that called a forbidden tool is a real finding, not a minority draw
94
+ * to be voted away" — and nothing enforced it.
95
+ *
96
+ * An author override still wins, exactly as it does over `suspect`. Overriding a
97
+ * deterministic assertion is a deliberate, recorded human act — and the failure
98
+ * this guards against was never a human deciding, it was nobody deciding.
99
+ */
78
100
  export function effectiveVerdicts(scenarios) {
79
101
  return scenarios.map((s) => ({
80
102
  id: s.id,
81
- verdict: s.override ?? s.judge_verdict,
103
+ verdict: s.override ?? objectiveVerdict(s) ?? s.judge_verdict,
82
104
  suspect: s.suspect && s.override == null, // an override resolves the misfire
83
105
  }));
84
106
  }
107
+ /**
108
+ * The verdict an objective gate forces, or undefined when it forces nothing.
109
+ *
110
+ * ERROR outranks FAIL: "the evidence is missing" must never read as "the
111
+ * assertion held". Absent `objective` forces nothing at all — it means the
112
+ * scenario declared no trace assertions, and treating that as a pass would
113
+ * upgrade every legacy result to "objectively verified".
114
+ */
115
+ function objectiveVerdict(s) {
116
+ if (!s.objective)
117
+ return undefined;
118
+ if (s.objective.status === "ERROR")
119
+ return "ERROR";
120
+ if (s.objective.status === "FAIL")
121
+ return "FAIL";
122
+ return undefined;
123
+ }
85
124
  /**
86
125
  * The ONLY place effective_grade is computed. Every writer goes through here,
87
126
  * so a persisted grade can never disagree with verdicts + overrides.
88
- * ctx is null for unscored (red/force) runs.
127
+ * ctx is null for unscored runs — `red` only, since 0.5.0: `force` is a real
128
+ * deployment and is scored (see SCORED_MODES directly above).
89
129
  */
90
130
  export function finalizeResults(draft, ctx) {
91
131
  let effective_grade;
@@ -208,7 +248,11 @@ export function ensureResultsGitignore(resultsRoot) {
208
248
  }
209
249
  // Matches transcript (`.rep<k>.txt`), judge-raw (`.rep<k>.judge.txt`) and
210
250
  // staged-diff (`.rep<k>.diff.txt`) rep suffixes.
211
- const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.|diff\.)?txt$/;
251
+ // Every rep-suffixed artifact kind. `.trace.jsonl` was added without updating this,
252
+ // so `repIndexOf` returned null for traces and `regate` looked for an unsuffixed
253
+ // path that does not exist on a multi-rep run — reporting "trace missing" for
254
+ // traces sitting on disk.
255
+ const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.|diff\.)?(?:txt|trace\.jsonl)$/;
212
256
  /** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
213
257
  export function repIndexOf(filename) {
214
258
  const m = REP_SUFFIX_RE.exec(filename);
@@ -268,9 +312,13 @@ export function findJudgeRawFiles(runDir, scenarioId, mode) {
268
312
  if (!existsSync(runDir))
269
313
  return [];
270
314
  const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
315
+ // `judge2`/`judge3` are the second and third opinions adjudication writes. They
316
+ // were not matched here, so an override on an adjudicated cell committed the
317
+ // first judge's answer and silently dropped the very judgments the adjudication
318
+ // rested on — the audit trail minus its evidence.
271
319
  const re = mode === undefined
272
- ? new RegExp(`^${esc}\\..*\\.judge\\.txt$`)
273
- : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge\\.txt$`);
320
+ ? new RegExp(`^${esc}\\..*\\.judge\\d*\\.txt$`)
321
+ : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge\\d*\\.txt$`);
274
322
  return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
275
323
  }
276
324
  /**
@@ -287,6 +335,92 @@ export function diffPath(runDir, scenarioId, mode, rep) {
287
335
  const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
288
336
  return join(runDir, `${base}.diff.txt`);
289
337
  }
338
+ /**
339
+ * Rebuild a `ScenarioResult` after a command re-measured part of it.
340
+ *
341
+ * **The single choke point for every rewriter**, and exhaustive by construction:
342
+ * every field is destructured below, so adding one to `ScenarioResult` fails the
343
+ * build HERE until someone decides whether it is carried, taken fresh, or
344
+ * dropped. That guard is the whole point of the function.
345
+ *
346
+ * It exists because the ad-hoc version — `{ ...fresh, override: prior.override,
347
+ * note: prior.note }`, written independently in three places — silently dropped
348
+ * `objective` from `grade` and `adjudication` from `regate`. Both failures ran in
349
+ * the dangerous direction: a trace-gated scenario re-read as "no assertions
350
+ * declared", and an unresolved judge disagreement as a settled verdict. 1,036
351
+ * tests passed through it; a real smoke run caught it.
352
+ *
353
+ * The author's `override` and `note` are always carried and are not policy —
354
+ * no command re-measures a human's judgement.
355
+ */
356
+ export function rebuildScenarioResult(fresh, prior, policy) {
357
+ // Exhaustive destructure. Do not replace with a spread: the spread is what
358
+ // allowed a new field to pass through unconsidered in the first place.
359
+ const { id, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, objective: freshObjective, adjudication: freshAdjudication, ...rest } = fresh;
360
+ const _exhaustive = rest;
361
+ void _exhaustive;
362
+ void _freshOverride;
363
+ void _freshNote;
364
+ const pick = (p, freshValue, priorValue) => {
365
+ if (p === "drop")
366
+ return undefined;
367
+ return p === "fresh" ? freshValue : priorValue;
368
+ };
369
+ const objective = pick(policy.objective, freshObjective, prior?.objective);
370
+ const adjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
371
+ // `unresolved` is carried by the `suspect` flag and by nothing else — the
372
+ // adjudication block records WHY, but `suspect` is what the ship bar reads.
373
+ // Taking `suspect` from `fresh` while carrying the block therefore published a
374
+ // record that said `state: "unresolved"` and scored as a clean SHIP. `regate`
375
+ // did exactly that: it rebuilds verdict and suspect from the saved first-wave
376
+ // judge file, so a free, offline command silently resolved a disagreement in
377
+ // favour of shipping — inverting this module's stated invariant that an
378
+ // unresolved disagreement must not resolve itself.
379
+ const unresolved = adjudication?.state === "unresolved";
380
+ // A settled adjudication outranks a re-read of the first-wave judge file for
381
+ // the same reason: `regate` re-measures GATES, not judgments, so re-reading
382
+ // `<id>.judge.txt` would revert a `confirmed`/`tie_broken` verdict that two or
383
+ // three judges settled — leaving `adjudication.verdict` on the record
384
+ // contradicting the `judge_verdict` beside it.
385
+ const settled = policy.adjudication === "carry" ? adjudication?.verdict : undefined;
386
+ // Field ORDER matches `outcomesToResult`, the writer that produces a run's
387
+ // results.yaml in the first place. It is not cosmetic: `results.yaml` is a
388
+ // committed file, and emitting the same fields in a different order made every
389
+ // `grade`/`regate`/`adjudicate` rewrite every multi-rep scenario block in the
390
+ // corpus with a pure-noise diff.
391
+ return {
392
+ id,
393
+ judge_verdict: settled ?? judge_verdict,
394
+ judge_reason,
395
+ suspect: suspect || unresolved,
396
+ // Aggregation shape always comes from the fresh computation — these describe
397
+ // how THIS result was aggregated, not the previous one.
398
+ ...(reps === undefined ? {} : { reps }),
399
+ ...(passes === undefined ? {} : { passes }),
400
+ ...(clean === undefined ? {} : { clean }),
401
+ ...(flakiness === undefined ? {} : { flakiness }),
402
+ ...(pass_threshold === undefined ? {} : { pass_threshold }),
403
+ // The author owns the verdict; a re-measurement never discards their call.
404
+ override: prior?.override ?? null,
405
+ note: prior?.note ?? "",
406
+ // Omitted rather than set to undefined: absent must stay absent, so a result
407
+ // with no evidence serialises byte-identically to one from before the field
408
+ // existed.
409
+ ...(objective ? { objective } : {}),
410
+ ...(adjudication ? { adjudication } : {}),
411
+ };
412
+ }
413
+ /**
414
+ * Where a rep's execution trace is saved: `<id>.<mode>[.rep<k>].trace.jsonl`.
415
+ *
416
+ * `.jsonl` rather than `.txt` so it is distinguishable at a glance from a
417
+ * transcript, and one JSON object per line so a multi-turn scenario's per-turn
418
+ * traces append without a wrapper.
419
+ */
420
+ export function tracePath(runDir, scenarioId, mode, rep) {
421
+ const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
422
+ return join(runDir, `${base}.trace.jsonl`);
423
+ }
290
424
  /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
291
425
  export function findDiffFiles(runDir, scenarioId, mode) {
292
426
  if (!existsSync(runDir))
@@ -297,6 +431,16 @@ export function findDiffFiles(runDir, scenarioId, mode) {
297
431
  : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.diff\\.txt$`);
298
432
  return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
299
433
  }
434
+ /** A scenario's execution-trace files, sorted (plain first, then numeric rep). Mode-scoped when given. */
435
+ export function findTraceFiles(runDir, scenarioId, mode) {
436
+ if (!existsSync(runDir))
437
+ return [];
438
+ const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
439
+ const re = mode === undefined
440
+ ? new RegExp(`^${esc}\\..*\\.trace\\.jsonl$`)
441
+ : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.trace\\.jsonl$`);
442
+ return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
443
+ }
300
444
  /** A single representative transcript file for a scenario in a run dir. Null if none. */
301
445
  export function findTranscriptFile(runDir, scenarioId) {
302
446
  return findTranscriptFiles(runDir, scenarioId)[0] ?? null;
@@ -320,6 +464,11 @@ export function preserveTranscript(resultsRoot, runDir, scenarioId) {
320
464
  ...findTranscriptFiles(runDir, scenarioId),
321
465
  ...findJudgeRawFiles(runDir, scenarioId),
322
466
  ...findDiffFiles(runDir, scenarioId),
467
+ // On a trace-gated scenario the trace IS the evidence for the override — the
468
+ // same role the staged diff plays on a seeded one. Omitting it committed an
469
+ // override whose justification was gitignored, and left `regate` with nothing
470
+ // to re-evaluate on the one cell a human had disputed.
471
+ ...findTraceFiles(runDir, scenarioId),
323
472
  ];
324
473
  if (files.length === 0)
325
474
  return;
package/dist/run.js CHANGED
@@ -2,11 +2,13 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { sourceHashes } from "./sources.js";
4
4
  import { judgeResemblesSubject } from "./grade.js";
5
- import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, scoreContextFor, isScoredMode, } from "./results.js";
5
+ import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, isScoredMode, } from "./results.js";
6
6
  import { appendJournal } from "./journal.js";
7
7
  import { liftHeadline } from "./lift.js";
8
8
  import { runSeeded } from "./seeded.js";
9
- import { createWorkspace } from "./workspace.js";
9
+ import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
10
+ import { evaluateTraceGates } from "./trace-gates.js";
11
+ import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
10
12
  import { runPool } from "./scheduler.js";
11
13
  import { outcomesToResult } from "./reps.js";
12
14
  import { judgeOneRep } from "./regrade.js";
@@ -77,8 +79,13 @@ export async function runSkillModel(opts) {
77
79
  });
78
80
  if (canary.status === "fail")
79
81
  throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
80
- if (canary.status === "skipped")
82
+ if (canary.status === "skipped") {
83
+ // Recorded, not just logged: `journal.jsonl` is gitignored, and the claim
84
+ // "this run's delivery was verified" has to survive a commit — including
85
+ // when it is the claim that it wasn't.
86
+ canaryStatus = "skipped";
81
87
  log(` ⚠ delivery canary skipped — ${canary.detail}`);
88
+ }
82
89
  else {
83
90
  canaryStatus = "pass";
84
91
  log(` ✓ delivery canary — the model quoted its skill instructions back (\`${canary.anchor}\`)`);
@@ -168,6 +175,14 @@ async function runRep(scenario, rep, repCount, ctx) {
168
175
  transcript = `[workspace setup failed] ${gatePrefix}`;
169
176
  }
170
177
  let noResponse = false;
178
+ let traces = [];
179
+ let unobservablePaths = false;
180
+ // The pre-run state, captured AFTER `createWorkspace` has applied the
181
+ // fixture's `_staged/` and `_uncommitted/` trees. Those land after the
182
+ // baseline commit, so a fixture that ships a deliberately dirty tree was
183
+ // being reported as changes the model made — a fabricated FAIL, written into
184
+ // a committed results.yaml, naming files the model never touched.
185
+ let before = ws ? snapshotPaths(ws.cwd, scenario.workspace) : null;
171
186
  if (ws) {
172
187
  // A blank assistant turn is a harness timeout, not model behavior: retry ONCE in a
173
188
  // fresh workspace (the first attempt may have half-mutated a seeded repo), and if
@@ -178,24 +193,46 @@ async function runRep(scenario, rep, repCount, ctx) {
178
193
  log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} empty response — retrying once`);
179
194
  ws.cleanup();
180
195
  ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
196
+ // A fresh workspace needs a fresh baseline, or the retry's diff would
197
+ // be taken against a directory that no longer exists.
198
+ before = snapshotPaths(ws.cwd, scenario.workspace);
181
199
  }
182
200
  if (scenario.mode === "seeded") {
183
201
  const r = await runSeeded(scenario, {
184
202
  skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
185
203
  specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
204
+ trace: scenario.traceAssert ? { scenarioId: scenario.id, rep } : undefined,
186
205
  });
187
206
  transcript = r.transcript;
188
207
  gatePrefix = r.gateFailure;
189
208
  stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
209
+ traces = r.traces;
190
210
  }
191
211
  else {
192
- transcript = await ctx.adapter.run({
212
+ const req = {
193
213
  skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
194
214
  // resolved like fixtures: relative to the spec's dir
195
215
  systemPromptFile: scenario.systemPromptFile
196
216
  ? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
197
217
  : undefined,
198
- });
218
+ // Absolute before it reaches a child process running in a neutral cwd.
219
+ extensions: scenario.extensions?.map((e) => resolve(dirname(ctx.specPath), e)),
220
+ };
221
+ if (scenario.traceAssert) {
222
+ // Missing required evidence is ERROR, never a silent fallback to the
223
+ // unstructured path: a gate with nothing to read must not look like a
224
+ // gate that passed.
225
+ if (!ctx.adapter.runStructured) {
226
+ throw new Error(`scenario \`${scenario.id}\` declares \`assert.trace\`, but the \`${ctx.adapter.name}\` adapter` +
227
+ ` cannot produce execution traces — the gate would have no evidence to read.`);
228
+ }
229
+ const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
230
+ transcript = structured.transcript;
231
+ traces = structured.traces;
232
+ }
233
+ else {
234
+ transcript = await ctx.adapter.run(req);
235
+ }
199
236
  }
200
237
  noResponse = hasEmptyAssistantTurn(transcript);
201
238
  if (!noResponse)
@@ -214,10 +251,77 @@ async function runRep(scenario, rep, repCount, ctx) {
214
251
  }
215
252
  appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
216
253
  }
254
+ // Filesystem evidence for `unchanged_paths`, observed AFTER the model ran.
255
+ //
256
+ // It cannot come through `RunReq`: the request is built before the run, and
257
+ // what changed only exists afterwards. That plumbing existed and no caller
258
+ // ever set it, so `changed_paths` was always `[]` and every
259
+ // `unchanged_paths` assertion passed vacuously — a safety gate reporting
260
+ // green while the model rewrote the workspace.
261
+ // Only when the scenario actually asserts on paths. A scenario using only
262
+ // `require_calls` / `forbid_calls` needs no filesystem evidence, so a
263
+ // workspace it cannot observe is not an error for it.
264
+ if (scenario.traceAssert?.unchanged_paths?.length && traces.length > 0 && ws) {
265
+ const changed = diffSnapshots(before, snapshotPaths(ws.cwd, scenario.workspace));
266
+ if (changed === null) {
267
+ // Missing evidence is ERROR, never a pass — spec.ts refuses the
268
+ // `workspace: none` combination up front, so reaching here means the
269
+ // workspace could not be read.
270
+ unobservablePaths = true;
271
+ }
272
+ else {
273
+ traces = traces.map((t) => {
274
+ const withPaths = { ...t, changed_paths: changed };
275
+ return { ...withPaths, trace_sha256: traceSha256(withPaths) };
276
+ });
277
+ }
278
+ }
279
+ // Objective evidence is persisted for every rep, pass or fail — a failing gate
280
+ // is exactly when someone wants to read what the model actually did.
281
+ let objective;
282
+ if (scenario.traceAssert) {
283
+ if (traces.length > 0) {
284
+ writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
285
+ }
286
+ const merged = mergeTraces(traces);
287
+ if (unobservablePaths) {
288
+ gatePrefix = "objective: workspace changes could not be observed — `unchanged_paths` has no evidence to check";
289
+ objective = { status: "ERROR", assertions: [] };
290
+ }
291
+ else if (merged === null) {
292
+ // Declared a gate, produced no trace: that is broken infrastructure, and
293
+ // grading it either way would be inventing a result.
294
+ gatePrefix = "objective: no execution trace was produced — cannot evaluate assert.trace";
295
+ objective = { status: "ERROR", assertions: [] };
296
+ }
297
+ else {
298
+ const gate = evaluateTraceGates(scenario.traceAssert, merged);
299
+ objective = {
300
+ status: gate.status,
301
+ trace_version: merged.trace_version,
302
+ trace_sha256: merged.trace_sha256,
303
+ assertions: gate.assertions,
304
+ };
305
+ if (gate.status === "FAIL") {
306
+ // Set the same gatePrefix the seeded gates use, so a trace failure
307
+ // short-circuits the judge through the path that already exists.
308
+ gatePrefix = `objective: ${gate.assertions.filter((x) => x.status === "FAIL").map((x) => x.detail).join("; ")}`;
309
+ }
310
+ }
311
+ appendJournal(runDir, {
312
+ event: "objective-result", ts: now(), id: scenario.id,
313
+ ok: objective.status === "PASS", detail: gatePrefix ?? "", ...repField,
314
+ });
315
+ }
217
316
  let verdict;
218
317
  let reason;
219
318
  let suspect = false;
220
- if (noResponse) {
319
+ if (objective?.status === "ERROR") {
320
+ verdict = "ERROR";
321
+ reason = gatePrefix ?? "objective evidence missing";
322
+ appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
323
+ }
324
+ else if (noResponse) {
221
325
  verdict = "ERROR";
222
326
  reason = "model produced no response after a retry (harness timeout?) — infra, not skill behavior";
223
327
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
@@ -238,7 +342,7 @@ async function runRep(scenario, rep, repCount, ctx) {
238
342
  suspect = o.suspect; // judgeOneRep already journaled (verdict + misfire)
239
343
  }
240
344
  log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
241
- return { verdict, reason, suspect };
345
+ return { verdict, reason, suspect, objective };
242
346
  }
243
347
  finally {
244
348
  ws?.cleanup();
@@ -291,7 +395,9 @@ export function formatScorecard(summary, lift, stability) {
291
395
  // Said on the scorecard, not just in the docs: the one thing that can invalidate
292
396
  // a green number is invisible in the number. `harness_cli_version` is recorded
293
397
  // beside the verdicts so a reader can tell which pi produced them.
294
- if (results.mode === "green" && !results.delivery_canary) {
398
+ // `skipped` counts as unproven here, not as proven: the probe was asked for and
399
+ // could not answer, which leaves delivery exactly as unverified as never asking.
400
+ if (results.mode === "green" && results.delivery_canary !== "pass") {
295
401
  lines.push(` NOTE: green delivery is harness-version-dependent` +
296
402
  (results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
297
403
  ` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
package/dist/seeded.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Scenario } from "./spec.js";
2
+ import type { ExecutionTraceV1 } from "./capture-trace-types.js";
2
3
  import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
3
4
  import { type ExecResult } from "./util/exec.js";
4
5
  interface SeededOpts {
@@ -17,6 +18,14 @@ interface SeededOpts {
17
18
  * deterministically.
18
19
  */
19
20
  runVitest?: (args: string[], cwd: string) => Promise<VitestRun>;
21
+ /**
22
+ * Trace metadata. Present when the scenario declares `assert.trace`, which
23
+ * routes the subject through the adapter's structured (`--mode json`) path.
24
+ */
25
+ trace?: {
26
+ scenarioId: string;
27
+ rep: number;
28
+ };
20
29
  }
21
30
  /**
22
31
  * Result of one vitest invocation — deliberately `ExecResult`, not a narrower
@@ -34,6 +43,8 @@ export interface SeededOutcome {
34
43
  transcript: string;
35
44
  gateFailure: string | null;
36
45
  diff: string;
46
+ /** One per turn; empty unless the scenario declared `assert.trace`. */
47
+ traces: ExecutionTraceV1[];
37
48
  }
38
49
  /**
39
50
  * The added/removed lines of a unified diff — what the model actually *changed*,
package/dist/seeded.js CHANGED
@@ -160,13 +160,37 @@ export function capDiff(diff, maxBytes = DIFF_MAX_BYTES) {
160
160
  */
161
161
  export async function runSeeded(scenario, opts) {
162
162
  const repo = opts.cwd;
163
- const harnessOut = await opts.adapter.run({
163
+ const req = {
164
164
  skillDir: opts.skillDir,
165
165
  model: opts.model,
166
166
  mode: opts.mode,
167
167
  turns: scenario.turns,
168
168
  cwd: repo,
169
- });
169
+ // Resolved against the spec dir, exactly like fixtures and post-tests.
170
+ extensions: scenario.extensions?.map((e) => resolve(opts.specDir, e)),
171
+ };
172
+ // A trace-gated seeded scenario runs through the structured path so the tool
173
+ // calls are recorded; everything downstream (gates, diff, transcript) is
174
+ // identical, because the rebuilt transcript is what print mode would have
175
+ // emitted anyway.
176
+ let traces = [];
177
+ let harnessOut;
178
+ if (opts.trace) {
179
+ if (!opts.adapter.runStructured) {
180
+ throw new Error(`scenario \`${opts.trace.scenarioId}\` declares \`assert.trace\`, but the \`${opts.adapter.name}\` adapter` +
181
+ ` cannot produce execution traces — the gate would have no evidence to read.`);
182
+ }
183
+ const structured = await opts.adapter.runStructured({
184
+ ...req,
185
+ scenarioId: opts.trace.scenarioId,
186
+ rep: opts.trace.rep,
187
+ });
188
+ harnessOut = structured.transcript;
189
+ traces = structured.traces;
190
+ }
191
+ else {
192
+ harnessOut = await opts.adapter.run(req);
193
+ }
170
194
  const parts = [harnessOut, "", "=== SEEDED GATES ==="];
171
195
  let gateFailure = null;
172
196
  const runVitest = opts.runVitest ??
@@ -189,7 +213,7 @@ export async function runSeeded(scenario, opts) {
189
213
  (gitFailure.stderr.trim() ? `: ${gitFailure.stderr.trim().split("\n")[0]}` : "");
190
214
  parts.push(` staged diff: ERROR (${msg})`);
191
215
  gateFailure = msg;
192
- return finish(parts, gateFailure, diff);
216
+ return finish(parts, gateFailure, diff, traces);
193
217
  }
194
218
  // BOTH needle gates read the changed lines only, never context. A unified diff
195
219
  // carries three lines of context per hunk, so an untouched symbol near the edit
@@ -257,7 +281,7 @@ export async function runSeeded(scenario, opts) {
257
281
  parts.push(` post_test: ERROR (${msg})`);
258
282
  if (!gateFailure)
259
283
  gateFailure = msg;
260
- return finish(parts, gateFailure, diff);
284
+ return finish(parts, gateFailure, diff, traces);
261
285
  }
262
286
  const v = await runVitest([POST_TEST_BASE], repo);
263
287
  const out = `${v.stdout}\n${v.stderr}`;
@@ -306,7 +330,7 @@ export async function runSeeded(scenario, opts) {
306
330
  gateFailure = problem;
307
331
  }
308
332
  }
309
- return finish(parts, gateFailure, diff);
333
+ return finish(parts, gateFailure, diff, traces);
310
334
  }
311
335
  function git(cwd, args) {
312
336
  return exec("git", args, { cwd, timeoutMs: 30_000 });
@@ -340,13 +364,13 @@ function bothStreams(v) {
340
364
  return o || e;
341
365
  }
342
366
  /** Append the staged diff and return the outcome. Every exit path goes through here, so the judge always sees the same sections in the same order. */
343
- function finish(parts, gateFailure, diff) {
367
+ function finish(parts, gateFailure, diff, traces = []) {
344
368
  // The code itself, last — the gates above only prove that keywords appeared.
345
369
  // Without this section a seeded checklist item about what the code *does* is
346
370
  // graded from the model's own description of its work.
347
371
  parts.push("", "=== STAGED DIFF ===");
348
372
  parts.push(diff.trim() === "" ? " (empty — the model left no staged changes)" : capDiff(diff));
349
- return { transcript: parts.join("\n"), gateFailure, diff };
373
+ return { transcript: parts.join("\n"), gateFailure, diff, traces };
350
374
  }
351
375
  /**
352
376
  * Parse vitest's `Tests N passed | M skipped (T)` summary line.
package/dist/sources.js CHANGED
@@ -167,24 +167,50 @@ function walk(dir, prefix = "") {
167
167
  * transcripts are actually invalid.
168
168
  */
169
169
  function facets(s) {
170
- const { id, title, critical, mode, turns, checklist, fixture, assert, workspace, remote, systemPromptFile, reps, passThreshold, ...restScenario } = s;
170
+ const { id, title, critical, mode, turns, checklist, fixture, assert, traceAssert, workspace, remote, systemPromptFile, extensions, reps, passThreshold, covers: _coversIsMetadata, ...restScenario } = s;
171
171
  const _scenarioExhaustive = restScenario;
172
172
  void _scenarioExhaustive;
173
+ // `covers` is destructured into a discard on purpose, and this comment is the
174
+ // decision the guard demanded: it belongs to NO digest. It records which
175
+ // instruction sections a scenario is declared to exercise, which changes what
176
+ // `--affected` selects next time — not what any past run measured. Bucketing it
177
+ // anywhere would charge a re-run (or at best a re-score) for editing a label,
178
+ // which is the exact trap the facet split was built to remove.
179
+ void _coversIsMetadata;
173
180
  const { vitest, diff_contains, diff_excludes, post_test, ...restAssert } = assert ?? {};
174
181
  const _assertExhaustive = restAssert;
175
182
  void _assertExhaustive;
176
- const hasGates = diff_contains !== undefined || diff_excludes !== undefined;
183
+ // `traceAssert` is a GATE, not stimulus: it is evaluated against a trace the run
184
+ // already saved, so `regate` (free) can re-answer it without re-running the model.
185
+ // Note the asymmetry with `env.extensions` in Phase 3, which IS stimulus — one
186
+ // changes what gets executed, the other only what we conclude from it.
187
+ const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined;
177
188
  return {
178
189
  // `vitest` and the `post_test` PATH are stimulus, not gates: both change what the
179
190
  // run executes in the workspace, and neither can be re-evaluated from a saved
180
191
  // diff. (`post_test`'s CONTENTS get their own file-path key, hashed separately.)
192
+ // `extensions` is STIMULUS, not a gate — note the asymmetry with `traceAssert`
193
+ // below. Changing which extensions load changes what the model can DO, so the
194
+ // old transcripts describe a different agent and only a re-run can answer.
195
+ // Changing an assertion only changes what we conclude from evidence already on
196
+ // disk, which `regate` can redo for free.
197
+ // APPENDED CONDITIONALLY, never as a fixed slot. This tuple is positional and
198
+ // its hash is stored in every published results.yaml, so adding an
199
+ // unconditional element re-hashes every scenario that never used the field —
200
+ // measured: 62 real lint findings became 261 across the reference corpus, all of
201
+ // them demanding paid re-runs for scenarios nobody had edited.
181
202
  stimulus: JSON.stringify([
182
203
  id, mode, turns, workspace, remote, systemPromptFile ?? null,
183
204
  fixture ?? null, vitest ?? null, post_test ?? null,
205
+ ...(extensions ? [extensions] : []),
184
206
  ]),
185
207
  rubric: JSON.stringify([id, title, checklist]),
186
208
  policy: JSON.stringify([id, critical, reps ?? null, passThreshold ?? null]),
187
- gates: hasGates ? JSON.stringify([id, diff_contains ?? null, diff_excludes ?? null]) : null,
209
+ // Same rule as `stimulus` above: conditional, so a needle-gated scenario that
210
+ // declares no trace assertions keeps the digest it was published with.
211
+ gates: hasGates
212
+ ? JSON.stringify([id, diff_contains ?? null, diff_excludes ?? null, ...(traceAssert ? [traceAssert] : [])])
213
+ : null,
188
214
  };
189
215
  }
190
216
  function sha(canonical) {
@@ -281,6 +307,15 @@ export function sourceHashes(ctx) {
281
307
  if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
282
308
  hashes[s.systemPromptFile] = fileSha256(resolve(ctx.specDir, s.systemPromptFile)) ?? UNREADABLE;
283
309
  }
310
+ // Extension CONTENTS, not just the paths the stimulus digest already covers.
311
+ // An orchestration scenario's subagent tool lives in these files: editing one
312
+ // changes what the model could do without changing a single character of the
313
+ // spec, which is precisely the drift the staleness gate exists to catch.
314
+ for (const ext of s.extensions ?? []) {
315
+ if (ext in hashes)
316
+ continue;
317
+ hashes[ext] = fileSha256(resolve(ctx.specDir, ext)) ?? UNREADABLE;
318
+ }
284
319
  // The post-test IS the gate on a post_test scenario, and it lives outside the
285
320
  // fixture tree by convention (`fixture: fixtures/A1`, `post_test: post/A1.test.ts`),
286
321
  // so neither the fixture digest nor the scenario digest — which holds only the
@@ -425,6 +460,8 @@ export function scenarioSourceKeys(s) {
425
460
  keys.push(GATES_PREFIX + s.id);
426
461
  if (s.systemPromptFile)
427
462
  keys.push(s.systemPromptFile); // the agent file IS the stimulus
463
+ for (const ext of s.extensions ?? [])
464
+ keys.push(ext); // an edited extension is new stimulus
428
465
  if (s.assert?.post_test)
429
466
  keys.push(s.assert.post_test); // its contents are the gate
430
467
  const fx = effectiveFixture(s);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The single choke point for appending a scenario to an existing
3
+ * `specification.yaml`.
4
+ *
5
+ * Two callers need this — `add-test` and capture promotion — and a second
6
+ * implementation is how they would drift into disagreeing about what a valid
7
+ * write is. Everything here is deliberately append-shaped: a spec is
8
+ * hand-authored and full of comments, and a round trip through
9
+ * `yaml.load`/`yaml.dump` would silently reformat it and drop every comment the
10
+ * author wrote. So the existing bytes are never re-serialized — the new block is
11
+ * concatenated onto them and the *result* is validated before anything is
12
+ * written.
13
+ */
14
+ /** Thrown when the spec on disk moved between the caller reading it and writing. */
15
+ export declare class ConcurrentSpecModification extends Error {
16
+ constructor(specPath: string);
17
+ }
18
+ /** Thrown when the scenario being appended collides with one already in the spec. */
19
+ export declare class DuplicateScenarioId extends Error {
20
+ constructor(id: string, specPath: string);
21
+ }
22
+ /** SHA-256 of spec text. Callers hold one across a read→confirm→write cycle. */
23
+ export declare function specSha256(text: string): string;
24
+ /**
25
+ * Render one scenario as a YAML block that can be concatenated onto a spec.
26
+ *
27
+ * Dumps `{ scenarios: [scenario] }` and strips the top-level key, leaving the
28
+ * correctly-indented list item. Going through `yaml.dump` rather than string
29
+ * templating is what makes arbitrary user text — quotes, colons, newlines,
30
+ * leading dashes — safe to embed.
31
+ */
32
+ export declare function renderScenarioBlock(scenario: Record<string, unknown>): string;
33
+ export interface AppendScenarioOptions {
34
+ specPath: string;
35
+ /** Plain object in spec field order; serialized by `renderScenarioBlock`. */
36
+ scenario: Record<string, unknown>;
37
+ /**
38
+ * SHA-256 the caller last saw. When supplied and the file no longer matches,
39
+ * the append is refused rather than layered onto someone else's edit.
40
+ */
41
+ baseSha256?: string;
42
+ }
43
+ export interface AppendScenarioResult {
44
+ id: string;
45
+ /** SHA-256 of the spec AFTER the append — the caller's new baseline. */
46
+ sha256: string;
47
+ /** The block that was appended, for preview/echo. */
48
+ block: string;
49
+ }
50
+ /**
51
+ * Validate and atomically append a scenario.
52
+ *
53
+ * Order matters and is load-bearing: read → detect concurrent modification →
54
+ * reject duplicate id → build → **validate the merged text** → write. The
55
+ * validation is on the merged result, not the block alone, because a block that
56
+ * parses in isolation can still break the file it lands in.
57
+ *
58
+ * The write is temp-file-plus-rename rather than `appendFileSync`. An append
59
+ * interrupted partway through leaves a syntactically broken spec on disk; a
60
+ * rename either happened or did not.
61
+ */
62
+ export declare function appendScenario(opts: AppendScenarioOptions): AppendScenarioResult;