@skill-harness/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) 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 +16 -1
  18. package/dist/lint.js +52 -0
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +48 -0
  22. package/dist/report.js +39 -1
  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/results.d.ts +128 -6
  27. package/dist/results.js +155 -6
  28. package/dist/run.d.ts +9 -1
  29. package/dist/run.js +129 -9
  30. package/dist/seeded.d.ts +11 -0
  31. package/dist/seeded.js +31 -7
  32. package/dist/sources.d.ts +26 -0
  33. package/dist/sources.js +82 -3
  34. package/dist/spec-write.d.ts +62 -0
  35. package/dist/spec-write.js +106 -0
  36. package/dist/spec.d.ts +29 -0
  37. package/dist/spec.js +55 -0
  38. package/dist/stability.d.ts +144 -0
  39. package/dist/stability.js +232 -0
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/trends.d.ts +28 -0
  43. package/dist/trends.js +76 -61
  44. package/dist/workspace.d.ts +36 -0
  45. package/dist/workspace.js +61 -0
  46. package/package.json +1 -1
package/dist/seeded.js CHANGED
@@ -160,13 +160,37 @@ export function capDiff(diff, maxBytes = DIFF_MAX_BYTES) {
160
160
  */
161
161
  export async function runSeeded(scenario, opts) {
162
162
  const repo = opts.cwd;
163
- const harnessOut = await opts.adapter.run({
163
+ const req = {
164
164
  skillDir: opts.skillDir,
165
165
  model: opts.model,
166
166
  mode: opts.mode,
167
167
  turns: scenario.turns,
168
168
  cwd: repo,
169
- });
169
+ // Resolved against the spec dir, exactly like fixtures and post-tests.
170
+ extensions: scenario.extensions?.map((e) => resolve(opts.specDir, e)),
171
+ };
172
+ // A trace-gated seeded scenario runs through the structured path so the tool
173
+ // calls are recorded; everything downstream (gates, diff, transcript) is
174
+ // identical, because the rebuilt transcript is what print mode would have
175
+ // emitted anyway.
176
+ let traces = [];
177
+ let harnessOut;
178
+ if (opts.trace) {
179
+ if (!opts.adapter.runStructured) {
180
+ throw new Error(`scenario \`${opts.trace.scenarioId}\` declares \`assert.trace\`, but the \`${opts.adapter.name}\` adapter` +
181
+ ` cannot produce execution traces — the gate would have no evidence to read.`);
182
+ }
183
+ const structured = await opts.adapter.runStructured({
184
+ ...req,
185
+ scenarioId: opts.trace.scenarioId,
186
+ rep: opts.trace.rep,
187
+ });
188
+ harnessOut = structured.transcript;
189
+ traces = structured.traces;
190
+ }
191
+ else {
192
+ harnessOut = await opts.adapter.run(req);
193
+ }
170
194
  const parts = [harnessOut, "", "=== SEEDED GATES ==="];
171
195
  let gateFailure = null;
172
196
  const runVitest = opts.runVitest ??
@@ -189,7 +213,7 @@ export async function runSeeded(scenario, opts) {
189
213
  (gitFailure.stderr.trim() ? `: ${gitFailure.stderr.trim().split("\n")[0]}` : "");
190
214
  parts.push(` staged diff: ERROR (${msg})`);
191
215
  gateFailure = msg;
192
- return finish(parts, gateFailure, diff);
216
+ return finish(parts, gateFailure, diff, traces);
193
217
  }
194
218
  // BOTH needle gates read the changed lines only, never context. A unified diff
195
219
  // carries three lines of context per hunk, so an untouched symbol near the edit
@@ -257,7 +281,7 @@ export async function runSeeded(scenario, opts) {
257
281
  parts.push(` post_test: ERROR (${msg})`);
258
282
  if (!gateFailure)
259
283
  gateFailure = msg;
260
- return finish(parts, gateFailure, diff);
284
+ return finish(parts, gateFailure, diff, traces);
261
285
  }
262
286
  const v = await runVitest([POST_TEST_BASE], repo);
263
287
  const out = `${v.stdout}\n${v.stderr}`;
@@ -306,7 +330,7 @@ export async function runSeeded(scenario, opts) {
306
330
  gateFailure = problem;
307
331
  }
308
332
  }
309
- return finish(parts, gateFailure, diff);
333
+ return finish(parts, gateFailure, diff, traces);
310
334
  }
311
335
  function git(cwd, args) {
312
336
  return exec("git", args, { cwd, timeoutMs: 30_000 });
@@ -340,13 +364,13 @@ function bothStreams(v) {
340
364
  return o || e;
341
365
  }
342
366
  /** Append the staged diff and return the outcome. Every exit path goes through here, so the judge always sees the same sections in the same order. */
343
- function finish(parts, gateFailure, diff) {
367
+ function finish(parts, gateFailure, diff, traces = []) {
344
368
  // The code itself, last — the gates above only prove that keywords appeared.
345
369
  // Without this section a seeded checklist item about what the code *does* is
346
370
  // graded from the model's own description of its work.
347
371
  parts.push("", "=== STAGED DIFF ===");
348
372
  parts.push(diff.trim() === "" ? " (empty — the model left no staged changes)" : capDiff(diff));
349
- return { transcript: parts.join("\n"), gateFailure, diff };
373
+ return { transcript: parts.join("\n"), gateFailure, diff, traces };
350
374
  }
351
375
  /**
352
376
  * Parse vitest's `Tests N passed | M skipped (T)` summary line.
package/dist/sources.d.ts CHANGED
@@ -172,5 +172,31 @@ export declare function describeSourceKey(key: string): string;
172
172
  * in place. Naming the actual remedy is what converts that into a free command.
173
173
  */
174
174
  export declare function remedyForKey(key: string): string;
175
+ /** The skill-text key. Skill-wide: it belongs to every scenario at once. */
176
+ export declare const SKILL_KEY = "SKILL.md";
177
+ /**
178
+ * Every recorded key whose drift could change THIS scenario's verdict — excluding the
179
+ * two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
180
+ * separately because they move every scenario at once.
181
+ *
182
+ * Written for run-over-run comparison (see stability.ts): "did these two runs ask this
183
+ * scenario the same question, judged by the same rubric?" is answerable from the
184
+ * recorded hashes, and only if you know which keys belong to the scenario. Derived
185
+ * from the spec rather than from the key strings, because the path-shaped keys
186
+ * (`system_prompt_file`, `post_test`) carry no scenario id at all.
187
+ *
188
+ * `policy:<id>` is deliberately NOT here. Its `reps`/`pass_threshold` half is already
189
+ * compared as an *aggregation* shape (1 draw vs a majority of 3 is the comparison a
190
+ * hash cannot express), and its `critical` half changes whether a verdict can block a
191
+ * ship, never what the verdict is. Including it would report a critical-set edit as
192
+ * "these runs measured different things", which is false.
193
+ *
194
+ * Both key generations are returned: 0.4.0+ runs carry the split facet keys, older
195
+ * ones the combined `scenario:<id>`. A caller comparing two runs must not treat a
196
+ * combined digest and a split one as comparable — they hash different byte layouts —
197
+ * so it compares only keys BOTH runs recorded, and treats "no shared key" as
198
+ * unverifiable rather than unchanged.
199
+ */
200
+ export declare function scenarioSourceKeys(s: Scenario): string[];
175
201
  /** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
176
202
  export declare function scenarioIdForKey(key: string, scenarios: Scenario[]): string | undefined;
package/dist/sources.js CHANGED
@@ -167,24 +167,50 @@ function walk(dir, prefix = "") {
167
167
  * transcripts are actually invalid.
168
168
  */
169
169
  function facets(s) {
170
- const { id, title, critical, mode, turns, checklist, fixture, assert, workspace, remote, systemPromptFile, reps, passThreshold, ...restScenario } = s;
170
+ const { id, title, critical, mode, turns, checklist, fixture, assert, traceAssert, workspace, remote, systemPromptFile, extensions, reps, passThreshold, covers: _coversIsMetadata, ...restScenario } = s;
171
171
  const _scenarioExhaustive = restScenario;
172
172
  void _scenarioExhaustive;
173
+ // `covers` is destructured into a discard on purpose, and this comment is the
174
+ // decision the guard demanded: it belongs to NO digest. It records which
175
+ // instruction sections a scenario is declared to exercise, which changes what
176
+ // `--affected` selects next time — not what any past run measured. Bucketing it
177
+ // anywhere would charge a re-run (or at best a re-score) for editing a label,
178
+ // which is the exact trap the facet split was built to remove.
179
+ void _coversIsMetadata;
173
180
  const { vitest, diff_contains, diff_excludes, post_test, ...restAssert } = assert ?? {};
174
181
  const _assertExhaustive = restAssert;
175
182
  void _assertExhaustive;
176
- const hasGates = diff_contains !== undefined || diff_excludes !== undefined;
183
+ // `traceAssert` is a GATE, not stimulus: it is evaluated against a trace the run
184
+ // already saved, so `regate` (free) can re-answer it without re-running the model.
185
+ // Note the asymmetry with `env.extensions` in Phase 3, which IS stimulus — one
186
+ // changes what gets executed, the other only what we conclude from it.
187
+ const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined;
177
188
  return {
178
189
  // `vitest` and the `post_test` PATH are stimulus, not gates: both change what the
179
190
  // run executes in the workspace, and neither can be re-evaluated from a saved
180
191
  // diff. (`post_test`'s CONTENTS get their own file-path key, hashed separately.)
192
+ // `extensions` is STIMULUS, not a gate — note the asymmetry with `traceAssert`
193
+ // below. Changing which extensions load changes what the model can DO, so the
194
+ // old transcripts describe a different agent and only a re-run can answer.
195
+ // Changing an assertion only changes what we conclude from evidence already on
196
+ // disk, which `regate` can redo for free.
197
+ // APPENDED CONDITIONALLY, never as a fixed slot. This tuple is positional and
198
+ // its hash is stored in every published results.yaml, so adding an
199
+ // unconditional element re-hashes every scenario that never used the field —
200
+ // measured: 62 real lint findings became 261 across the reference corpus, all of
201
+ // them demanding paid re-runs for scenarios nobody had edited.
181
202
  stimulus: JSON.stringify([
182
203
  id, mode, turns, workspace, remote, systemPromptFile ?? null,
183
204
  fixture ?? null, vitest ?? null, post_test ?? null,
205
+ ...(extensions ? [extensions] : []),
184
206
  ]),
185
207
  rubric: JSON.stringify([id, title, checklist]),
186
208
  policy: JSON.stringify([id, critical, reps ?? null, passThreshold ?? null]),
187
- gates: hasGates ? JSON.stringify([id, diff_contains ?? null, diff_excludes ?? null]) : null,
209
+ // Same rule as `stimulus` above: conditional, so a needle-gated scenario that
210
+ // declares no trace assertions keeps the digest it was published with.
211
+ gates: hasGates
212
+ ? JSON.stringify([id, diff_contains ?? null, diff_excludes ?? null, ...(traceAssert ? [traceAssert] : [])])
213
+ : null,
188
214
  };
189
215
  }
190
216
  function sha(canonical) {
@@ -281,6 +307,15 @@ export function sourceHashes(ctx) {
281
307
  if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
282
308
  hashes[s.systemPromptFile] = fileSha256(resolve(ctx.specDir, s.systemPromptFile)) ?? UNREADABLE;
283
309
  }
310
+ // Extension CONTENTS, not just the paths the stimulus digest already covers.
311
+ // An orchestration scenario's subagent tool lives in these files: editing one
312
+ // changes what the model could do without changing a single character of the
313
+ // spec, which is precisely the drift the staleness gate exists to catch.
314
+ for (const ext of s.extensions ?? []) {
315
+ if (ext in hashes)
316
+ continue;
317
+ hashes[ext] = fileSha256(resolve(ctx.specDir, ext)) ?? UNREADABLE;
318
+ }
284
319
  // The post-test IS the gate on a post_test scenario, and it lives outside the
285
320
  // fixture tree by convention (`fixture: fixtures/A1`, `post_test: post/A1.test.ts`),
286
321
  // so neither the fixture digest nor the scenario digest — which holds only the
@@ -390,6 +425,50 @@ export function remedyForKey(key) {
390
425
  }
391
426
  return "re-run"; // stimulus:, SKILL.md, fixture:, agent files, post_test contents
392
427
  }
428
+ /** The skill-text key. Skill-wide: it belongs to every scenario at once. */
429
+ export const SKILL_KEY = "SKILL.md";
430
+ /**
431
+ * Every recorded key whose drift could change THIS scenario's verdict — excluding the
432
+ * two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
433
+ * separately because they move every scenario at once.
434
+ *
435
+ * Written for run-over-run comparison (see stability.ts): "did these two runs ask this
436
+ * scenario the same question, judged by the same rubric?" is answerable from the
437
+ * recorded hashes, and only if you know which keys belong to the scenario. Derived
438
+ * from the spec rather than from the key strings, because the path-shaped keys
439
+ * (`system_prompt_file`, `post_test`) carry no scenario id at all.
440
+ *
441
+ * `policy:<id>` is deliberately NOT here. Its `reps`/`pass_threshold` half is already
442
+ * compared as an *aggregation* shape (1 draw vs a majority of 3 is the comparison a
443
+ * hash cannot express), and its `critical` half changes whether a verdict can block a
444
+ * ship, never what the verdict is. Including it would report a critical-set edit as
445
+ * "these runs measured different things", which is false.
446
+ *
447
+ * Both key generations are returned: 0.4.0+ runs carry the split facet keys, older
448
+ * ones the combined `scenario:<id>`. A caller comparing two runs must not treat a
449
+ * combined digest and a split one as comparable — they hash different byte layouts —
450
+ * so it compares only keys BOTH runs recorded, and treats "no shared key" as
451
+ * unverifiable rather than unchanged.
452
+ */
453
+ export function scenarioSourceKeys(s) {
454
+ const keys = [
455
+ STIMULUS_PREFIX + s.id,
456
+ RUBRIC_PREFIX + s.id,
457
+ SCENARIO_PREFIX + s.id, // legacy combined (pre-0.4.0 runs)
458
+ ];
459
+ if (gatesDigest(s) !== null)
460
+ keys.push(GATES_PREFIX + s.id);
461
+ if (s.systemPromptFile)
462
+ keys.push(s.systemPromptFile); // the agent file IS the stimulus
463
+ for (const ext of s.extensions ?? [])
464
+ keys.push(ext); // an edited extension is new stimulus
465
+ if (s.assert?.post_test)
466
+ keys.push(s.assert.post_test); // its contents are the gate
467
+ const fx = effectiveFixture(s);
468
+ if (fx)
469
+ keys.push(FIXTURE_PREFIX + fx);
470
+ return keys;
471
+ }
393
472
  /** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
394
473
  export function scenarioIdForKey(key, scenarios) {
395
474
  if (key === PERSONA_KEY)
@@ -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);