@skill-harness/core 0.7.0 → 0.9.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 +8 -0
- package/dist/adjudication.d.ts +1 -0
- package/dist/adjudication.js +26 -4
- package/dist/capture-trace-types.d.ts +19 -0
- package/dist/comparison.d.ts +96 -0
- package/dist/comparison.js +183 -0
- package/dist/execution-trace.js +93 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lint.d.ts +7 -0
- package/dist/lint.js +14 -2
- package/dist/regate.d.ts +1 -1
- package/dist/regate.js +94 -21
- package/dist/regrade.d.ts +4 -0
- package/dist/regrade.js +29 -5
- package/dist/report.d.ts +7 -0
- package/dist/report.js +6 -0
- package/dist/reps.d.ts +8 -1
- package/dist/reps.js +47 -13
- package/dist/rescore.js +2 -2
- package/dist/restamp.d.ts +68 -0
- package/dist/restamp.js +194 -0
- package/dist/results.d.ts +28 -0
- package/dist/results.js +56 -4
- package/dist/run.js +96 -35
- package/dist/sandbox.d.ts +32 -0
- package/dist/sandbox.js +12 -0
- package/dist/score.d.ts +3 -1
- package/dist/score.js +20 -5
- package/dist/seeded.d.ts +7 -1
- package/dist/seeded.js +29 -8
- package/dist/sources.d.ts +50 -0
- package/dist/sources.js +186 -7
- package/dist/spec.d.ts +15 -0
- package/dist/spec.js +53 -1
- package/dist/stability.js +26 -4
- package/dist/trace-gates.d.ts +2 -1
- package/dist/trace-gates.js +4 -1
- package/dist/trajectory-gates.d.ts +137 -0
- package/dist/trajectory-gates.js +592 -0
- package/package.json +5 -3
- package/schemas/results-v2.schema.json +133 -0
- package/schemas/specification-v1.schema.json +217 -0
- package/schemas/trajectory-event-v1.schema.json +61 -0
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function withSandbox(backend, request, run) {
|
|
2
|
+
const session = await backend.prepare(request);
|
|
3
|
+
try {
|
|
4
|
+
const value = await run(session);
|
|
5
|
+
const diff = await session.captureDiff();
|
|
6
|
+
return { value, diff, backend: backend.name, containment: backend.containment };
|
|
7
|
+
}
|
|
8
|
+
finally {
|
|
9
|
+
await session.cleanup();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=sandbox.js.map
|
package/dist/score.d.ts
CHANGED
|
@@ -22,12 +22,14 @@ export interface ScoreResult {
|
|
|
22
22
|
criticalFails: number;
|
|
23
23
|
bSeriesFails: number;
|
|
24
24
|
suspectCount: number;
|
|
25
|
+
errorCount: number;
|
|
25
26
|
note: string;
|
|
26
27
|
}
|
|
27
28
|
export declare function letterFor(pct: number): string;
|
|
28
29
|
/**
|
|
29
30
|
* Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
|
|
30
|
-
* on verdict PASS; FAIL
|
|
31
|
+
* on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS are excluded as
|
|
32
|
+
* infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
|
|
31
33
|
* unresolved judge misfire) is excluded from both `passed` and `total` — it is
|
|
32
34
|
* untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
|
|
33
35
|
* until an author override resolves it. SHIP otherwise requires: enough total
|
package/dist/score.js
CHANGED
|
@@ -11,7 +11,8 @@ export function letterFor(pct) {
|
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
|
|
14
|
-
* on verdict PASS; FAIL
|
|
14
|
+
* on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS are excluded as
|
|
15
|
+
* infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
|
|
15
16
|
* unresolved judge misfire) is excluded from both `passed` and `total` — it is
|
|
16
17
|
* untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
|
|
17
18
|
* until an author override resolves it. SHIP otherwise requires: enough total
|
|
@@ -25,11 +26,16 @@ export function score(verdicts, input) {
|
|
|
25
26
|
let criticalFails = 0;
|
|
26
27
|
let bSeriesFails = 0;
|
|
27
28
|
let suspectCount = 0;
|
|
29
|
+
let errorCount = 0;
|
|
28
30
|
for (const v of verdicts) {
|
|
29
31
|
if (v.suspect) {
|
|
30
32
|
suspectCount++;
|
|
31
33
|
continue; // untrustworthy: neither pass nor fail
|
|
32
34
|
}
|
|
35
|
+
if (v.verdict === "ERROR" || v.verdict === "JUDGE-AMBIGUOUS") {
|
|
36
|
+
errorCount++;
|
|
37
|
+
continue; // infrastructure/indeterminate: neither behavioral pass nor fail
|
|
38
|
+
}
|
|
33
39
|
total++;
|
|
34
40
|
if (v.verdict === "PASS") {
|
|
35
41
|
passed++;
|
|
@@ -42,21 +48,30 @@ export function score(verdicts, input) {
|
|
|
42
48
|
}
|
|
43
49
|
const pct = total > 0 ? Math.round((passed * 100) / total) : 0;
|
|
44
50
|
const letter = letterFor(pct);
|
|
45
|
-
const
|
|
51
|
+
const validBar = Number.isInteger(shipBar.total) && shipBar.total >= 1 && Number.isInteger(shipBar.min_pass) && shipBar.min_pass >= 1 && shipBar.min_pass <= shipBar.total;
|
|
52
|
+
const ship = validBar &&
|
|
53
|
+
total >= shipBar.total &&
|
|
46
54
|
passed >= shipBar.min_pass &&
|
|
47
55
|
(!shipBar.no_critical_fail || criticalFails === 0) &&
|
|
48
56
|
bSeriesFails === 0 &&
|
|
49
|
-
suspectCount === 0
|
|
57
|
+
suspectCount === 0 &&
|
|
58
|
+
errorCount === 0;
|
|
50
59
|
let note = "";
|
|
51
|
-
if (
|
|
60
|
+
if (!validBar) {
|
|
61
|
+
note = "invalid ship bar: total/min_pass must be positive integers with min_pass <= total";
|
|
62
|
+
}
|
|
63
|
+
else if (suspectCount > 0) {
|
|
52
64
|
note = `${suspectCount} suspect: re-judge/resolve`;
|
|
53
65
|
}
|
|
66
|
+
else if (errorCount > 0) {
|
|
67
|
+
note = `${errorCount} infrastructure error${errorCount === 1 ? "" : "s"}: retry/repair evidence`;
|
|
68
|
+
}
|
|
54
69
|
else if (criticalFails > 0) {
|
|
55
70
|
note = `gated: ${criticalFails} critical fail${criticalFails === 1 ? "" : "s"}`;
|
|
56
71
|
}
|
|
57
72
|
else if (bSeriesFails > 0) {
|
|
58
73
|
note = `gated: ${bSeriesFails} B-series fail${bSeriesFails === 1 ? "" : "s"}`;
|
|
59
74
|
}
|
|
60
|
-
return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, note };
|
|
75
|
+
return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, note };
|
|
61
76
|
}
|
|
62
77
|
//# sourceMappingURL=score.js.map
|
package/dist/seeded.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Scenario } from "./spec.js";
|
|
2
2
|
import type { ExecutionTraceV1 } from "./capture-trace-types.js";
|
|
3
|
+
import type { TrajectoryEventV1 } from "./trajectory-gates.js";
|
|
3
4
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
4
5
|
import { type ExecResult } from "./util/exec.js";
|
|
5
6
|
interface SeededOpts {
|
|
@@ -42,9 +43,14 @@ export type VitestRun = ExecResult;
|
|
|
42
43
|
export interface SeededOutcome {
|
|
43
44
|
transcript: string;
|
|
44
45
|
gateFailure: string | null;
|
|
46
|
+
gateError: string | null;
|
|
45
47
|
diff: string;
|
|
46
|
-
/** One per turn; empty unless
|
|
48
|
+
/** One per turn; empty unless structured execution was requested. */
|
|
47
49
|
traces: ExecutionTraceV1[];
|
|
50
|
+
/** Adapter-neutral workflow/tool events for assert.trajectory. */
|
|
51
|
+
events: TrajectoryEventV1[];
|
|
52
|
+
/** Required native sources that could not be read or normalized. */
|
|
53
|
+
eventErrors: string[];
|
|
48
54
|
}
|
|
49
55
|
/**
|
|
50
56
|
* The added/removed lines of a unified diff — what the model actually *changed*,
|
package/dist/seeded.js
CHANGED
|
@@ -168,12 +168,15 @@ export async function runSeeded(scenario, opts) {
|
|
|
168
168
|
cwd: repo,
|
|
169
169
|
// Resolved against the spec dir, exactly like fixtures and post-tests.
|
|
170
170
|
extensions: scenario.extensions?.map((e) => resolve(opts.specDir, e)),
|
|
171
|
+
eventSources: scenario.eventSources,
|
|
171
172
|
};
|
|
172
173
|
// A trace-gated seeded scenario runs through the structured path so the tool
|
|
173
174
|
// calls are recorded; everything downstream (gates, diff, transcript) is
|
|
174
175
|
// identical, because the rebuilt transcript is what print mode would have
|
|
175
176
|
// emitted anyway.
|
|
176
177
|
let traces = [];
|
|
178
|
+
let events = [];
|
|
179
|
+
let eventErrors = [];
|
|
177
180
|
let harnessOut;
|
|
178
181
|
if (opts.trace) {
|
|
179
182
|
if (!opts.adapter.runStructured) {
|
|
@@ -187,12 +190,15 @@ export async function runSeeded(scenario, opts) {
|
|
|
187
190
|
});
|
|
188
191
|
harnessOut = structured.transcript;
|
|
189
192
|
traces = structured.traces;
|
|
193
|
+
events = structured.events ?? [];
|
|
194
|
+
eventErrors = structured.eventErrors ?? [];
|
|
190
195
|
}
|
|
191
196
|
else {
|
|
192
197
|
harnessOut = await opts.adapter.run(req);
|
|
193
198
|
}
|
|
194
199
|
const parts = [harnessOut, "", "=== SEEDED GATES ==="];
|
|
195
200
|
let gateFailure = null;
|
|
201
|
+
let gateError = null;
|
|
196
202
|
const runVitest = opts.runVitest ??
|
|
197
203
|
((args, cwd) => exec("npx", ["vitest", "run", ...args], { cwd, timeoutMs: VITEST_TIMEOUT_MS }));
|
|
198
204
|
// `exec` never throws on a non-zero exit and, on timeout, SIGKILLs the child and
|
|
@@ -213,7 +219,8 @@ export async function runSeeded(scenario, opts) {
|
|
|
213
219
|
(gitFailure.stderr.trim() ? `: ${gitFailure.stderr.trim().split("\n")[0]}` : "");
|
|
214
220
|
parts.push(` staged diff: ERROR (${msg})`);
|
|
215
221
|
gateFailure = msg;
|
|
216
|
-
|
|
222
|
+
gateError = msg;
|
|
223
|
+
return finish(parts, gateFailure, gateError, diff, traces, events, eventErrors);
|
|
217
224
|
}
|
|
218
225
|
// BOTH needle gates read the changed lines only, never context. A unified diff
|
|
219
226
|
// carries three lines of context per hunk, so an untouched symbol near the edit
|
|
@@ -241,10 +248,14 @@ export async function runSeeded(scenario, opts) {
|
|
|
241
248
|
? ` vitest run: ERROR (timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior)`
|
|
242
249
|
: ` vitest run: ${passed ? "PASS" : `FAIL (exit ${v.code})`}`);
|
|
243
250
|
parts.push(indent(bothStreams(v)));
|
|
244
|
-
if (!passed
|
|
245
|
-
|
|
251
|
+
if (!passed) {
|
|
252
|
+
const problem = killed
|
|
246
253
|
? `vitest timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior`
|
|
247
254
|
: `vitest failed (exit ${v.code})`;
|
|
255
|
+
if (!gateFailure)
|
|
256
|
+
gateFailure = problem;
|
|
257
|
+
if (killed)
|
|
258
|
+
gateError = problem;
|
|
248
259
|
}
|
|
249
260
|
}
|
|
250
261
|
const postTest = scenario.assert?.post_test;
|
|
@@ -263,6 +274,7 @@ export async function runSeeded(scenario, opts) {
|
|
|
263
274
|
parts.push(` post_test: ERROR (${msg})`);
|
|
264
275
|
if (!gateFailure)
|
|
265
276
|
gateFailure = msg;
|
|
277
|
+
gateError = msg;
|
|
266
278
|
}
|
|
267
279
|
else {
|
|
268
280
|
// `.test` is part of the name we build, never taken from the source: extname()
|
|
@@ -281,7 +293,8 @@ export async function runSeeded(scenario, opts) {
|
|
|
281
293
|
parts.push(` post_test: ERROR (${msg})`);
|
|
282
294
|
if (!gateFailure)
|
|
283
295
|
gateFailure = msg;
|
|
284
|
-
|
|
296
|
+
gateError = msg;
|
|
297
|
+
return finish(parts, gateFailure, gateError, diff, traces, events, eventErrors);
|
|
285
298
|
}
|
|
286
299
|
const v = await runVitest([POST_TEST_BASE], repo);
|
|
287
300
|
const out = `${v.stdout}\n${v.stderr}`;
|
|
@@ -300,13 +313,17 @@ export async function runSeeded(scenario, opts) {
|
|
|
300
313
|
const notCollected = /^\s*No test files found/im.test(out);
|
|
301
314
|
const killed = v.code === null;
|
|
302
315
|
let problem = null;
|
|
316
|
+
let problemIsError = false;
|
|
303
317
|
if (killed) {
|
|
318
|
+
problemIsError = true;
|
|
304
319
|
problem = `post_test ${JSON.stringify(postTest)} timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior`;
|
|
305
320
|
}
|
|
306
321
|
else if (notCollected) {
|
|
322
|
+
problemIsError = true;
|
|
307
323
|
problem = `post_test ${JSON.stringify(postTest)} was never collected by vitest — spec/fixture error, not model behavior`;
|
|
308
324
|
}
|
|
309
325
|
else if (tally === null) {
|
|
326
|
+
problemIsError = true;
|
|
310
327
|
problem = `post_test ${JSON.stringify(postTest)} produced no parseable vitest summary (exit ${v.code}) — cannot confirm it ran`;
|
|
311
328
|
}
|
|
312
329
|
else if (v.code !== 0 || tally.failed > 0) {
|
|
@@ -317,20 +334,24 @@ export async function runSeeded(scenario, opts) {
|
|
|
317
334
|
problem = `post_test ${JSON.stringify(postTest)} failed (exit ${v.code})`;
|
|
318
335
|
}
|
|
319
336
|
else if (tally.skipped > 0 || tally.todo > 0) {
|
|
337
|
+
problemIsError = true;
|
|
320
338
|
problem = `post_test ${JSON.stringify(postTest)} has ${tally.skipped + tally.todo} skipped/todo test(s) — a hidden gate must actually run; spec error, not model behavior`;
|
|
321
339
|
}
|
|
322
340
|
else if (tally.passed === 0) {
|
|
341
|
+
problemIsError = true;
|
|
323
342
|
problem = `post_test ${JSON.stringify(postTest)} ran no assertions — spec error, not model behavior`;
|
|
324
343
|
}
|
|
325
344
|
parts.push(problem === null
|
|
326
345
|
? ` post_test ${JSON.stringify(postTest)}: PASS (${tally.passed} assertion-bearing test(s))`
|
|
327
|
-
: ` post_test ${JSON.stringify(postTest)}: ${
|
|
346
|
+
: ` post_test ${JSON.stringify(postTest)}: ${problemIsError ? "ERROR" : "FAIL"} (${problem})`);
|
|
328
347
|
parts.push(indent(bothStreams(v)));
|
|
329
348
|
if (problem && !gateFailure)
|
|
330
349
|
gateFailure = problem;
|
|
350
|
+
if (problem && problemIsError)
|
|
351
|
+
gateError = problem;
|
|
331
352
|
}
|
|
332
353
|
}
|
|
333
|
-
return finish(parts, gateFailure, diff, traces);
|
|
354
|
+
return finish(parts, gateFailure, gateError, diff, traces, events, eventErrors);
|
|
334
355
|
}
|
|
335
356
|
function git(cwd, args) {
|
|
336
357
|
return exec("git", args, { cwd, timeoutMs: 30_000 });
|
|
@@ -364,13 +385,13 @@ function bothStreams(v) {
|
|
|
364
385
|
return o || e;
|
|
365
386
|
}
|
|
366
387
|
/** 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. */
|
|
367
|
-
function finish(parts, gateFailure, diff, traces = []) {
|
|
388
|
+
function finish(parts, gateFailure, gateError, diff, traces = [], events = [], eventErrors = []) {
|
|
368
389
|
// The code itself, last — the gates above only prove that keywords appeared.
|
|
369
390
|
// Without this section a seeded checklist item about what the code *does* is
|
|
370
391
|
// graded from the model's own description of its work.
|
|
371
392
|
parts.push("", "=== STAGED DIFF ===");
|
|
372
393
|
parts.push(diff.trim() === "" ? " (empty — the model left no staged changes)" : capDiff(diff));
|
|
373
|
-
return { transcript: parts.join("\n"), gateFailure, diff, traces };
|
|
394
|
+
return { transcript: parts.join("\n"), gateFailure, gateError, diff, traces, events, eventErrors };
|
|
374
395
|
}
|
|
375
396
|
/**
|
|
376
397
|
* 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
|
package/dist/sources.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import yaml from "js-yaml";
|
|
4
5
|
/**
|
|
5
6
|
* What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
|
|
6
7
|
* lint can prove a published result still describes the current inputs.
|
|
@@ -167,7 +168,7 @@ function walk(dir, prefix = "") {
|
|
|
167
168
|
* transcripts are actually invalid.
|
|
168
169
|
*/
|
|
169
170
|
function facets(s) {
|
|
170
|
-
const { id, title, critical, mode, turns, checklist, fixture, assert, traceAssert, workspace, remote, systemPromptFile, extensions, reps, passThreshold, covers: _coversIsMetadata, ...restScenario } = s;
|
|
171
|
+
const { id, title, critical, mode, turns, checklist, fixture, assert, traceAssert, trajectoryAssert, workspace, remote, systemPromptFile, extensions, eventSources, reps, passThreshold, covers: _coversIsMetadata, ...restScenario } = s;
|
|
171
172
|
const _scenarioExhaustive = restScenario;
|
|
172
173
|
void _scenarioExhaustive;
|
|
173
174
|
// `covers` is destructured into a discard on purpose, and this comment is the
|
|
@@ -184,7 +185,7 @@ function facets(s) {
|
|
|
184
185
|
// already saved, so `regate` (free) can re-answer it without re-running the model.
|
|
185
186
|
// Note the asymmetry with `env.extensions` in Phase 3, which IS stimulus — one
|
|
186
187
|
// changes what gets executed, the other only what we conclude from it.
|
|
187
|
-
const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined;
|
|
188
|
+
const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined || trajectoryAssert !== undefined;
|
|
188
189
|
return {
|
|
189
190
|
// `vitest` and the `post_test` PATH are stimulus, not gates: both change what the
|
|
190
191
|
// run executes in the workspace, and neither can be re-evaluated from a saved
|
|
@@ -203,13 +204,21 @@ function facets(s) {
|
|
|
203
204
|
id, mode, turns, workspace, remote, systemPromptFile ?? null,
|
|
204
205
|
fixture ?? null, vitest ?? null, post_test ?? null,
|
|
205
206
|
...(extensions ? [extensions] : []),
|
|
207
|
+
// Event-source paths choose which native ledgers are captured. A saved
|
|
208
|
+
// normalized artifact cannot answer for a source that was never collected,
|
|
209
|
+
// so changing this is stimulus and needs a re-run.
|
|
210
|
+
...(eventSources ? [eventSources] : []),
|
|
206
211
|
]),
|
|
207
212
|
rubric: JSON.stringify([id, title, checklist]),
|
|
208
213
|
policy: JSON.stringify([id, critical, reps ?? null, passThreshold ?? null]),
|
|
209
214
|
// Same rule as `stimulus` above: conditional, so a needle-gated scenario that
|
|
210
215
|
// declares no trace assertions keeps the digest it was published with.
|
|
211
216
|
gates: hasGates
|
|
212
|
-
? JSON.stringify([
|
|
217
|
+
? JSON.stringify([
|
|
218
|
+
id, diff_contains ?? null, diff_excludes ?? null,
|
|
219
|
+
...(traceAssert ? [traceAssert] : []),
|
|
220
|
+
...(trajectoryAssert ? [trajectoryAssert] : []),
|
|
221
|
+
])
|
|
213
222
|
: null,
|
|
214
223
|
};
|
|
215
224
|
}
|
|
@@ -292,7 +301,12 @@ export function sourceHashes(ctx) {
|
|
|
292
301
|
const hashes = {};
|
|
293
302
|
// UNREADABLE rather than omission on every branch below: a source we failed to
|
|
294
303
|
// hash must stay visible to lint, not vanish from the record. See UNREADABLE.
|
|
295
|
-
|
|
304
|
+
// Two digests of one file, and both are load-bearing. `SKILL.md` is the raw bytes,
|
|
305
|
+
// kept so an older skill-harness reading this record still has the key it knows and so
|
|
306
|
+
// `restamp` has a proof to upgrade from; `skill:prompt` is the text the model actually
|
|
307
|
+
// receives, and is the one lint compares (see `isSupersededKey`).
|
|
308
|
+
hashes[SKILL_KEY] = fileSha256(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
|
|
309
|
+
hashes[SKILL_PROMPT_KEY] = promptDocDigestOfFile(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
|
|
296
310
|
hashes[PERSONA_KEY] = personaDigest(ctx.judgePersona);
|
|
297
311
|
for (const s of ctx.scenarios) {
|
|
298
312
|
// Split, not combined: each facet's drift has a different cheapest remedy, and a
|
|
@@ -304,8 +318,13 @@ export function sourceHashes(ctx) {
|
|
|
304
318
|
const gates = gatesDigest(s);
|
|
305
319
|
if (gates !== null)
|
|
306
320
|
hashes[GATES_PREFIX + s.id] = gates;
|
|
321
|
+
// An agent file is a prompt document too, delivered with `--append-system-prompt`,
|
|
322
|
+
// and its `tools:` header is read by the harness rather than the model — so it gets
|
|
323
|
+
// the same pair of digests, for the same reasons, as SKILL.md above.
|
|
307
324
|
if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
|
|
308
|
-
|
|
325
|
+
const abs = resolve(ctx.specDir, s.systemPromptFile);
|
|
326
|
+
hashes[s.systemPromptFile] = fileSha256(abs) ?? UNREADABLE;
|
|
327
|
+
hashes[PROMPT_PREFIX + s.systemPromptFile] = promptDocDigestOfFile(abs) ?? UNREADABLE;
|
|
309
328
|
}
|
|
310
329
|
// Extension CONTENTS, not just the paths the stimulus digest already covers.
|
|
311
330
|
// An orchestration scenario's subagent tool lives in these files: editing one
|
|
@@ -344,8 +363,16 @@ export function sourceHashes(ctx) {
|
|
|
344
363
|
* from drifting: a new key kind is defined once, for both sides.
|
|
345
364
|
*/
|
|
346
365
|
export function currentHashFor(key, ctx) {
|
|
347
|
-
if (key ===
|
|
366
|
+
if (key === SKILL_KEY)
|
|
348
367
|
return fileSha256(resolve(ctx.skillDir, "SKILL.md"));
|
|
368
|
+
if (key === SKILL_PROMPT_KEY)
|
|
369
|
+
return promptDocDigestOfFile(resolve(ctx.skillDir, "SKILL.md"));
|
|
370
|
+
// Before the unknown-prefix guard below, which would otherwise swallow these as keys
|
|
371
|
+
// from a newer skill-harness. A deleted agent file must still resolve to null ("gone"),
|
|
372
|
+
// not undefined ("not comparable").
|
|
373
|
+
if (key.startsWith(PROMPT_PREFIX)) {
|
|
374
|
+
return promptDocDigestOfFile(resolve(ctx.specDir, key.slice(PROMPT_PREFIX.length)));
|
|
375
|
+
}
|
|
349
376
|
if (key === PERSONA_KEY)
|
|
350
377
|
return personaDigest(ctx.judgePersona);
|
|
351
378
|
// Facet keys, and the legacy combined key, all resolve per scenario id. A removed
|
|
@@ -383,6 +410,13 @@ export function currentHashFor(key, ctx) {
|
|
|
383
410
|
export function describeSourceKey(key) {
|
|
384
411
|
if (key === PERSONA_KEY)
|
|
385
412
|
return "the judge persona";
|
|
413
|
+
// Deliberately the same label as the raw-bytes key: only one of the pair is ever
|
|
414
|
+
// compared (see `isSupersededKey`), and a reader of a lint finding cares which FILE
|
|
415
|
+
// moved, not which of two digests of it was the one consulted.
|
|
416
|
+
if (key === SKILL_PROMPT_KEY)
|
|
417
|
+
return SKILL_KEY;
|
|
418
|
+
if (key.startsWith(PROMPT_PREFIX))
|
|
419
|
+
return key.slice(PROMPT_PREFIX.length);
|
|
386
420
|
if (key.startsWith(STIMULUS_PREFIX))
|
|
387
421
|
return `the stimulus for \`${key.slice(STIMULUS_PREFIX.length)}\``;
|
|
388
422
|
if (key.startsWith(RUBRIC_PREFIX))
|
|
@@ -427,6 +461,149 @@ export function remedyForKey(key) {
|
|
|
427
461
|
}
|
|
428
462
|
/** The skill-text key. Skill-wide: it belongs to every scenario at once. */
|
|
429
463
|
export const SKILL_KEY = "SKILL.md";
|
|
464
|
+
/**
|
|
465
|
+
* The model-visible digests of the two prompt documents a run delivers: the skill text
|
|
466
|
+
* and any `system_prompt_file`. They measure what the model RECEIVES, where the bare
|
|
467
|
+
* keys above (`SKILL.md`, `<agent path>`) measure the file's raw bytes.
|
|
468
|
+
*
|
|
469
|
+
* | key | contents |
|
|
470
|
+
* |---|---|
|
|
471
|
+
* | `skill:prompt` | SKILL.md's body + its model-visible frontmatter |
|
|
472
|
+
* | `prompt:<path>` | the same, for one `system_prompt_file` |
|
|
473
|
+
*
|
|
474
|
+
* Both are prefixed, so a skill-harness OLDER than this one reads them through
|
|
475
|
+
* `currentHashFor`'s unknown-prefix guard and reports "not comparable" rather than a
|
|
476
|
+
* confident wrong finding about a source that is fine.
|
|
477
|
+
*/
|
|
478
|
+
export const SKILL_PROMPT_KEY = "skill:prompt";
|
|
479
|
+
export const PROMPT_PREFIX = "prompt:";
|
|
480
|
+
/**
|
|
481
|
+
* Frontmatter keys the model never receives: capability declarations the HARNESS
|
|
482
|
+
* consumes to build a tool allowlist.
|
|
483
|
+
*
|
|
484
|
+
* Why a denylist and not an allowlist of "body + description". An allowlist makes the
|
|
485
|
+
* safe-looking choice the dangerous one: the day a harness starts putting some new
|
|
486
|
+
* frontmatter key in context, every published number silently stops being protected and
|
|
487
|
+
* nothing says so. A denylist fails the other way — a newly-invented inert key charges
|
|
488
|
+
* one unnecessary re-run until it is named here, which is a one-line patch and a visible
|
|
489
|
+
* complaint. This module already takes that side everywhere else (see `UNREADABLE`, and
|
|
490
|
+
* the unknown-prefix guard in `currentHashFor`): a blind spot is the worst outcome, and
|
|
491
|
+
* a false alarm is merely expensive.
|
|
492
|
+
*
|
|
493
|
+
* `tools` and `allowed-tools` are the two spellings of the same ceiling in the reference
|
|
494
|
+
* corpus. Both are read by pi/pi-daddy to build a `--tools` allowlist; neither is ever
|
|
495
|
+
* rendered into the model's context.
|
|
496
|
+
*/
|
|
497
|
+
const CAPABILITY_KEYS = new Set(["allowed-tools", "tools"]);
|
|
498
|
+
/** Opening `---` on line 1 only — a `---` further down is a horizontal rule. */
|
|
499
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
500
|
+
/** A prompt document split into its frontmatter text (null when it has none) and its body. */
|
|
501
|
+
function splitPromptDoc(text) {
|
|
502
|
+
const m = FRONTMATTER_RE.exec(text);
|
|
503
|
+
// Unterminated frontmatter falls through to "all body", the same call
|
|
504
|
+
// instruction-coverage.ts makes: treat the whole file as content rather than lose it.
|
|
505
|
+
return m ? { frontmatter: m[1], body: text.slice(m[0].length) } : { frontmatter: null, body: text };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Parsed YAML in a stable shape: object keys sorted, string scalars trimmed.
|
|
509
|
+
*
|
|
510
|
+
* Trimmed because YAML block style is not stimulus — `description: >` clips a trailing
|
|
511
|
+
* newline that `description: <text>` does not, so an author refolding a paragraph the
|
|
512
|
+
* model receives identically would otherwise be charged a re-run.
|
|
513
|
+
*/
|
|
514
|
+
function canonicalValue(v) {
|
|
515
|
+
if (typeof v === "string")
|
|
516
|
+
return v.trim();
|
|
517
|
+
if (Array.isArray(v))
|
|
518
|
+
return v.map(canonicalValue);
|
|
519
|
+
if (v && typeof v === "object") {
|
|
520
|
+
return Object.entries(v)
|
|
521
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
522
|
+
.map(([k, val]) => [k, canonicalValue(val)]);
|
|
523
|
+
}
|
|
524
|
+
return v;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* The frontmatter a run can observe, canonically. Built from the PARSED YAML for the
|
|
528
|
+
* same reason the scenario digests are built from the parsed scenario: reindenting or
|
|
529
|
+
* reordering keys changes no stimulus and must not read as a change.
|
|
530
|
+
*
|
|
531
|
+
* Unparseable frontmatter is hashed VERBATIM rather than dropped. Dropping it would mean
|
|
532
|
+
* a file whose frontmatter is malformed quietly loses that half of its protection — and
|
|
533
|
+
* a malformed header is exactly when an author is most likely to be mid-edit.
|
|
534
|
+
*/
|
|
535
|
+
function modelVisibleFrontmatter(fm) {
|
|
536
|
+
if (fm === null)
|
|
537
|
+
return null;
|
|
538
|
+
let parsed;
|
|
539
|
+
try {
|
|
540
|
+
parsed = yaml.load(fm);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return ["unparsed", fm];
|
|
544
|
+
}
|
|
545
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
546
|
+
return ["unparsed", fm];
|
|
547
|
+
return [
|
|
548
|
+
"parsed",
|
|
549
|
+
Object.entries(parsed)
|
|
550
|
+
.filter(([k]) => !CAPABILITY_KEYS.has(k))
|
|
551
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
552
|
+
.map(([k, v]) => [k, canonicalValue(v)]),
|
|
553
|
+
];
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* The digest of a prompt document: its body verbatim, plus every frontmatter field the
|
|
557
|
+
* model can actually receive.
|
|
558
|
+
*
|
|
559
|
+
* The gate exists to protect published claims, so "the current text" has to mean the text
|
|
560
|
+
* the model was given. Hashing the raw bytes — which is what shipped through 0.7.0 —
|
|
561
|
+
* charged a full re-run for edits no graded run could observe: measured on the reference
|
|
562
|
+
* corpus, adding one `allowed-tools:` key to seven SKILL.md files with byte-identical
|
|
563
|
+
* bodies took lint from 2 findings to 22, and 20 of those demanded paid re-waves.
|
|
564
|
+
*
|
|
565
|
+
* Versioned in the hashed tuple (`prompt-doc/1`) because this is a stored-hash format: if
|
|
566
|
+
* the rule for "model-visible" ever changes, the tag changes with it and old digests stay
|
|
567
|
+
* legible as what they were.
|
|
568
|
+
*/
|
|
569
|
+
export function promptDocDigest(text) {
|
|
570
|
+
const { frontmatter, body } = splitPromptDoc(text);
|
|
571
|
+
return sha(JSON.stringify(["prompt-doc/1", modelVisibleFrontmatter(frontmatter), body]));
|
|
572
|
+
}
|
|
573
|
+
/** `promptDocDigest` of a file, or null when it doesn't exist / isn't readable. */
|
|
574
|
+
export function promptDocDigestOfFile(path) {
|
|
575
|
+
try {
|
|
576
|
+
return promptDocDigest(readFileSync(path, "utf8"));
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* True when `key` is a raw-bytes key whose model-visible counterpart THIS RUN also
|
|
584
|
+
* recorded — in which case the counterpart is the honest comparison and the raw-bytes one
|
|
585
|
+
* must be skipped.
|
|
586
|
+
*
|
|
587
|
+
* Both keys keep being written, and this is why: an older skill-harness reading a newer
|
|
588
|
+
* results.yaml still finds the bare `SKILL.md` key and keeps checking it, and `restamp`
|
|
589
|
+
* needs the raw-bytes hash as the proof it upgrades from. Superseding at COMPARISON time
|
|
590
|
+
* rather than dropping the key at record time is what buys both.
|
|
591
|
+
*
|
|
592
|
+
* `UNREADABLE` on EITHER side supersedes nothing. If the counterpart is unreadable there
|
|
593
|
+
* is nothing better to compare against; and if the raw-bytes key itself is unreadable, the
|
|
594
|
+
* run never verified that document at all — a fact lint must keep reporting, since the
|
|
595
|
+
* one thing this module refuses to do is let an unhashed source drop out of checking for
|
|
596
|
+
* the life of a result.
|
|
597
|
+
*/
|
|
598
|
+
export function isSupersededKey(key, recorded) {
|
|
599
|
+
if (key === SKILL_PROMPT_KEY || key.startsWith(PROMPT_PREFIX))
|
|
600
|
+
return false;
|
|
601
|
+
if (recorded[key] === UNREADABLE)
|
|
602
|
+
return false;
|
|
603
|
+
const upgraded = key === SKILL_KEY ? SKILL_PROMPT_KEY : PROMPT_PREFIX + key;
|
|
604
|
+
const v = recorded[upgraded];
|
|
605
|
+
return v !== undefined && v !== UNREADABLE;
|
|
606
|
+
}
|
|
430
607
|
/**
|
|
431
608
|
* Every recorded key whose drift could change THIS scenario's verdict — excluding the
|
|
432
609
|
* two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
|
|
@@ -458,8 +635,10 @@ export function scenarioSourceKeys(s) {
|
|
|
458
635
|
];
|
|
459
636
|
if (gatesDigest(s) !== null)
|
|
460
637
|
keys.push(GATES_PREFIX + s.id);
|
|
461
|
-
if (s.systemPromptFile)
|
|
638
|
+
if (s.systemPromptFile) {
|
|
462
639
|
keys.push(s.systemPromptFile); // the agent file IS the stimulus
|
|
640
|
+
keys.push(PROMPT_PREFIX + s.systemPromptFile); // ...as the model receives it
|
|
641
|
+
}
|
|
463
642
|
for (const ext of s.extensions ?? [])
|
|
464
643
|
keys.push(ext); // an edited extension is new stimulus
|
|
465
644
|
if (s.assert?.post_test)
|
package/dist/spec.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { WorkspaceKind } from "./workspace.js";
|
|
2
2
|
import { type TraceAssert } from "./trace-gates.js";
|
|
3
|
+
import { type TrajectoryAssert } from "./trajectory-gates.js";
|
|
3
4
|
export type ScenarioMode = "inline" | "seeded";
|
|
4
5
|
export interface SeededAssert {
|
|
5
6
|
vitest?: boolean;
|
|
@@ -20,6 +21,14 @@ export interface SeededAssert {
|
|
|
20
21
|
*/
|
|
21
22
|
post_test?: string;
|
|
22
23
|
}
|
|
24
|
+
export interface TrajectoryEventSource {
|
|
25
|
+
/** Native format adapter registered by the harness adapter. */
|
|
26
|
+
adapter: "normalized-v1" | "principal-assurance-v1" | "pi-daddy-v1";
|
|
27
|
+
/** Workspace-relative file/glob. Never an absolute path or traversal. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Missing evidence is an error by default; false is for optional supplemental ledgers. */
|
|
30
|
+
required: boolean;
|
|
31
|
+
}
|
|
23
32
|
export interface Scenario {
|
|
24
33
|
id: string;
|
|
25
34
|
title: string;
|
|
@@ -37,6 +46,10 @@ export interface Scenario {
|
|
|
37
46
|
* Declaring it opts the scenario into structured (`--mode json`) execution.
|
|
38
47
|
*/
|
|
39
48
|
traceAssert?: TraceAssert;
|
|
49
|
+
/** Objective assertions over adapter-neutral, versioned workflow events. */
|
|
50
|
+
trajectoryAssert?: TrajectoryAssert;
|
|
51
|
+
/** Native event/ledger files the harness normalizes after execution. */
|
|
52
|
+
eventSources?: TrajectoryEventSource[];
|
|
40
53
|
workspace: WorkspaceKind;
|
|
41
54
|
remote: boolean;
|
|
42
55
|
systemPromptFile?: string;
|
|
@@ -69,6 +82,8 @@ export interface ShipBar {
|
|
|
69
82
|
no_critical_fail: boolean;
|
|
70
83
|
}
|
|
71
84
|
export interface Spec {
|
|
85
|
+
/** Specification schema. Omitted YAML is read as v1 for 0.8 compatibility. */
|
|
86
|
+
schema?: 1;
|
|
72
87
|
skill: string;
|
|
73
88
|
judge_persona: string;
|
|
74
89
|
ship_bar: ShipBar;
|