@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.
Files changed (44) hide show
  1. package/dist/adapters/types.d.ts +37 -0
  2. package/dist/adjudication.d.ts +210 -0
  3. package/dist/adjudication.js +392 -0
  4. package/dist/affected.d.ts +88 -0
  5. package/dist/affected.js +222 -0
  6. package/dist/capture-trace-types.d.ts +228 -0
  7. package/dist/capture-trace-types.js +23 -0
  8. package/dist/capture.d.ts +193 -0
  9. package/dist/capture.js +344 -0
  10. package/dist/execution-trace.d.ts +61 -0
  11. package/dist/execution-trace.js +299 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +9 -0
  14. package/dist/instruction-coverage.d.ts +106 -0
  15. package/dist/instruction-coverage.js +253 -0
  16. package/dist/journal.d.ts +17 -0
  17. package/dist/lint.d.ts +8 -1
  18. package/dist/lint.js +31 -2
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +24 -0
  22. package/dist/report.js +24 -0
  23. package/dist/reps.d.ts +14 -1
  24. package/dist/reps.js +28 -2
  25. package/dist/rescore.js +11 -2
  26. package/dist/restamp.d.ts +68 -0
  27. package/dist/restamp.js +194 -0
  28. package/dist/results.d.ts +128 -6
  29. package/dist/results.js +155 -6
  30. package/dist/run.js +114 -8
  31. package/dist/seeded.d.ts +11 -0
  32. package/dist/seeded.js +31 -7
  33. package/dist/sources.d.ts +50 -0
  34. package/dist/sources.js +215 -7
  35. package/dist/spec-write.d.ts +62 -0
  36. package/dist/spec-write.js +106 -0
  37. package/dist/spec.d.ts +29 -0
  38. package/dist/spec.js +55 -0
  39. package/dist/stability.js +26 -4
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/workspace.d.ts +36 -0
  43. package/dist/workspace.js +61 -0
  44. 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
- const hasGates = diff_contains !== undefined || diff_excludes !== undefined;
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
- gates: hasGates ? JSON.stringify([id, diff_contains ?? null, diff_excludes ?? null]) : null,
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
- hashes["SKILL.md"] = fileSha256(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
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
- hashes[s.systemPromptFile] = fileSha256(resolve(ctx.specDir, s.systemPromptFile)) ?? UNREADABLE;
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 === "SKILL.md")
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?.[SKILL_KEY] !== undefined &&
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" });