@skill-harness/core 0.5.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.
Files changed (46) 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 +16 -1
  18. package/dist/lint.js +52 -0
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +48 -0
  22. package/dist/report.js +39 -1
  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/results.d.ts +128 -6
  27. package/dist/results.js +155 -6
  28. package/dist/run.d.ts +9 -1
  29. package/dist/run.js +129 -9
  30. package/dist/seeded.d.ts +11 -0
  31. package/dist/seeded.js +31 -7
  32. package/dist/sources.d.ts +26 -0
  33. package/dist/sources.js +82 -3
  34. package/dist/spec-write.d.ts +62 -0
  35. package/dist/spec-write.js +106 -0
  36. package/dist/spec.d.ts +29 -0
  37. package/dist/spec.js +55 -0
  38. package/dist/stability.d.ts +144 -0
  39. package/dist/stability.js +232 -0
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/trends.d.ts +28 -0
  43. package/dist/trends.js +76 -61
  44. package/dist/workspace.d.ts +36 -0
  45. package/dist/workspace.js +61 -0
  46. package/package.json +1 -1
package/dist/results.d.ts CHANGED
@@ -14,6 +14,56 @@ export interface ScenarioResult {
14
14
  clean?: number;
15
15
  flakiness?: number;
16
16
  pass_threshold?: number;
17
+ /**
18
+ * Objective trace-gate evidence. ADDITIVE and optional.
19
+ *
20
+ * Absent means "the scenario declared no trace assertions" — NOT an objective
21
+ * pass. Anything reading this must treat the two as different; collapsing them
22
+ * would silently upgrade every legacy result to "objectively verified".
23
+ */
24
+ objective?: ObjectiveResult;
25
+ /**
26
+ * Confidence-aware adjudication. ADDITIVE and optional.
27
+ *
28
+ * Absent means historical single-judge behavior — NOT that judges agreed. An
29
+ * unresolved adjudication additionally sets `suspect: true`, which is what
30
+ * actually blocks SHIP; this field is the audit trail behind that flag.
31
+ */
32
+ adjudication?: AdjudicationResult;
33
+ }
34
+ /** One judge's answer for a cell, kept verbatim however the collapse turned out. */
35
+ export interface Judgment {
36
+ /** 1-based: judgment 1 is the first-wave judge, 2 the secondary, 3 the tie-break. */
37
+ ordinal: number;
38
+ judge: {
39
+ provider: string;
40
+ model: string;
41
+ };
42
+ verdict: Verdict;
43
+ reason: string;
44
+ /** The judge misfired — recorded, never counted as a clean vote. */
45
+ suspect: boolean;
46
+ }
47
+ export interface AdjudicationResult {
48
+ state: "confirmed" | "tie_broken" | "unresolved";
49
+ /** Why the cell was re-judged. */
50
+ trigger: "ambiguous" | "contradictory" | "non_unanimous" | "ship_deciding";
51
+ /** Every judgment, in order. Never pruned — an author resolving this needs all of them. */
52
+ judgments: Judgment[];
53
+ /** The collapsed answer. Absent when unresolved. */
54
+ verdict?: "PASS" | "FAIL";
55
+ }
56
+ /** Objective gate outcome for one scenario cell. */
57
+ export interface ObjectiveResult {
58
+ /** ERROR means the evidence was missing or malformed — never a pass. */
59
+ status: "PASS" | "FAIL" | "ERROR";
60
+ trace_version?: number;
61
+ trace_sha256?: string;
62
+ assertions: {
63
+ kind: string;
64
+ status: "PASS" | "FAIL" | "ERROR";
65
+ detail: string;
66
+ }[];
17
67
  }
18
68
  export interface GradeSummary {
19
69
  passed: number;
@@ -56,16 +106,23 @@ export interface ResultsFile {
56
106
  harness_cli_version?: string;
57
107
  /**
58
108
  * `pass` when this run proved, before spending the wave, that the skill body was
59
- * reachable in the model's context (see canary.ts). Absent means the probe was
60
- * not asked for never that it failed, because a failed canary aborts the run
61
- * and no results.yaml is written.
109
+ * reachable in the model's context (see canary.ts). `skipped` when the probe was
110
+ * asked for but could not be performed SKILL.md has no `## ` heading to quote
111
+ * back, so no reply could prove anything. Absent means the probe was not asked
112
+ * for — never that it failed, because a failed canary aborts the run and no
113
+ * results.yaml is written.
114
+ *
115
+ * `skipped` exists because absent and skipped were the same value: a user who
116
+ * passed `--canary` precisely because pi ≥ 0.83.0 delivery is unreliable got a
117
+ * silently degraded probe, a fully billed wave, and a committed results.yaml
118
+ * byte-identical to a run where delivery was never checked at all.
62
119
  *
63
120
  * Only green runs can carry it: red delivers nothing by design and force delivers
64
121
  * through the system prompt. It is provenance for the *validity* of a green run,
65
122
  * which is why it lives here rather than only in the journal — `journal.jsonl` is
66
123
  * gitignored, and this claim has to survive a commit.
67
124
  */
68
- delivery_canary?: "pass";
125
+ delivery_canary?: "pass" | "skipped";
69
126
  skill: string;
70
127
  harness: string;
71
128
  model: string;
@@ -152,12 +209,35 @@ export declare function runDirFor(skillDir: string, harness: string, model: Mode
152
209
  export declare function transcriptPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
153
210
  export declare function reportPath(runDir: string): string;
154
211
  export declare function resultsPath(runDir: string): string;
155
- /** The verdict that counts: author override when present, else the judge's. */
212
+ /**
213
+ * The verdict that counts: author override when present, else the objective
214
+ * gate, else the judge's.
215
+ *
216
+ * **The objective gate outranks the judge.** `assert.trace` is a mechanical
217
+ * statement about what the model DID — it called `write`, it touched `.env`.
218
+ * The judge is an LLM reading prose. When they disagree, the measurement wins.
219
+ *
220
+ * This is the only place that ordering is enforced, and it has to be here.
221
+ * `objective` used to reach the ship decision solely through `gatePrefix` in
222
+ * `run.ts`, which forces a single rep's verdict — so every path that recomputed
223
+ * a verdict afterwards silently dropped the gate while keeping the `objective`
224
+ * block that claimed it was enforced. Three of them did: `--reps N` out-voted an
225
+ * objective FAIL 2-to-1 (100%, grade A, SHIP, on a CRITICAL scenario that called
226
+ * a forbidden tool), `regrade` re-judged from a transcript the tool calls are
227
+ * absent from, and `regate` recomputed it. `reps.ts` already states the policy —
228
+ * "one rep that called a forbidden tool is a real finding, not a minority draw
229
+ * to be voted away" — and nothing enforced it.
230
+ *
231
+ * An author override still wins, exactly as it does over `suspect`. Overriding a
232
+ * deterministic assertion is a deliberate, recorded human act — and the failure
233
+ * this guards against was never a human deciding, it was nobody deciding.
234
+ */
156
235
  export declare function effectiveVerdicts(scenarios: ScenarioResult[]): ScenarioVerdict[];
157
236
  /**
158
237
  * The ONLY place effective_grade is computed. Every writer goes through here,
159
238
  * so a persisted grade can never disagree with verdicts + overrides.
160
- * ctx is null for unscored (red/force) runs.
239
+ * ctx is null for unscored runs — `red` only, since 0.5.0: `force` is a real
240
+ * deployment and is scored (see SCORED_MODES directly above).
161
241
  */
162
242
  export declare function finalizeResults(draft: ResultsDraft, ctx: ScoreContext | null): ResultsFile;
163
243
  /** Finalize + persist results.yaml (creating the run dir). Returns what was written. */
@@ -207,8 +287,50 @@ export declare function findJudgeRawFiles(runDir: string, scenarioId: string, mo
207
287
  * generated evidence, ignored like transcripts, not committed like results.yaml.
208
288
  */
209
289
  export declare function diffPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
290
+ /**
291
+ * What happens to one piece of recorded evidence when a command rebuilds a result.
292
+ *
293
+ * - `carry` — the command did not re-measure this, so the prior value still
294
+ * describes the run and must survive.
295
+ * - `fresh` — the command re-measured it; take the new value.
296
+ * - `drop` — the command invalidated it; a stale value would misinform.
297
+ */
298
+ export type EvidencePolicy = "carry" | "fresh" | "drop";
299
+ export interface RebuildPolicy {
300
+ objective: EvidencePolicy;
301
+ adjudication: EvidencePolicy;
302
+ }
303
+ /**
304
+ * Rebuild a `ScenarioResult` after a command re-measured part of it.
305
+ *
306
+ * **The single choke point for every rewriter**, and exhaustive by construction:
307
+ * every field is destructured below, so adding one to `ScenarioResult` fails the
308
+ * build HERE until someone decides whether it is carried, taken fresh, or
309
+ * dropped. That guard is the whole point of the function.
310
+ *
311
+ * It exists because the ad-hoc version — `{ ...fresh, override: prior.override,
312
+ * note: prior.note }`, written independently in three places — silently dropped
313
+ * `objective` from `grade` and `adjudication` from `regate`. Both failures ran in
314
+ * the dangerous direction: a trace-gated scenario re-read as "no assertions
315
+ * declared", and an unresolved judge disagreement as a settled verdict. 1,036
316
+ * tests passed through it; a real smoke run caught it.
317
+ *
318
+ * The author's `override` and `note` are always carried and are not policy —
319
+ * no command re-measures a human's judgement.
320
+ */
321
+ export declare function rebuildScenarioResult(fresh: ScenarioResult, prior: ScenarioResult | undefined, policy: RebuildPolicy): ScenarioResult;
322
+ /**
323
+ * Where a rep's execution trace is saved: `<id>.<mode>[.rep<k>].trace.jsonl`.
324
+ *
325
+ * `.jsonl` rather than `.txt` so it is distinguishable at a glance from a
326
+ * transcript, and one JSON object per line so a multi-turn scenario's per-turn
327
+ * traces append without a wrapper.
328
+ */
329
+ export declare function tracePath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
210
330
  /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
211
331
  export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
332
+ /** A scenario's execution-trace files, sorted (plain first, then numeric rep). Mode-scoped when given. */
333
+ export declare function findTraceFiles(runDir: string, scenarioId: string, mode?: string): string[];
212
334
  /** A single representative transcript file for a scenario in a run dir. Null if none. */
213
335
  export declare function findTranscriptFile(runDir: string, scenarioId: string): string | null;
214
336
  /**
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.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { Spec } from "./spec.js";
2
2
  import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
3
3
  import { type ResultsFile } from "./results.js";
4
4
  import { type Lift } from "./lift.js";
5
+ import { type ScenarioStability } from "./stability.js";
5
6
  export interface RunOptions {
6
7
  spec: Spec;
7
8
  skillDir: string;
@@ -55,5 +56,12 @@ export declare function hasEmptyAssistantTurn(transcript: string): boolean;
55
56
  * exists. Passing it for a green run turns the scorecard from "the skill scored
56
57
  * B" into "the skill *did* this much" — without a baseline the grade alone can't
57
58
  * distinguish a skill that works from a model that never needed it.
59
+ *
60
+ * `stability` is the run-over-run half of the same argument, and it is derived from
61
+ * history rather than from this run: a cell that flipped its verdict between the last
62
+ * two runs of this skill × model × mode is worth less than the ✓ beside it suggests,
63
+ * and this run's own `flaky 0.00` cannot say so — it only ever looked at one run. Pass
64
+ * the cells for THIS tag and mode; anything else would report another model's history
65
+ * under this model's scorecard.
58
66
  */
59
- export declare function formatScorecard(summary: RunSummary, lift?: Lift): string;
67
+ export declare function formatScorecard(summary: RunSummary, lift?: Lift, stability?: ScenarioStability[]): string;
package/dist/run.js CHANGED
@@ -2,15 +2,18 @@ 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";
13
15
  import { runDeliveryCanary, canaryFailure } from "./canary.js";
16
+ import { boundaryCells, stabilityNote } from "./stability.js";
14
17
  /** Run one skill against one model: run scenarios, grade, score, persist. */
15
18
  export async function runSkillModel(opts) {
16
19
  const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
@@ -76,8 +79,13 @@ export async function runSkillModel(opts) {
76
79
  });
77
80
  if (canary.status === "fail")
78
81
  throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
79
- 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";
80
87
  log(` ⚠ delivery canary skipped — ${canary.detail}`);
88
+ }
81
89
  else {
82
90
  canaryStatus = "pass";
83
91
  log(` ✓ delivery canary — the model quoted its skill instructions back (\`${canary.anchor}\`)`);
@@ -167,6 +175,14 @@ async function runRep(scenario, rep, repCount, ctx) {
167
175
  transcript = `[workspace setup failed] ${gatePrefix}`;
168
176
  }
169
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;
170
186
  if (ws) {
171
187
  // A blank assistant turn is a harness timeout, not model behavior: retry ONCE in a
172
188
  // fresh workspace (the first attempt may have half-mutated a seeded repo), and if
@@ -177,24 +193,46 @@ async function runRep(scenario, rep, repCount, ctx) {
177
193
  log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} empty response — retrying once`);
178
194
  ws.cleanup();
179
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);
180
199
  }
181
200
  if (scenario.mode === "seeded") {
182
201
  const r = await runSeeded(scenario, {
183
202
  skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
184
203
  specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
204
+ trace: scenario.traceAssert ? { scenarioId: scenario.id, rep } : undefined,
185
205
  });
186
206
  transcript = r.transcript;
187
207
  gatePrefix = r.gateFailure;
188
208
  stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
209
+ traces = r.traces;
189
210
  }
190
211
  else {
191
- transcript = await ctx.adapter.run({
212
+ const req = {
192
213
  skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
193
214
  // resolved like fixtures: relative to the spec's dir
194
215
  systemPromptFile: scenario.systemPromptFile
195
216
  ? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
196
217
  : undefined,
197
- });
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
+ }
198
236
  }
199
237
  noResponse = hasEmptyAssistantTurn(transcript);
200
238
  if (!noResponse)
@@ -213,10 +251,77 @@ async function runRep(scenario, rep, repCount, ctx) {
213
251
  }
214
252
  appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
215
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
+ }
216
316
  let verdict;
217
317
  let reason;
218
318
  let suspect = false;
219
- 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) {
220
325
  verdict = "ERROR";
221
326
  reason = "model produced no response after a retry (harness timeout?) — infra, not skill behavior";
222
327
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
@@ -237,7 +342,7 @@ async function runRep(scenario, rep, repCount, ctx) {
237
342
  suspect = o.suspect; // judgeOneRep already journaled (verdict + misfire)
238
343
  }
239
344
  log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
240
- return { verdict, reason, suspect };
345
+ return { verdict, reason, suspect, objective };
241
346
  }
242
347
  finally {
243
348
  ws?.cleanup();
@@ -251,8 +356,15 @@ async function runRep(scenario, rep, repCount, ctx) {
251
356
  * exists. Passing it for a green run turns the scorecard from "the skill scored
252
357
  * B" into "the skill *did* this much" — without a baseline the grade alone can't
253
358
  * distinguish a skill that works from a model that never needed it.
359
+ *
360
+ * `stability` is the run-over-run half of the same argument, and it is derived from
361
+ * history rather than from this run: a cell that flipped its verdict between the last
362
+ * two runs of this skill × model × mode is worth less than the ✓ beside it suggests,
363
+ * and this run's own `flaky 0.00` cannot say so — it only ever looked at one run. Pass
364
+ * the cells for THIS tag and mode; anything else would report another model's history
365
+ * under this model's scorecard.
254
366
  */
255
- export function formatScorecard(summary, lift) {
367
+ export function formatScorecard(summary, lift, stability) {
256
368
  const { results } = summary;
257
369
  const g = results.effective_grade;
258
370
  const lines = [];
@@ -283,12 +395,20 @@ export function formatScorecard(summary, lift) {
283
395
  // Said on the scorecard, not just in the docs: the one thing that can invalidate
284
396
  // a green number is invisible in the number. `harness_cli_version` is recorded
285
397
  // beside the verdicts so a reader can tell which pi produced them.
286
- 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") {
287
401
  lines.push(` NOTE: green delivery is harness-version-dependent` +
288
402
  (results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
289
403
  ` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
290
404
  ` Use --mode force for delivery that cannot silently degrade, or --canary to prove it per run.`);
291
405
  }
406
+ // Boundary cells last, because they qualify the verdicts above: a ✓ on a cell that
407
+ // flipped between the last two runs is one draw, whatever its rep count said.
408
+ const ran = new Set(results.scenarios.map((s) => s.id));
409
+ for (const s of boundaryCells(stability ?? []).filter((c) => ran.has(c.id))) {
410
+ lines.push(` ⇄ ${stabilityNote(s)}`);
411
+ }
292
412
  return lines.join("\n");
293
413
  }
294
414
  //# sourceMappingURL=run.js.map
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*,