@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/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,24 +168,50 @@ 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, workspace, remote, systemPromptFile, reps, passThreshold, ...restScenario } = s;
|
|
171
|
+
const { id, title, critical, mode, turns, checklist, fixture, assert, traceAssert, workspace, remote, systemPromptFile, extensions, reps, passThreshold, covers: _coversIsMetadata, ...restScenario } = s;
|
|
171
172
|
const _scenarioExhaustive = restScenario;
|
|
172
173
|
void _scenarioExhaustive;
|
|
174
|
+
// `covers` is destructured into a discard on purpose, and this comment is the
|
|
175
|
+
// decision the guard demanded: it belongs to NO digest. It records which
|
|
176
|
+
// instruction sections a scenario is declared to exercise, which changes what
|
|
177
|
+
// `--affected` selects next time — not what any past run measured. Bucketing it
|
|
178
|
+
// anywhere would charge a re-run (or at best a re-score) for editing a label,
|
|
179
|
+
// which is the exact trap the facet split was built to remove.
|
|
180
|
+
void _coversIsMetadata;
|
|
173
181
|
const { vitest, diff_contains, diff_excludes, post_test, ...restAssert } = assert ?? {};
|
|
174
182
|
const _assertExhaustive = restAssert;
|
|
175
183
|
void _assertExhaustive;
|
|
176
|
-
|
|
184
|
+
// `traceAssert` is a GATE, not stimulus: it is evaluated against a trace the run
|
|
185
|
+
// already saved, so `regate` (free) can re-answer it without re-running the model.
|
|
186
|
+
// Note the asymmetry with `env.extensions` in Phase 3, which IS stimulus — one
|
|
187
|
+
// changes what gets executed, the other only what we conclude from it.
|
|
188
|
+
const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined;
|
|
177
189
|
return {
|
|
178
190
|
// `vitest` and the `post_test` PATH are stimulus, not gates: both change what the
|
|
179
191
|
// run executes in the workspace, and neither can be re-evaluated from a saved
|
|
180
192
|
// diff. (`post_test`'s CONTENTS get their own file-path key, hashed separately.)
|
|
193
|
+
// `extensions` is STIMULUS, not a gate — note the asymmetry with `traceAssert`
|
|
194
|
+
// below. Changing which extensions load changes what the model can DO, so the
|
|
195
|
+
// old transcripts describe a different agent and only a re-run can answer.
|
|
196
|
+
// Changing an assertion only changes what we conclude from evidence already on
|
|
197
|
+
// disk, which `regate` can redo for free.
|
|
198
|
+
// APPENDED CONDITIONALLY, never as a fixed slot. This tuple is positional and
|
|
199
|
+
// its hash is stored in every published results.yaml, so adding an
|
|
200
|
+
// unconditional element re-hashes every scenario that never used the field —
|
|
201
|
+
// measured: 62 real lint findings became 261 across the reference corpus, all of
|
|
202
|
+
// them demanding paid re-runs for scenarios nobody had edited.
|
|
181
203
|
stimulus: JSON.stringify([
|
|
182
204
|
id, mode, turns, workspace, remote, systemPromptFile ?? null,
|
|
183
205
|
fixture ?? null, vitest ?? null, post_test ?? null,
|
|
206
|
+
...(extensions ? [extensions] : []),
|
|
184
207
|
]),
|
|
185
208
|
rubric: JSON.stringify([id, title, checklist]),
|
|
186
209
|
policy: JSON.stringify([id, critical, reps ?? null, passThreshold ?? null]),
|
|
187
|
-
|
|
210
|
+
// Same rule as `stimulus` above: conditional, so a needle-gated scenario that
|
|
211
|
+
// declares no trace assertions keeps the digest it was published with.
|
|
212
|
+
gates: hasGates
|
|
213
|
+
? JSON.stringify([id, diff_contains ?? null, diff_excludes ?? null, ...(traceAssert ? [traceAssert] : [])])
|
|
214
|
+
: null,
|
|
188
215
|
};
|
|
189
216
|
}
|
|
190
217
|
function sha(canonical) {
|
|
@@ -266,7 +293,12 @@ export function sourceHashes(ctx) {
|
|
|
266
293
|
const hashes = {};
|
|
267
294
|
// UNREADABLE rather than omission on every branch below: a source we failed to
|
|
268
295
|
// hash must stay visible to lint, not vanish from the record. See UNREADABLE.
|
|
269
|
-
|
|
296
|
+
// Two digests of one file, and both are load-bearing. `SKILL.md` is the raw bytes,
|
|
297
|
+
// kept so an older skill-harness reading this record still has the key it knows and so
|
|
298
|
+
// `restamp` has a proof to upgrade from; `skill:prompt` is the text the model actually
|
|
299
|
+
// receives, and is the one lint compares (see `isSupersededKey`).
|
|
300
|
+
hashes[SKILL_KEY] = fileSha256(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
|
|
301
|
+
hashes[SKILL_PROMPT_KEY] = promptDocDigestOfFile(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
|
|
270
302
|
hashes[PERSONA_KEY] = personaDigest(ctx.judgePersona);
|
|
271
303
|
for (const s of ctx.scenarios) {
|
|
272
304
|
// Split, not combined: each facet's drift has a different cheapest remedy, and a
|
|
@@ -278,8 +310,22 @@ export function sourceHashes(ctx) {
|
|
|
278
310
|
const gates = gatesDigest(s);
|
|
279
311
|
if (gates !== null)
|
|
280
312
|
hashes[GATES_PREFIX + s.id] = gates;
|
|
313
|
+
// An agent file is a prompt document too, delivered with `--append-system-prompt`,
|
|
314
|
+
// and its `tools:` header is read by the harness rather than the model — so it gets
|
|
315
|
+
// the same pair of digests, for the same reasons, as SKILL.md above.
|
|
281
316
|
if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
|
|
282
|
-
|
|
317
|
+
const abs = resolve(ctx.specDir, s.systemPromptFile);
|
|
318
|
+
hashes[s.systemPromptFile] = fileSha256(abs) ?? UNREADABLE;
|
|
319
|
+
hashes[PROMPT_PREFIX + s.systemPromptFile] = promptDocDigestOfFile(abs) ?? UNREADABLE;
|
|
320
|
+
}
|
|
321
|
+
// Extension CONTENTS, not just the paths the stimulus digest already covers.
|
|
322
|
+
// An orchestration scenario's subagent tool lives in these files: editing one
|
|
323
|
+
// changes what the model could do without changing a single character of the
|
|
324
|
+
// spec, which is precisely the drift the staleness gate exists to catch.
|
|
325
|
+
for (const ext of s.extensions ?? []) {
|
|
326
|
+
if (ext in hashes)
|
|
327
|
+
continue;
|
|
328
|
+
hashes[ext] = fileSha256(resolve(ctx.specDir, ext)) ?? UNREADABLE;
|
|
283
329
|
}
|
|
284
330
|
// The post-test IS the gate on a post_test scenario, and it lives outside the
|
|
285
331
|
// fixture tree by convention (`fixture: fixtures/A1`, `post_test: post/A1.test.ts`),
|
|
@@ -309,8 +355,16 @@ export function sourceHashes(ctx) {
|
|
|
309
355
|
* from drifting: a new key kind is defined once, for both sides.
|
|
310
356
|
*/
|
|
311
357
|
export function currentHashFor(key, ctx) {
|
|
312
|
-
if (key ===
|
|
358
|
+
if (key === SKILL_KEY)
|
|
313
359
|
return fileSha256(resolve(ctx.skillDir, "SKILL.md"));
|
|
360
|
+
if (key === SKILL_PROMPT_KEY)
|
|
361
|
+
return promptDocDigestOfFile(resolve(ctx.skillDir, "SKILL.md"));
|
|
362
|
+
// Before the unknown-prefix guard below, which would otherwise swallow these as keys
|
|
363
|
+
// from a newer skill-harness. A deleted agent file must still resolve to null ("gone"),
|
|
364
|
+
// not undefined ("not comparable").
|
|
365
|
+
if (key.startsWith(PROMPT_PREFIX)) {
|
|
366
|
+
return promptDocDigestOfFile(resolve(ctx.specDir, key.slice(PROMPT_PREFIX.length)));
|
|
367
|
+
}
|
|
314
368
|
if (key === PERSONA_KEY)
|
|
315
369
|
return personaDigest(ctx.judgePersona);
|
|
316
370
|
// Facet keys, and the legacy combined key, all resolve per scenario id. A removed
|
|
@@ -348,6 +402,13 @@ export function currentHashFor(key, ctx) {
|
|
|
348
402
|
export function describeSourceKey(key) {
|
|
349
403
|
if (key === PERSONA_KEY)
|
|
350
404
|
return "the judge persona";
|
|
405
|
+
// Deliberately the same label as the raw-bytes key: only one of the pair is ever
|
|
406
|
+
// compared (see `isSupersededKey`), and a reader of a lint finding cares which FILE
|
|
407
|
+
// moved, not which of two digests of it was the one consulted.
|
|
408
|
+
if (key === SKILL_PROMPT_KEY)
|
|
409
|
+
return SKILL_KEY;
|
|
410
|
+
if (key.startsWith(PROMPT_PREFIX))
|
|
411
|
+
return key.slice(PROMPT_PREFIX.length);
|
|
351
412
|
if (key.startsWith(STIMULUS_PREFIX))
|
|
352
413
|
return `the stimulus for \`${key.slice(STIMULUS_PREFIX.length)}\``;
|
|
353
414
|
if (key.startsWith(RUBRIC_PREFIX))
|
|
@@ -392,6 +453,149 @@ export function remedyForKey(key) {
|
|
|
392
453
|
}
|
|
393
454
|
/** The skill-text key. Skill-wide: it belongs to every scenario at once. */
|
|
394
455
|
export const SKILL_KEY = "SKILL.md";
|
|
456
|
+
/**
|
|
457
|
+
* The model-visible digests of the two prompt documents a run delivers: the skill text
|
|
458
|
+
* and any `system_prompt_file`. They measure what the model RECEIVES, where the bare
|
|
459
|
+
* keys above (`SKILL.md`, `<agent path>`) measure the file's raw bytes.
|
|
460
|
+
*
|
|
461
|
+
* | key | contents |
|
|
462
|
+
* |---|---|
|
|
463
|
+
* | `skill:prompt` | SKILL.md's body + its model-visible frontmatter |
|
|
464
|
+
* | `prompt:<path>` | the same, for one `system_prompt_file` |
|
|
465
|
+
*
|
|
466
|
+
* Both are prefixed, so a skill-harness OLDER than this one reads them through
|
|
467
|
+
* `currentHashFor`'s unknown-prefix guard and reports "not comparable" rather than a
|
|
468
|
+
* confident wrong finding about a source that is fine.
|
|
469
|
+
*/
|
|
470
|
+
export const SKILL_PROMPT_KEY = "skill:prompt";
|
|
471
|
+
export const PROMPT_PREFIX = "prompt:";
|
|
472
|
+
/**
|
|
473
|
+
* Frontmatter keys the model never receives: capability declarations the HARNESS
|
|
474
|
+
* consumes to build a tool allowlist.
|
|
475
|
+
*
|
|
476
|
+
* Why a denylist and not an allowlist of "body + description". An allowlist makes the
|
|
477
|
+
* safe-looking choice the dangerous one: the day a harness starts putting some new
|
|
478
|
+
* frontmatter key in context, every published number silently stops being protected and
|
|
479
|
+
* nothing says so. A denylist fails the other way — a newly-invented inert key charges
|
|
480
|
+
* one unnecessary re-run until it is named here, which is a one-line patch and a visible
|
|
481
|
+
* complaint. This module already takes that side everywhere else (see `UNREADABLE`, and
|
|
482
|
+
* the unknown-prefix guard in `currentHashFor`): a blind spot is the worst outcome, and
|
|
483
|
+
* a false alarm is merely expensive.
|
|
484
|
+
*
|
|
485
|
+
* `tools` and `allowed-tools` are the two spellings of the same ceiling in the reference
|
|
486
|
+
* corpus. Both are read by pi/pi-daddy to build a `--tools` allowlist; neither is ever
|
|
487
|
+
* rendered into the model's context.
|
|
488
|
+
*/
|
|
489
|
+
const CAPABILITY_KEYS = new Set(["allowed-tools", "tools"]);
|
|
490
|
+
/** Opening `---` on line 1 only — a `---` further down is a horizontal rule. */
|
|
491
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
492
|
+
/** A prompt document split into its frontmatter text (null when it has none) and its body. */
|
|
493
|
+
function splitPromptDoc(text) {
|
|
494
|
+
const m = FRONTMATTER_RE.exec(text);
|
|
495
|
+
// Unterminated frontmatter falls through to "all body", the same call
|
|
496
|
+
// instruction-coverage.ts makes: treat the whole file as content rather than lose it.
|
|
497
|
+
return m ? { frontmatter: m[1], body: text.slice(m[0].length) } : { frontmatter: null, body: text };
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Parsed YAML in a stable shape: object keys sorted, string scalars trimmed.
|
|
501
|
+
*
|
|
502
|
+
* Trimmed because YAML block style is not stimulus — `description: >` clips a trailing
|
|
503
|
+
* newline that `description: <text>` does not, so an author refolding a paragraph the
|
|
504
|
+
* model receives identically would otherwise be charged a re-run.
|
|
505
|
+
*/
|
|
506
|
+
function canonicalValue(v) {
|
|
507
|
+
if (typeof v === "string")
|
|
508
|
+
return v.trim();
|
|
509
|
+
if (Array.isArray(v))
|
|
510
|
+
return v.map(canonicalValue);
|
|
511
|
+
if (v && typeof v === "object") {
|
|
512
|
+
return Object.entries(v)
|
|
513
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
514
|
+
.map(([k, val]) => [k, canonicalValue(val)]);
|
|
515
|
+
}
|
|
516
|
+
return v;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The frontmatter a run can observe, canonically. Built from the PARSED YAML for the
|
|
520
|
+
* same reason the scenario digests are built from the parsed scenario: reindenting or
|
|
521
|
+
* reordering keys changes no stimulus and must not read as a change.
|
|
522
|
+
*
|
|
523
|
+
* Unparseable frontmatter is hashed VERBATIM rather than dropped. Dropping it would mean
|
|
524
|
+
* a file whose frontmatter is malformed quietly loses that half of its protection — and
|
|
525
|
+
* a malformed header is exactly when an author is most likely to be mid-edit.
|
|
526
|
+
*/
|
|
527
|
+
function modelVisibleFrontmatter(fm) {
|
|
528
|
+
if (fm === null)
|
|
529
|
+
return null;
|
|
530
|
+
let parsed;
|
|
531
|
+
try {
|
|
532
|
+
parsed = yaml.load(fm);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
return ["unparsed", fm];
|
|
536
|
+
}
|
|
537
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
538
|
+
return ["unparsed", fm];
|
|
539
|
+
return [
|
|
540
|
+
"parsed",
|
|
541
|
+
Object.entries(parsed)
|
|
542
|
+
.filter(([k]) => !CAPABILITY_KEYS.has(k))
|
|
543
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
544
|
+
.map(([k, v]) => [k, canonicalValue(v)]),
|
|
545
|
+
];
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The digest of a prompt document: its body verbatim, plus every frontmatter field the
|
|
549
|
+
* model can actually receive.
|
|
550
|
+
*
|
|
551
|
+
* The gate exists to protect published claims, so "the current text" has to mean the text
|
|
552
|
+
* the model was given. Hashing the raw bytes — which is what shipped through 0.7.0 —
|
|
553
|
+
* charged a full re-run for edits no graded run could observe: measured on the reference
|
|
554
|
+
* corpus, adding one `allowed-tools:` key to seven SKILL.md files with byte-identical
|
|
555
|
+
* bodies took lint from 2 findings to 22, and 20 of those demanded paid re-waves.
|
|
556
|
+
*
|
|
557
|
+
* Versioned in the hashed tuple (`prompt-doc/1`) because this is a stored-hash format: if
|
|
558
|
+
* the rule for "model-visible" ever changes, the tag changes with it and old digests stay
|
|
559
|
+
* legible as what they were.
|
|
560
|
+
*/
|
|
561
|
+
export function promptDocDigest(text) {
|
|
562
|
+
const { frontmatter, body } = splitPromptDoc(text);
|
|
563
|
+
return sha(JSON.stringify(["prompt-doc/1", modelVisibleFrontmatter(frontmatter), body]));
|
|
564
|
+
}
|
|
565
|
+
/** `promptDocDigest` of a file, or null when it doesn't exist / isn't readable. */
|
|
566
|
+
export function promptDocDigestOfFile(path) {
|
|
567
|
+
try {
|
|
568
|
+
return promptDocDigest(readFileSync(path, "utf8"));
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* True when `key` is a raw-bytes key whose model-visible counterpart THIS RUN also
|
|
576
|
+
* recorded — in which case the counterpart is the honest comparison and the raw-bytes one
|
|
577
|
+
* must be skipped.
|
|
578
|
+
*
|
|
579
|
+
* Both keys keep being written, and this is why: an older skill-harness reading a newer
|
|
580
|
+
* results.yaml still finds the bare `SKILL.md` key and keeps checking it, and `restamp`
|
|
581
|
+
* needs the raw-bytes hash as the proof it upgrades from. Superseding at COMPARISON time
|
|
582
|
+
* rather than dropping the key at record time is what buys both.
|
|
583
|
+
*
|
|
584
|
+
* `UNREADABLE` on EITHER side supersedes nothing. If the counterpart is unreadable there
|
|
585
|
+
* is nothing better to compare against; and if the raw-bytes key itself is unreadable, the
|
|
586
|
+
* run never verified that document at all — a fact lint must keep reporting, since the
|
|
587
|
+
* one thing this module refuses to do is let an unhashed source drop out of checking for
|
|
588
|
+
* the life of a result.
|
|
589
|
+
*/
|
|
590
|
+
export function isSupersededKey(key, recorded) {
|
|
591
|
+
if (key === SKILL_PROMPT_KEY || key.startsWith(PROMPT_PREFIX))
|
|
592
|
+
return false;
|
|
593
|
+
if (recorded[key] === UNREADABLE)
|
|
594
|
+
return false;
|
|
595
|
+
const upgraded = key === SKILL_KEY ? SKILL_PROMPT_KEY : PROMPT_PREFIX + key;
|
|
596
|
+
const v = recorded[upgraded];
|
|
597
|
+
return v !== undefined && v !== UNREADABLE;
|
|
598
|
+
}
|
|
395
599
|
/**
|
|
396
600
|
* Every recorded key whose drift could change THIS scenario's verdict — excluding the
|
|
397
601
|
* two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
|
|
@@ -423,8 +627,12 @@ export function scenarioSourceKeys(s) {
|
|
|
423
627
|
];
|
|
424
628
|
if (gatesDigest(s) !== null)
|
|
425
629
|
keys.push(GATES_PREFIX + s.id);
|
|
426
|
-
if (s.systemPromptFile)
|
|
630
|
+
if (s.systemPromptFile) {
|
|
427
631
|
keys.push(s.systemPromptFile); // the agent file IS the stimulus
|
|
632
|
+
keys.push(PROMPT_PREFIX + s.systemPromptFile); // ...as the model receives it
|
|
633
|
+
}
|
|
634
|
+
for (const ext of s.extensions ?? [])
|
|
635
|
+
keys.push(ext); // an edited extension is new stimulus
|
|
428
636
|
if (s.assert?.post_test)
|
|
429
637
|
keys.push(s.assert.post_test); // its contents are the gate
|
|
430
638
|
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;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
import { parseSpec } from "./spec.js";
|
|
6
|
+
/**
|
|
7
|
+
* The single choke point for appending a scenario to an existing
|
|
8
|
+
* `specification.yaml`.
|
|
9
|
+
*
|
|
10
|
+
* Two callers need this — `add-test` and capture promotion — and a second
|
|
11
|
+
* implementation is how they would drift into disagreeing about what a valid
|
|
12
|
+
* write is. Everything here is deliberately append-shaped: a spec is
|
|
13
|
+
* hand-authored and full of comments, and a round trip through
|
|
14
|
+
* `yaml.load`/`yaml.dump` would silently reformat it and drop every comment the
|
|
15
|
+
* author wrote. So the existing bytes are never re-serialized — the new block is
|
|
16
|
+
* concatenated onto them and the *result* is validated before anything is
|
|
17
|
+
* written.
|
|
18
|
+
*/
|
|
19
|
+
/** Thrown when the spec on disk moved between the caller reading it and writing. */
|
|
20
|
+
export class ConcurrentSpecModification extends Error {
|
|
21
|
+
constructor(specPath) {
|
|
22
|
+
super(`${specPath} changed on disk since it was read — refusing to append. ` +
|
|
23
|
+
`Re-read the spec and retry; appending now would validate against a file that no longer exists.`);
|
|
24
|
+
this.name = "ConcurrentSpecModification";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Thrown when the scenario being appended collides with one already in the spec. */
|
|
28
|
+
export class DuplicateScenarioId extends Error {
|
|
29
|
+
constructor(id, specPath) {
|
|
30
|
+
super(`scenario id \`${id}\` already exists in ${specPath}`);
|
|
31
|
+
this.name = "DuplicateScenarioId";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** SHA-256 of spec text. Callers hold one across a read→confirm→write cycle. */
|
|
35
|
+
export function specSha256(text) {
|
|
36
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Render one scenario as a YAML block that can be concatenated onto a spec.
|
|
40
|
+
*
|
|
41
|
+
* Dumps `{ scenarios: [scenario] }` and strips the top-level key, leaving the
|
|
42
|
+
* correctly-indented list item. Going through `yaml.dump` rather than string
|
|
43
|
+
* templating is what makes arbitrary user text — quotes, colons, newlines,
|
|
44
|
+
* leading dashes — safe to embed.
|
|
45
|
+
*/
|
|
46
|
+
export function renderScenarioBlock(scenario) {
|
|
47
|
+
const dumped = yaml.dump({ scenarios: [scenario] }, { lineWidth: -1, noRefs: true });
|
|
48
|
+
return "\n" + dumped.replace(/^scenarios:\n/, "");
|
|
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 function appendScenario(opts) {
|
|
63
|
+
const { specPath, scenario, baseSha256 } = opts;
|
|
64
|
+
const current = readFileSync(specPath, "utf8");
|
|
65
|
+
if (baseSha256 !== undefined && specSha256(current) !== baseSha256) {
|
|
66
|
+
throw new ConcurrentSpecModification(specPath);
|
|
67
|
+
}
|
|
68
|
+
const id = scenario.id;
|
|
69
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
70
|
+
throw new Error("scenario needs a non-empty string `id`");
|
|
71
|
+
}
|
|
72
|
+
const existing = parseSpec(current, specPath);
|
|
73
|
+
if (existing.scenarios.some((s) => s.id === id)) {
|
|
74
|
+
throw new DuplicateScenarioId(id, specPath);
|
|
75
|
+
}
|
|
76
|
+
const block = renderScenarioBlock(scenario);
|
|
77
|
+
const merged = current + block;
|
|
78
|
+
parseSpec(merged, specPath); // throws if the append broke the spec
|
|
79
|
+
atomicWrite(specPath, merged);
|
|
80
|
+
return { id, sha256: specSha256(merged), block };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Write via a sibling temp file and rename.
|
|
84
|
+
*
|
|
85
|
+
* Sibling, not `/tmp`: `rename(2)` is only atomic within a filesystem, and a
|
|
86
|
+
* cross-device rename would silently degrade to copy-then-delete — exactly the
|
|
87
|
+
* torn write this exists to prevent.
|
|
88
|
+
*/
|
|
89
|
+
function atomicWrite(path, text) {
|
|
90
|
+
const tmp = join(dirname(path), `.${Date.now()}-${process.pid}.specwrite.tmp`);
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(tmp, text, "utf8");
|
|
93
|
+
renameSync(tmp, path);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
try {
|
|
97
|
+
unlinkSync(tmp);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Best effort: the original file is untouched either way, and masking the
|
|
101
|
+
// real failure with a cleanup error would hide why the write failed.
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=spec-write.js.map
|
package/dist/spec.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { WorkspaceKind } from "./workspace.js";
|
|
2
|
+
import { type TraceAssert } from "./trace-gates.js";
|
|
2
3
|
export type ScenarioMode = "inline" | "seeded";
|
|
3
4
|
export interface SeededAssert {
|
|
4
5
|
vitest?: boolean;
|
|
@@ -28,9 +29,37 @@ export interface Scenario {
|
|
|
28
29
|
checklist: string[];
|
|
29
30
|
fixture?: string;
|
|
30
31
|
assert?: SeededAssert;
|
|
32
|
+
/**
|
|
33
|
+
* Objective assertions over the execution trace.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately NOT part of `SeededAssert`: the other gates read a staged git
|
|
36
|
+
* diff and are meaningless without a fixture, while a trace exists for any run.
|
|
37
|
+
* Declaring it opts the scenario into structured (`--mode json`) execution.
|
|
38
|
+
*/
|
|
39
|
+
traceAssert?: TraceAssert;
|
|
31
40
|
workspace: WorkspaceKind;
|
|
32
41
|
remote: boolean;
|
|
33
42
|
systemPromptFile?: string;
|
|
43
|
+
/**
|
|
44
|
+
* `env.extensions`: pi extension files to load, resolved relative to the spec dir.
|
|
45
|
+
*
|
|
46
|
+
* Loading is CLOSED, not additive — the adapter passes `--no-extensions` plus one
|
|
47
|
+
* `--extension` per entry, so exactly these load and nothing discovered does.
|
|
48
|
+
* (Measured on pi 0.83.0: that flag pair isolates even under `-a` project-local
|
|
49
|
+
* trust.) Without it, whatever the developer happened to have installed would
|
|
50
|
+
* silently become part of the test.
|
|
51
|
+
*/
|
|
52
|
+
extensions?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* `covers`: instruction sections this scenario is declared to exercise, e.g.
|
|
55
|
+
* `SKILL.md#core-principle`.
|
|
56
|
+
*
|
|
57
|
+
* METADATA. It stales nothing — see `sources.ts`, where it is deliberately in
|
|
58
|
+
* no digest. A `covers` edit changes which tests `--affected` selects, not what
|
|
59
|
+
* any past run measured, so charging a re-run for it would be the exact
|
|
60
|
+
* "pay tokens to fix a label" trap the facet split exists to remove.
|
|
61
|
+
*/
|
|
62
|
+
covers?: string[];
|
|
34
63
|
reps?: number;
|
|
35
64
|
passThreshold?: number;
|
|
36
65
|
}
|
package/dist/spec.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import yaml from "js-yaml";
|
|
3
|
+
import { parseTraceAssert } from "./trace-gates.js";
|
|
3
4
|
/** Thrown on any validation failure. Message always carries the spec file path. */
|
|
4
5
|
export class SpecError extends Error {
|
|
5
6
|
constructor(message, file) {
|
|
@@ -54,6 +55,34 @@ function resolveWorkspace(env, mode, fixture, id, file) {
|
|
|
54
55
|
}
|
|
55
56
|
throw new SpecError(`scenario \`${id}\` env.workspace must be none | empty-git | fixture:<path>`, file);
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve `env.extensions` into a list of paths.
|
|
60
|
+
*
|
|
61
|
+
* Incompatible with `system_prompt_file` by construction: that flag REPLACES the
|
|
62
|
+
* system prompt to test a subagent definition in isolation, while an
|
|
63
|
+
* orchestration scenario tests the PARENT that delegates to one. Allowing both
|
|
64
|
+
* would silently test neither — the parent's instructions would be gone.
|
|
65
|
+
*/
|
|
66
|
+
function resolveExtensions(env, hasSystemPrompt, id, file) {
|
|
67
|
+
const raw = env && typeof env === "object" ? env.extensions : undefined;
|
|
68
|
+
if (raw === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
71
|
+
throw new SpecError(`scenario \`${id}\` env.extensions must be a non-empty list of paths`, file);
|
|
72
|
+
}
|
|
73
|
+
const paths = raw.map((p, i) => {
|
|
74
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
75
|
+
throw new SpecError(`scenario \`${id}\` env.extensions[${i}] must be a non-empty path`, file);
|
|
76
|
+
}
|
|
77
|
+
return p.trim();
|
|
78
|
+
});
|
|
79
|
+
if (hasSystemPrompt) {
|
|
80
|
+
throw new SpecError(`scenario \`${id}\` sets both env.extensions and system_prompt_file — ` +
|
|
81
|
+
`system_prompt_file replaces the system prompt to test a subagent in isolation, ` +
|
|
82
|
+
`while env.extensions tests the parent that delegates to one. Pick one.`, file);
|
|
83
|
+
}
|
|
84
|
+
return paths;
|
|
85
|
+
}
|
|
57
86
|
/**
|
|
58
87
|
* Resolve `env.remote`. A remote needs a repo to attach to, so it is only meaningful
|
|
59
88
|
* with empty-git or a fixture — asking for one on a bare cwd is an authoring mistake,
|
|
@@ -144,6 +173,12 @@ export function parseSpec(text, file) {
|
|
|
144
173
|
workspace: "none",
|
|
145
174
|
remote: false,
|
|
146
175
|
};
|
|
176
|
+
// `assert.trace` is legal for inline AND seeded scenarios — it reads the
|
|
177
|
+
// execution trace, which every run produces, not a staged diff.
|
|
178
|
+
const rawAssert = s.assert;
|
|
179
|
+
if (rawAssert?.trace !== undefined) {
|
|
180
|
+
scenario.traceAssert = parseTraceAssert(rawAssert.trace, `${file}: scenario \`${id}\``);
|
|
181
|
+
}
|
|
147
182
|
if (mode === "seeded") {
|
|
148
183
|
if (typeof s.fixture !== "string" || s.fixture.length === 0) {
|
|
149
184
|
throw new SpecError(`seeded scenario \`${id}\` requires a \`fixture\` path`, file);
|
|
@@ -208,6 +243,26 @@ export function parseSpec(text, file) {
|
|
|
208
243
|
}
|
|
209
244
|
scenario.systemPromptFile = s.system_prompt_file.trim();
|
|
210
245
|
}
|
|
246
|
+
if (s.covers !== undefined) {
|
|
247
|
+
if (!isStringArray(s.covers) || s.covers.length === 0) {
|
|
248
|
+
throw new SpecError(`scenario \`${id}\` \`covers\` must be a non-empty list of strings`, file);
|
|
249
|
+
}
|
|
250
|
+
const bad = s.covers.find((c) => c.trim() === "");
|
|
251
|
+
if (bad !== undefined)
|
|
252
|
+
throw new SpecError(`scenario \`${id}\` \`covers\` has an empty entry`, file);
|
|
253
|
+
scenario.covers = s.covers.map((c) => c.trim());
|
|
254
|
+
}
|
|
255
|
+
// `unchanged_paths` is checked against the workspace's git state, so a scenario
|
|
256
|
+
// with no repo has nothing to observe. Refused here — free and offline — rather
|
|
257
|
+
// than at run time, because the alternative shipped for a while: the assertion
|
|
258
|
+
// silently passed against an empty change list and reported a green safety gate.
|
|
259
|
+
if (scenario.traceAssert?.unchanged_paths?.length && scenario.workspace === "none") {
|
|
260
|
+
throw new SpecError(`scenario \`${id}\` declares \`assert.trace.unchanged_paths\` but has no workspace to observe — ` +
|
|
261
|
+
`set \`env.workspace: empty-git\` or \`fixture:<path>\`, or drop the assertion. ` +
|
|
262
|
+
`A path policy with nothing to compare against would pass unconditionally.`, file);
|
|
263
|
+
}
|
|
264
|
+
// After system_prompt_file, so the incompatibility check sees the resolved value.
|
|
265
|
+
scenario.extensions = resolveExtensions(s.env, scenario.systemPromptFile !== undefined, id, file);
|
|
211
266
|
if (s.reps !== undefined) {
|
|
212
267
|
if (typeof s.reps !== "number" || !Number.isInteger(s.reps) || s.reps < 1) {
|
|
213
268
|
throw new SpecError(`scenario \`${id}\` \`reps\` must be a positive integer`, file);
|
package/dist/stability.js
CHANGED
|
@@ -2,7 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { loadSpec } from "./spec.js";
|
|
3
3
|
import { effectiveVerdicts } from "./results.js";
|
|
4
4
|
import { collectScoredRuns } from "./trends.js";
|
|
5
|
-
import { describeSourceKey, scenarioSourceKeys, PERSONA_KEY, SKILL_KEY } from "./sources.js";
|
|
5
|
+
import { describeSourceKey, isSupersededKey, scenarioSourceKeys, PERSONA_KEY, SKILL_KEY, SKILL_PROMPT_KEY } from "./sources.js";
|
|
6
6
|
const DEFAULT_WINDOW = 5;
|
|
7
7
|
/** A verdict that carries evidence about the task rather than about the harness or judge. */
|
|
8
8
|
function conclusive(v) {
|
|
@@ -23,6 +23,25 @@ function pointFor(r, s, verdict) {
|
|
|
23
23
|
unanimous: reps > 1 && s.flakiness === 0,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Did the skill text the MODEL RECEIVES differ between two runs?
|
|
28
|
+
*
|
|
29
|
+
* Prefers `skill:prompt` when both runs recorded it, and falls back to the raw-bytes
|
|
30
|
+
* `SKILL.md` key for runs that predate it. A frontmatter-only edit moves the raw bytes and
|
|
31
|
+
* not the prompt, and labelling a flip "SKILL.md changed across that step" on that basis
|
|
32
|
+
* would point a reader at an edit no model could have seen — the note's whole job is to
|
|
33
|
+
* say which of two explanations the record supports, so it must not invent a third.
|
|
34
|
+
*/
|
|
35
|
+
function skillTextChanged(prev, cur) {
|
|
36
|
+
for (const key of [SKILL_PROMPT_KEY, SKILL_KEY]) {
|
|
37
|
+
const a = prev?.[key];
|
|
38
|
+
const b = cur?.[key];
|
|
39
|
+
if (a === undefined || b === undefined)
|
|
40
|
+
continue;
|
|
41
|
+
return a !== b;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
26
45
|
/** reps + threshold, normalised the way lift.ts normalises it (a lone rep has no threshold). */
|
|
27
46
|
function shapeOf(s) {
|
|
28
47
|
const reps = s.reps ?? 1;
|
|
@@ -43,6 +62,11 @@ function compareSources(a, b, keys) {
|
|
|
43
62
|
let shared = 0;
|
|
44
63
|
const changed = [];
|
|
45
64
|
for (const key of keys) {
|
|
65
|
+
// Same rule as lint's: when both runs recorded the model-visible digest of a prompt
|
|
66
|
+
// document, THAT is the comparison. Leaving the raw-bytes key in would call two runs
|
|
67
|
+
// "different questions" over an `allowed-tools:` line neither model could read.
|
|
68
|
+
if (isSupersededKey(key, a) && isSupersededKey(key, b))
|
|
69
|
+
continue;
|
|
46
70
|
const va = a[key];
|
|
47
71
|
const vb = b[key];
|
|
48
72
|
if (va === undefined || vb === undefined)
|
|
@@ -77,9 +101,7 @@ function stabilityForScenario(group, scenario, window) {
|
|
|
77
101
|
const cur = raw[i];
|
|
78
102
|
const from = points[i - 1];
|
|
79
103
|
const to = points[i];
|
|
80
|
-
const skillChanged = prev.r.source_hashes
|
|
81
|
-
cur.r.source_hashes?.[SKILL_KEY] !== undefined &&
|
|
82
|
-
prev.r.source_hashes[SKILL_KEY] !== cur.r.source_hashes[SKILL_KEY];
|
|
104
|
+
const skillChanged = skillTextChanged(prev.r.source_hashes, cur.r.source_hashes);
|
|
83
105
|
const base = { from, to, flipped: false, skillChanged, changedSources: [], unanimousFlip: false };
|
|
84
106
|
if (!prev.ok || !cur.ok) {
|
|
85
107
|
pairs.push({ ...base, status: "inconclusive" });
|