@skill-harness/core 0.6.0 → 0.8.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 (44) hide show
  1. package/dist/adapters/types.d.ts +37 -0
  2. package/dist/adjudication.d.ts +210 -0
  3. package/dist/adjudication.js +392 -0
  4. package/dist/affected.d.ts +88 -0
  5. package/dist/affected.js +222 -0
  6. package/dist/capture-trace-types.d.ts +228 -0
  7. package/dist/capture-trace-types.js +23 -0
  8. package/dist/capture.d.ts +193 -0
  9. package/dist/capture.js +344 -0
  10. package/dist/execution-trace.d.ts +61 -0
  11. package/dist/execution-trace.js +299 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +9 -0
  14. package/dist/instruction-coverage.d.ts +106 -0
  15. package/dist/instruction-coverage.js +253 -0
  16. package/dist/journal.d.ts +17 -0
  17. package/dist/lint.d.ts +8 -1
  18. package/dist/lint.js +31 -2
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +24 -0
  22. package/dist/report.js +24 -0
  23. package/dist/reps.d.ts +14 -1
  24. package/dist/reps.js +28 -2
  25. package/dist/rescore.js +11 -2
  26. package/dist/restamp.d.ts +68 -0
  27. package/dist/restamp.js +194 -0
  28. package/dist/results.d.ts +128 -6
  29. package/dist/results.js +155 -6
  30. package/dist/run.js +114 -8
  31. package/dist/seeded.d.ts +11 -0
  32. package/dist/seeded.js +31 -7
  33. package/dist/sources.d.ts +50 -0
  34. package/dist/sources.js +215 -7
  35. package/dist/spec-write.d.ts +62 -0
  36. package/dist/spec-write.js +106 -0
  37. package/dist/spec.d.ts +29 -0
  38. package/dist/spec.js +55 -0
  39. package/dist/stability.js +26 -4
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/workspace.d.ts +36 -0
  43. package/dist/workspace.js +61 -0
  44. package/package.json +1 -1
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.d.ts CHANGED
@@ -174,6 +174,56 @@ export declare function describeSourceKey(key: string): string;
174
174
  export declare function remedyForKey(key: string): string;
175
175
  /** The skill-text key. Skill-wide: it belongs to every scenario at once. */
176
176
  export declare const SKILL_KEY = "SKILL.md";
177
+ /**
178
+ * The model-visible digests of the two prompt documents a run delivers: the skill text
179
+ * and any `system_prompt_file`. They measure what the model RECEIVES, where the bare
180
+ * keys above (`SKILL.md`, `<agent path>`) measure the file's raw bytes.
181
+ *
182
+ * | key | contents |
183
+ * |---|---|
184
+ * | `skill:prompt` | SKILL.md's body + its model-visible frontmatter |
185
+ * | `prompt:<path>` | the same, for one `system_prompt_file` |
186
+ *
187
+ * Both are prefixed, so a skill-harness OLDER than this one reads them through
188
+ * `currentHashFor`'s unknown-prefix guard and reports "not comparable" rather than a
189
+ * confident wrong finding about a source that is fine.
190
+ */
191
+ export declare const SKILL_PROMPT_KEY = "skill:prompt";
192
+ export declare const PROMPT_PREFIX = "prompt:";
193
+ /**
194
+ * The digest of a prompt document: its body verbatim, plus every frontmatter field the
195
+ * model can actually receive.
196
+ *
197
+ * The gate exists to protect published claims, so "the current text" has to mean the text
198
+ * the model was given. Hashing the raw bytes — which is what shipped through 0.7.0 —
199
+ * charged a full re-run for edits no graded run could observe: measured on the reference
200
+ * corpus, adding one `allowed-tools:` key to seven SKILL.md files with byte-identical
201
+ * bodies took lint from 2 findings to 22, and 20 of those demanded paid re-waves.
202
+ *
203
+ * Versioned in the hashed tuple (`prompt-doc/1`) because this is a stored-hash format: if
204
+ * the rule for "model-visible" ever changes, the tag changes with it and old digests stay
205
+ * legible as what they were.
206
+ */
207
+ export declare function promptDocDigest(text: string): string;
208
+ /** `promptDocDigest` of a file, or null when it doesn't exist / isn't readable. */
209
+ export declare function promptDocDigestOfFile(path: string): string | null;
210
+ /**
211
+ * True when `key` is a raw-bytes key whose model-visible counterpart THIS RUN also
212
+ * recorded — in which case the counterpart is the honest comparison and the raw-bytes one
213
+ * must be skipped.
214
+ *
215
+ * Both keys keep being written, and this is why: an older skill-harness reading a newer
216
+ * results.yaml still finds the bare `SKILL.md` key and keeps checking it, and `restamp`
217
+ * needs the raw-bytes hash as the proof it upgrades from. Superseding at COMPARISON time
218
+ * rather than dropping the key at record time is what buys both.
219
+ *
220
+ * `UNREADABLE` on EITHER side supersedes nothing. If the counterpart is unreadable there
221
+ * is nothing better to compare against; and if the raw-bytes key itself is unreadable, the
222
+ * run never verified that document at all — a fact lint must keep reporting, since the
223
+ * one thing this module refuses to do is let an unhashed source drop out of checking for
224
+ * the life of a result.
225
+ */
226
+ export declare function isSupersededKey(key: string, recorded: Record<string, string>): boolean;
177
227
  /**
178
228
  * Every recorded key whose drift could change THIS scenario's verdict — excluding the
179
229
  * two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle