@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.
- 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 +9 -0
- package/dist/index.js +9 -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 +8 -1
- package/dist/lint.js +31 -2
- 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/restamp.d.ts +68 -0
- package/dist/restamp.js +194 -0
- 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.d.ts +50 -0
- package/dist/sources.js +215 -7
- 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/stability.js +26 -4
- 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.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
|