@skill-harness/core 0.7.0 → 0.9.0

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