@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/adapters/types.d.ts +37 -0
- package/dist/adjudication.d.ts +210 -0
- package/dist/adjudication.js +392 -0
- package/dist/affected.d.ts +88 -0
- package/dist/affected.js +222 -0
- package/dist/capture-trace-types.d.ts +228 -0
- package/dist/capture-trace-types.js +23 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +344 -0
- package/dist/execution-trace.d.ts +61 -0
- package/dist/execution-trace.js +299 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/instruction-coverage.d.ts +106 -0
- package/dist/instruction-coverage.js +253 -0
- package/dist/journal.d.ts +17 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +23 -0
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +24 -0
- package/dist/report.js +24 -0
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.js +114 -8
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.js +40 -3
- package/dist/spec-write.d.ts +62 -0
- package/dist/spec-write.js +106 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +55 -0
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- 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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
273
|
-
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|