@skill-harness/core 0.20.0 → 0.22.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.
@@ -1,8 +1,10 @@
1
1
  export interface DiscoveredSkill {
2
2
  name: string;
3
3
  dir: string;
4
+ testsDir: string;
4
5
  hasSpec: boolean;
5
6
  specPath: string;
7
+ specSource: "skills" | "specs" | "both" | null;
6
8
  }
7
9
  /**
8
10
  * Scan a skills root. A "skill" is any immediate subdirectory containing a
@@ -16,10 +18,10 @@ export interface DiscoveredSkill {
16
18
  * and a normal answer. The adapter refuses such a path too (see requireSkillDir),
17
19
  * but the honest fix is here, where the path is built.
18
20
  */
19
- export declare function discover(root: string): DiscoveredSkill[];
21
+ export declare function discover(root: string, specsRoot?: string): DiscoveredSkill[];
20
22
  /**
21
23
  * Resolve a single skill by name; throws a helpful error if absent or specless.
22
24
  * A directory that exists but lacks a SKILL.md gets a specific error (rather than
23
25
  * the generic "no skill") so callers don't reimplement the SKILL.md existence check.
24
26
  */
25
- export declare function resolveSkill(root: string, name: string): DiscoveredSkill;
27
+ export declare function resolveSkill(root: string, name: string, specsRoot?: string): DiscoveredSkill;
package/dist/discover.js CHANGED
@@ -12,11 +12,15 @@ import { join, resolve } from "node:path";
12
12
  * and a normal answer. The adapter refuses such a path too (see requireSkillDir),
13
13
  * but the honest fix is here, where the path is built.
14
14
  */
15
- export function discover(root) {
15
+ export function discover(root, specsRoot) {
16
16
  const absRoot = resolve(root);
17
17
  if (!existsSync(absRoot) || !statSync(absRoot).isDirectory()) {
18
18
  throw new Error(`skills root is not a directory: ${root}`);
19
19
  }
20
+ const absSpecsRoot = specsRoot ? resolve(specsRoot) : undefined;
21
+ if (absSpecsRoot && (!existsSync(absSpecsRoot) || !statSync(absSpecsRoot).isDirectory())) {
22
+ throw new Error(`specs root is not a directory: ${specsRoot}`);
23
+ }
20
24
  const skills = [];
21
25
  for (const name of readdirSync(absRoot)) {
22
26
  if (name.startsWith("."))
@@ -26,8 +30,22 @@ export function discover(root) {
26
30
  continue;
27
31
  if (!existsSync(join(dir, "SKILL.md")))
28
32
  continue;
29
- const specPath = join(dir, "tests", "specification.yaml");
30
- skills.push({ name, dir, hasSpec: existsSync(specPath), specPath });
33
+ const localSpecPath = join(dir, "tests", "specification.yaml");
34
+ const overlayTestsDir = absSpecsRoot ? join(absSpecsRoot, name, "tests") : join(dir, "tests");
35
+ const overlaySpecPath = join(overlayTestsDir, "specification.yaml");
36
+ const local = existsSync(localSpecPath);
37
+ const overlay = Boolean(absSpecsRoot && existsSync(overlaySpecPath));
38
+ const specSource = local && overlay ? "both" : overlay ? "specs" : local ? "skills" : null;
39
+ skills.push({
40
+ name,
41
+ dir,
42
+ // An explicit --specs is authoritative. The local path is inspected only so
43
+ // list can report it and duplicate specs can be refused rather than merged.
44
+ testsDir: overlayTestsDir,
45
+ hasSpec: absSpecsRoot ? overlay && !local : local,
46
+ specPath: overlaySpecPath,
47
+ specSource,
48
+ });
31
49
  }
32
50
  skills.sort((a, b) => a.name.localeCompare(b.name));
33
51
  return skills;
@@ -37,8 +55,8 @@ export function discover(root) {
37
55
  * A directory that exists but lacks a SKILL.md gets a specific error (rather than
38
56
  * the generic "no skill") so callers don't reimplement the SKILL.md existence check.
39
57
  */
40
- export function resolveSkill(root, name) {
41
- const skill = discover(root).find((s) => s.name === name);
58
+ export function resolveSkill(root, name, specsRoot) {
59
+ const skill = discover(root, specsRoot).find((s) => s.name === name);
42
60
  if (!skill) {
43
61
  const dir = join(resolve(root), name);
44
62
  if (existsSync(dir) && statSync(dir).isDirectory() && !existsSync(join(dir, "SKILL.md"))) {
@@ -46,6 +64,9 @@ export function resolveSkill(root, name) {
46
64
  }
47
65
  throw new Error(`no skill \`${name}\` under ${root}`);
48
66
  }
67
+ if (skill.specSource === "both") {
68
+ throw new Error(`skill \`${name}\` has a specification.yaml in both --skills and --specs; remove one (specs are not merged)`);
69
+ }
49
70
  return skill;
50
71
  }
51
72
  //# sourceMappingURL=discover.js.map
@@ -85,6 +85,8 @@ export interface CoverageOptions {
85
85
  scenarios: Scenario[];
86
86
  /** Instruction files to report on even if nothing references them. */
87
87
  baseFiles?: string[];
88
+ /** Resolve selected spec-relative references from another root (for split skill/spec repos). */
89
+ fileOverrides?: Record<string, string>;
88
90
  }
89
91
  /**
90
92
  * Build the coverage report.
@@ -115,7 +115,7 @@ export function computeCoverage(opts) {
115
115
  const readSections = (file) => {
116
116
  if (fileSections.has(file))
117
117
  return fileSections.get(file);
118
- const abs = isAbsolute(file) ? file : resolve(opts.specDir, file);
118
+ const abs = opts.fileOverrides?.[file] ?? (isAbsolute(file) ? file : resolve(opts.specDir, file));
119
119
  if (!existsSync(abs))
120
120
  return null;
121
121
  const sections = parseSections(readFileSync(abs, "utf8"));
package/dist/lint.d.ts CHANGED
@@ -26,7 +26,7 @@ export declare function failsGate(f: LintFinding): boolean;
26
26
  * becomes a single `code:"spec"` finding. Returns ALL findings so the CLI can
27
27
  * report every problem across every skill.
28
28
  */
29
- export declare function lintSkill(skillDir: string): LintFinding[];
29
+ export declare function lintSkill(skillDir: string, testsDir?: string): LintFinding[];
30
30
  /**
31
31
  * All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
32
32
  * Empty if none. Never throws: unreadable/dangling entries (e.g. a broken symlink, or a
package/dist/lint.js CHANGED
@@ -37,8 +37,8 @@ function isFile(p) {
37
37
  * becomes a single `code:"spec"` finding. Returns ALL findings so the CLI can
38
38
  * report every problem across every skill.
39
39
  */
40
- export function lintSkill(skillDir) {
41
- const specPath = join(skillDir, "tests", "specification.yaml");
40
+ export function lintSkill(skillDir, testsDir = join(skillDir, "tests")) {
41
+ const specPath = join(testsDir, "specification.yaml");
42
42
  const findings = [];
43
43
  let spec;
44
44
  try {
@@ -55,7 +55,8 @@ export function lintSkill(skillDir) {
55
55
  // kinds added after it shipped. Reported as a finding (so CI sees it) rather than
56
56
  // thrown, because refusing to lint is worse than linting with a caveat, and it is
57
57
  // one of the few ways this situation announces itself at all.
58
- const stale = downgradeWarning(skillDir);
58
+ const resultsSkillDir = dirname(testsDir);
59
+ const stale = downgradeWarning(resultsSkillDir);
59
60
  if (stale) {
60
61
  findings.push({ skill, code: "consistency", message: stale.replace(/^warning: /, "") });
61
62
  }
@@ -152,7 +153,11 @@ export function lintSkill(skillDir) {
152
153
  // uncovered section is only reported by `coverage --strict`. Renaming a heading
153
154
  // is the usual cause, so the finding names the near-misses.
154
155
  if (s.covers?.length) {
155
- const report = computeCoverage({ specDir, scenarios: [s] });
156
+ const report = computeCoverage({
157
+ specDir,
158
+ scenarios: [s],
159
+ fileOverrides: { "../SKILL.md": join(skillDir, "SKILL.md") },
160
+ });
156
161
  for (const b of report.broken) {
157
162
  const hint = b.didYouMean.length ? ` — did you mean ${b.didYouMean.map((x) => `#${x}`).join(", ")}?` : "";
158
163
  findings.push({
@@ -183,7 +188,7 @@ export function lintSkill(skillDir) {
183
188
  // malformed fields — e.g. `scenarios: null`) is caught and surfaces as a `consistency`
184
189
  // finding instead of throwing (lintSkill never throws) or being silently dropped (a
185
190
  // broken committed artifact must fail the gate, not pass it).
186
- const resultsRoot = join(skillDir, "tests", "results");
191
+ const resultsRoot = join(testsDir, "results");
187
192
  for (const runDir of enumerateRunDirs(resultsRoot)) {
188
193
  try {
189
194
  const raw = yaml.load(readFileSync(resultsPath(runDir), "utf8"));
@@ -316,7 +321,7 @@ export function lintSkill(skillDir) {
316
321
  // is on disk. Wrapped: lintSkill must never throw, and a stability read touches every
317
322
  // run file in the tree.
318
323
  try {
319
- for (const cell of boundaryCells(stabilityFrom(collectScoredRuns(skillDir), spec))) {
324
+ for (const cell of boundaryCells(stabilityFrom(collectScoredRuns(resultsSkillDir), spec))) {
320
325
  findings.push({
321
326
  skill, scenario: cell.id, code: "stability", severity: "info",
322
327
  message: `${cell.tag} mode=${cell.mode}: ${stabilityNote(cell)}`,
package/dist/regate.d.ts CHANGED
@@ -26,10 +26,12 @@ export interface RegateResult {
26
26
  judgeCalls: number;
27
27
  }
28
28
  /**
29
- * Re-evaluate needle gates against a run's **saved staged diffs** and re-decide the
30
- * verdicts they determined — without re-running the model.
29
+ * Re-evaluate diff, output, and trace gates against a run's saved artifacts and
30
+ * re-decide the verdicts they determined — without re-running the model.
31
31
  *
32
- * `diff_contains` / `diff_excludes` are pure functions of the diff, and since
32
+ * `diff_contains` / `diff_excludes` are pure functions of the diff, and
33
+ * `output_matches` / `output_excludes` are pure functions of the saved final
34
+ * assistant message. Since
33
35
  * `f6a5f6c` every seeded rep persists its diff as a run artifact. So the defect class
34
36
  * "the gate was wrong, the behavior wasn't" — hit three times in the reference corpus
35
37
  * (a context needle, a baseline-satisfied needle, a filename needle) — no longer costs
@@ -48,6 +50,9 @@ export interface RegateResult {
48
50
  * That third row is what keeps this cheap without guessing: a rep the judge already
49
51
  * saw has its verdict on disk, so regate re-reads it rather than re-asking.
50
52
  *
53
+ * Output gates likewise use the complete saved transcript set and never re-run the
54
+ * subject. A newly failing output gate is therefore judge-free.
55
+ *
51
56
  * **Limits, deliberately hard failures rather than partial work:** `assert.vitest` and
52
57
  * `assert.post_test` need the workspace and cannot be re-evaluated from any artifact,
53
58
  * so a scenario carrying either is not regatable. Diffs and judge-raw files are
package/dist/regate.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { basename, join } from "node:path";
3
3
  import { parseVerdict, detectMisfire } from "./grade.js";
4
- import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
4
+ import { evaluateNeedleGates, evaluateOutputGates, hasNeedleGates, hasOutputGates } from "./seeded.js";
5
5
  import { evaluateTraceGates } from "./trace-gates.js";
6
6
  import { mergeTraces, deserializeTrace } from "./execution-trace.js";
7
7
  import { judgeOneRep } from "./regrade.js";
8
- import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, deliveryStatusForObservations, } from "./results.js";
8
+ import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, findDiffFiles, findTranscriptFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, deliveryStatusForObservations, } from "./results.js";
9
9
  import { outcomesToResult } from "./reps.js";
10
10
  import { appendJournal } from "./journal.js";
11
11
  import { gatesDigest, GATES_PREFIX } from "./sources.js";
@@ -13,6 +13,19 @@ import { gatesDigest, GATES_PREFIX } from "./sources.js";
13
13
  const GATE_FAILED_RE = /: (MISSING|PRESENT)$/m;
14
14
  const TRAILER = "=== SEEDED GATES ===";
15
15
  const DIFF_HEADER = "=== STAGED DIFF ===";
16
+ const TRAILER_LINE_RE = /^=== SEEDED GATES ===[ \t]*$/gm;
17
+ const DIFF_HEADER_LINE_RE = /^=== STAGED DIFF ===[ \t]*$/gm;
18
+ /** Last exact full-line marker before `before`; model/diff substrings do not count. */
19
+ function lastLineIndexBefore(text, pattern, before = text.length) {
20
+ pattern.lastIndex = 0;
21
+ let found = -1;
22
+ for (const match of text.matchAll(pattern)) {
23
+ if (match.index >= before)
24
+ break;
25
+ found = match.index;
26
+ }
27
+ return found;
28
+ }
16
29
  /**
17
30
  * Rebuild a transcript with a fresh gates trailer, preserving the model's turns and
18
31
  * the embedded diff exactly.
@@ -24,10 +37,13 @@ const DIFF_HEADER = "=== STAGED DIFF ===";
24
37
  */
25
38
  function rewriteTranscript(path, gateLines) {
26
39
  const original = readFileSync(path, "utf8");
27
- const trailerAt = original.indexOf(TRAILER);
40
+ // The harness-owned diff header is last; the harness-owned trailer is the last
41
+ // exact trailer line before it. Prefixes and marker text inside the diff do not
42
+ // become structural boundaries.
43
+ const diffAt = lastLineIndexBefore(original, DIFF_HEADER_LINE_RE);
44
+ const trailerAt = lastLineIndexBefore(original, TRAILER_LINE_RE, diffAt === -1 ? original.length : diffAt);
28
45
  if (trailerAt === -1)
29
46
  return; // no trailer to correct (non-seeded shape); leave it alone
30
- const diffAt = original.indexOf(DIFF_HEADER);
31
47
  const head = original.slice(0, trailerAt);
32
48
  const tail = diffAt === -1 ? "" : original.slice(diffAt);
33
49
  renameSync(path, path.replace(/\.txt$/, ".pre-regate.txt"));
@@ -43,10 +59,12 @@ function verdictFromSavedJudgement(runDir, id, mode, rep) {
43
59
  return { verdict: parsed.verdict, reason: parsed.reason, suspect: detectMisfire(raw, parsed.verdict) };
44
60
  }
45
61
  /**
46
- * Re-evaluate needle gates against a run's **saved staged diffs** and re-decide the
47
- * verdicts they determined — without re-running the model.
62
+ * Re-evaluate diff, output, and trace gates against a run's saved artifacts and
63
+ * re-decide the verdicts they determined — without re-running the model.
48
64
  *
49
- * `diff_contains` / `diff_excludes` are pure functions of the diff, and since
65
+ * `diff_contains` / `diff_excludes` are pure functions of the diff, and
66
+ * `output_matches` / `output_excludes` are pure functions of the saved final
67
+ * assistant message. Since
50
68
  * `f6a5f6c` every seeded rep persists its diff as a run artifact. So the defect class
51
69
  * "the gate was wrong, the behavior wasn't" — hit three times in the reference corpus
52
70
  * (a context needle, a baseline-satisfied needle, a filename needle) — no longer costs
@@ -65,6 +83,9 @@ function verdictFromSavedJudgement(runDir, id, mode, rep) {
65
83
  * That third row is what keeps this cheap without guessing: a rep the judge already
66
84
  * saw has its verdict on disk, so regate re-reads it rather than re-asking.
67
85
  *
86
+ * Output gates likewise use the complete saved transcript set and never re-run the
87
+ * subject. A newly failing output gate is therefore judge-free.
88
+ *
68
89
  * **Limits, deliberately hard failures rather than partial work:** `assert.vitest` and
69
90
  * `assert.post_test` need the workspace and cannot be re-evaluated from any artifact,
70
91
  * so a scenario carrying either is not regatable. Diffs and judge-raw files are
@@ -87,8 +108,9 @@ export async function regateRun(opts) {
87
108
  for (const rec of prev.scenarios) {
88
109
  const s = specById.get(rec.id);
89
110
  const needles = hasNeedleGates(s ?? {});
111
+ const outputGated = hasOutputGates(s ?? {});
90
112
  const traceGated = Boolean(s?.traceAssert);
91
- if (!s || (!needles && !traceGated))
113
+ if (!s || (!needles && !outputGated && !traceGated))
92
114
  continue; // nothing for regate to re-decide
93
115
  if (s.assert?.vitest || s.assert?.post_test) {
94
116
  blocked.push(`${s.id}: declares ${s.assert.vitest ? "assert.vitest" : "assert.post_test"}, which needs the workspace — ` +
@@ -102,6 +124,10 @@ export async function regateRun(opts) {
102
124
  blocked.push(`${s.id}: staged-diff artifacts are incomplete for ${expectedReps} recorded rep(s) — regate refuses a partial repetition set`);
103
125
  continue;
104
126
  }
127
+ if (outputGated && !complete(findTranscriptFiles(opts.runDir, s.id, mode), expectedSuffixes.map((rep) => transcriptPath(opts.runDir, s.id, mode, rep)))) {
128
+ blocked.push(`${s.id}: transcript artifacts are incomplete for ${expectedReps} recorded rep(s) — regate refuses a partial repetition set`);
129
+ continue;
130
+ }
105
131
  // A trace gate is only re-decidable from a saved trace. A run recorded before
106
132
  // traces existed has none, and saying so is the whole point — pretending
107
133
  // regate can answer would report a verdict derived from no evidence.
@@ -114,7 +140,7 @@ export async function regateRun(opts) {
114
140
  }
115
141
  if (targets.length === 0) {
116
142
  throw new Error(`nothing to regate in ${opts.runDir}` +
117
- (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes or assert.trace"));
143
+ (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff/output assertions or assert.trace"));
118
144
  }
119
145
  const changes = [];
120
146
  let judgeCalls = 0;
@@ -125,11 +151,11 @@ export async function regateRun(opts) {
125
151
  scenarios.push(rec); // untouched: not regatable, or no gates
126
152
  continue;
127
153
  }
128
- const diffFiles = findDiffFiles(opts.runDir, scenario.id, mode);
129
- const traceFiles = findTraceFiles(opts.runDir, scenario.id, mode);
130
- const repKeys = diffFiles.length > 0
131
- ? diffFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: f }))
132
- : traceFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }));
154
+ const expectedReps = rec.reps ?? 1;
155
+ const repKeys = (expectedReps === 1 ? [undefined] : Array.from({ length: expectedReps }, (_, index) => index)).map((rep) => ({
156
+ rep,
157
+ diffFile: hasNeedleGates(scenario) ? basename(diffPath(opts.runDir, scenario.id, mode, rep)) : undefined,
158
+ }));
133
159
  const outcomes = [];
134
160
  // Per scenario, not run-wide: with several regated scenarios, a global counter
135
161
  // would report every change as "re-judged" because some other scenario was.
@@ -139,9 +165,24 @@ export async function regateRun(opts) {
139
165
  for (const { rep, diffFile } of repKeys) {
140
166
  const diff = diffFile ? readFileSync(join(opts.runDir, diffFile), "utf8") : "";
141
167
  const needleGate = diffFile ? evaluateNeedleGates(scenario, diff) : { lines: [], failure: null };
142
- // Trace gate, re-decided from the saved trace. Free: no model, no judge.
168
+ const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
169
+ const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
170
+ const outputGate = evaluateOutputGates(scenario, before);
171
+ // Output and trace gates are re-decided from saved artifacts. Free: no model, no judge.
143
172
  let traceFailure = null;
144
- let objective;
173
+ const expectedOutputHash = rec.objective?.rep_output_sha256?.[rep ?? 0]
174
+ ?? (rec.reps === undefined ? rec.objective?.output_sha256 : undefined);
175
+ const outputDigestMismatch = expectedOutputHash !== undefined && expectedOutputHash !== outputGate.outputSha256;
176
+ const outputIntegrityAssertion = outputDigestMismatch
177
+ ? [{ kind: "output_evidence", status: "ERROR", detail: "saved final assistant message no longer matches the hash recorded by the run" }]
178
+ : [];
179
+ let objective = hasOutputGates(scenario)
180
+ ? {
181
+ status: outputDigestMismatch ? "ERROR" : outputGate.status,
182
+ ...(outputGate.outputSha256 ? { output_sha256: outputGate.outputSha256 } : {}),
183
+ assertions: [...outputIntegrityAssertion, ...outputGate.assertions],
184
+ }
185
+ : undefined;
145
186
  if (scenario.traceAssert) {
146
187
  const tp = tracePath(opts.runDir, scenario.id, mode, rep);
147
188
  // A PARTIAL read is refused, not graded. `deserializeTrace` returns null
@@ -163,7 +204,7 @@ export async function regateRun(opts) {
163
204
  usable.length === lines.length
164
205
  ? "objective: saved trace is missing or unreadable — cannot re-evaluate assert.trace"
165
206
  : `objective: saved trace is incomplete (${usable.length}/${lines.length} turns readable) — cannot re-evaluate assert.trace`;
166
- objective = { status: "ERROR", assertions: [] };
207
+ objective = { ...(objective ?? {}), status: "ERROR", assertions: objective?.assertions ?? [] };
167
208
  }
168
209
  else {
169
210
  const g = evaluateTraceGates(scenario.traceAssert, merged);
@@ -172,7 +213,17 @@ export async function regateRun(opts) {
172
213
  const integrityAssertion = digestMismatch
173
214
  ? [{ kind: "trace_evidence", status: "ERROR", detail: "saved trace no longer matches the hash recorded by the run" }]
174
215
  : [];
175
- objective = { status: digestMismatch ? "ERROR" : g.status, trace_version: merged.trace_version, trace_sha256: merged.trace_sha256, assertions: [...integrityAssertion, ...g.assertions] };
216
+ const priorStatus = objective?.status ?? "PASS";
217
+ const status = digestMismatch || g.status === "ERROR" || priorStatus === "ERROR"
218
+ ? "ERROR"
219
+ : g.status === "FAIL" || priorStatus === "FAIL"
220
+ ? "FAIL"
221
+ : "PASS";
222
+ objective = {
223
+ ...(objective ?? { assertions: [] }), status,
224
+ trace_version: merged.trace_version, trace_sha256: merged.trace_sha256,
225
+ assertions: [...(objective?.assertions ?? []), ...integrityAssertion, ...g.assertions],
226
+ };
176
227
  if (g.status === "FAIL" || g.status === "ERROR" || digestMismatch) {
177
228
  const bad = [...integrityAssertion.map((x) => x.detail), ...g.assertions.filter((x) => x.status === g.status && g.status !== "PASS").map((x) => x.detail)];
178
229
  traceFailure = `objective: ${bad.join("; ")}`;
@@ -194,16 +245,29 @@ export async function regateRun(opts) {
194
245
  if (delivery.status !== "PASS")
195
246
  traceFailure = `objective: ${delivery.detail}`;
196
247
  }
197
- const gate = { lines: needleGate.lines, failure: needleGate.failure ?? traceFailure };
198
- const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
199
- const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
248
+ const outputFailure = outputDigestMismatch
249
+ ? "objective: saved final assistant message no longer matches the hash recorded by the run"
250
+ : outputGate.status === "ERROR" || outputGate.status === "FAIL" ? outputGate.failure : null;
251
+ const gate = { lines: [...needleGate.lines, ...outputGate.lines], failure: needleGate.failure ?? outputFailure ?? traceFailure };
200
252
  // Two sources, because the two gate kinds record their prior state
201
253
  // differently: a seeded needle gate leaves a trailer in the transcript, a
202
254
  // trace gate leaves an `objective` block on the result. Reading only the
203
255
  // trailer meant a trace gate flipping to PASS never triggered the re-judge
204
256
  // it needs, leaving a stale FAIL verdict beside a PASS objective.
205
- const oldObjectiveFailed = rec.objective?.status === "FAIL" || rec.objective?.status === "ERROR" || rec.objective?.status === "NOT-MEASURED";
206
- const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER))) || oldObjectiveFailed;
257
+ const perRepObjective = rec.rep_judgments?.find(panel => panel.repetition === (rep ?? 0))?.objective;
258
+ // Modern --reps records carry objective evidence per repetition. Falling
259
+ // back to the strict scenario aggregate there would label every rep failed
260
+ // when only one failed, and unnecessarily re-judge already-passing reps.
261
+ // Legacy records without panels have only the aggregate, so retain that
262
+ // conservative fallback for compatibility.
263
+ const oldObjective = perRepObjective ?? (rec.rep_judgments?.length ? undefined : rec.objective);
264
+ const oldObjectiveFailed = oldObjective?.status === "FAIL" || oldObjective?.status === "ERROR" || oldObjective?.status === "NOT-MEASURED";
265
+ const oldDiffAt = lastLineIndexBefore(before, DIFF_HEADER_LINE_RE);
266
+ const oldTrailerAt = lastLineIndexBefore(before, TRAILER_LINE_RE, oldDiffAt === -1 ? before.length : oldDiffAt);
267
+ const oldGateTrailer = oldTrailerAt === -1
268
+ ? ""
269
+ : before.slice(oldTrailerAt, oldDiffAt === -1 ? before.length : oldDiffAt);
270
+ const oldGateFailed = GATE_FAILED_RE.test(oldGateTrailer) || oldObjectiveFailed;
207
271
  // The trailer is regenerated whatever the outcome: leaving a stale
208
272
  // `MISSING` note beside a corrected verdict would misinform the next reader
209
273
  // (and the next judge, which reads this transcript).
@@ -284,6 +348,7 @@ export async function regateRun(opts) {
284
348
  // Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
285
349
  // and policy were not re-decided here, so their hashes stay exactly as recorded.
286
350
  source_hashes: refreshGateHashes(prev.source_hashes, targets),
351
+ source_hash_roots: prev.source_hash_roots,
287
352
  scenarios,
288
353
  }, ctx);
289
354
  appendJournal(opts.runDir, {
package/dist/regrade.js CHANGED
@@ -230,6 +230,7 @@ export async function regradeRun(opts) {
230
230
  // *stimulus* hashes stay" — see refreshRubricHashes.
231
231
  partial: prev?.partial,
232
232
  source_hashes: refreshRubricHashes(prev?.source_hashes, spec, targets),
233
+ source_hash_roots: prev?.source_hash_roots,
233
234
  scenarios: scenarioResults,
234
235
  }, ctx);
235
236
  const g = results.effective_grade;
package/dist/reps.js CHANGED
@@ -16,9 +16,11 @@ export function aggregateObjective(outcomes) {
16
16
  if (present.length === 1)
17
17
  return picked;
18
18
  const traceHashes = present.map((objective) => objective.trace_sha256);
19
+ const outputHashes = present.map((objective) => objective.output_sha256);
19
20
  return {
20
21
  ...picked,
21
22
  ...(traceHashes.every((hash) => typeof hash === "string") ? { rep_trace_sha256: traceHashes } : {}),
23
+ ...(outputHashes.every((hash) => typeof hash === "string") ? { rep_output_sha256: outputHashes } : {}),
22
24
  };
23
25
  }
24
26
  /**
package/dist/rescore.js CHANGED
@@ -98,6 +98,7 @@ export function rescoreRun(opts) {
98
98
  // that is what makes `rescore` the honest remedy lint names for it. Stimulus,
99
99
  // rubric and gate hashes are untouched: none of them was re-evaluated here.
100
100
  source_hashes: refreshPolicyHashes(prev.source_hashes, opts.spec),
101
+ source_hash_roots: prev.source_hash_roots,
101
102
  scenarios,
102
103
  }, ctx);
103
104
  appendJournal(opts.runDir, {
package/dist/restamp.d.ts CHANGED
@@ -63,6 +63,8 @@ export interface RestampReport {
63
63
  export interface RestampOptions {
64
64
  /** Git ref holding the bytes the runs measured, for a tree that has already been edited. */
65
65
  from?: string;
66
+ /** Tests/results location; defaults to <skillDir>/tests. */
67
+ testsDir?: string;
66
68
  }
67
69
  /** Upgrade every provable record under one skill's tests/results. */
68
70
  export declare function restampSkill(skillDir: string, opts?: RestampOptions): RestampReport;
package/dist/restamp.js CHANGED
@@ -101,7 +101,8 @@ function writeAtomically(path, text) {
101
101
  /** Upgrade every provable record under one skill's tests/results. */
102
102
  export function restampSkill(skillDir, opts = {}) {
103
103
  const report = { runs: 0, upgraded: 0, unprovable: 0, unchanged: 0, partial: 0, added: [] };
104
- const specPath = join(skillDir, "tests", "specification.yaml");
104
+ const testsDir = opts.testsDir ?? join(skillDir, "tests");
105
+ const specPath = join(testsDir, "specification.yaml");
105
106
  const spec = loadSpec(specPath);
106
107
  const specDir = dirname(specPath);
107
108
  // Only `system_prompt_file` paths, resolved from the CURRENT spec. The other
@@ -132,7 +133,7 @@ export function restampSkill(skillDir, opts = {}) {
132
133
  currentPrompt,
133
134
  });
134
135
  }
135
- for (const runDir of enumerateRunDirs(join(skillDir, "tests", "results"))) {
136
+ for (const runDir of enumerateRunDirs(join(testsDir, "results"))) {
136
137
  report.runs++;
137
138
  let doc;
138
139
  try {
package/dist/results.d.ts CHANGED
@@ -117,8 +117,11 @@ export interface ObjectiveResult {
117
117
  status: "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED";
118
118
  trace_version?: number;
119
119
  trace_sha256?: string;
120
+ /** SHA-256 of the final assistant message evaluated by output regex gates. */
121
+ output_sha256?: string;
120
122
  /** Per-repetition hashes retained when an aggregate has more than one rep. */
121
123
  rep_trace_sha256?: string[];
124
+ rep_output_sha256?: string[];
122
125
  assertions: {
123
126
  kind: string;
124
127
  status: "PASS" | "FAIL" | "ERROR" | "NOT-MEASURED";
@@ -238,6 +241,8 @@ export interface ResultsFile {
238
241
  * simply don't carry it, and are never retroactively flagged.
239
242
  */
240
243
  source_hashes?: Record<string, string>;
244
+ /** For split-root runs, identifies whether each hash came from --skills or --specs. */
245
+ source_hash_roots?: Record<string, "skills" | "specs">;
241
246
  effective_grade: GradeSummary;
242
247
  scenarios: ScenarioResult[];
243
248
  /** Adapter-computed model-visible prompt observations. Required for schema v3. */
package/dist/results.js CHANGED
@@ -212,6 +212,7 @@ export function finalizeResults(draft, ctx) {
212
212
  ...(draft.arm ? { arm: draft.arm } : {}),
213
213
  ...(draft.partial ? { partial: true } : {}),
214
214
  ...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
215
+ ...(draft.source_hash_roots ? { source_hash_roots: draft.source_hash_roots } : {}),
215
216
  effective_grade,
216
217
  scenarios: draft.scenarios,
217
218
  ...(draft.subject_invocations ? { subject_invocations: draft.subject_invocations } : {}),
package/dist/run.d.ts CHANGED
@@ -8,6 +8,10 @@ export interface RunOptions {
8
8
  spec: Spec;
9
9
  skillDir: string;
10
10
  specPath: string;
11
+ /** Tests/results location; defaults to <skillDir>/tests for the single-root layout. */
12
+ testsDir?: string;
13
+ /** Record per-hash skills/specs provenance for a split-root run. */
14
+ recordSourceRoots?: boolean;
11
15
  adapter: HarnessAdapter;
12
16
  model: ModelRef;
13
17
  modelToken: string;
package/dist/run.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { sourceHashes } from "./sources.js";
3
+ import { sourceHashes, sourceHashRoots } from "./sources.js";
4
4
  import { judgeResemblesSubject } from "./grade.js";
5
5
  import { NONE_ARM, seedArmDefinitions } from "./arms.js";
6
6
  import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
7
7
  import { appendJournal } from "./journal.js";
8
8
  import { liftHeadline } from "./lift.js";
9
- import { runSeeded } from "./seeded.js";
9
+ import { evaluateOutputGates, hasOutputGates, runSeeded } from "./seeded.js";
10
10
  import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
11
11
  import { evaluateTraceGates } from "./trace-gates.js";
12
12
  import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
@@ -68,7 +68,7 @@ export async function runSkillModel(opts) {
68
68
  `(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
69
69
  }
70
70
  const arm = opts.arm ?? NONE_ARM;
71
- const runDir = runDirFor(skillDir, adapter.name, model, timestamp, arm.name);
71
+ const runDir = runDirFor(dirname(opts.testsDir ?? join(skillDir, "tests")), adapter.name, model, timestamp, arm.name);
72
72
  mkdirSync(runDir, { recursive: true });
73
73
  ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
74
74
  // Which harness CLI delivered the skill, asked once per run and recorded with the
@@ -145,6 +145,7 @@ export async function runSkillModel(opts) {
145
145
  return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
146
146
  });
147
147
  const ctx = scoreContextFor({ mode, partial }, spec);
148
+ const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona });
148
149
  const results = writeResults(runDir, {
149
150
  skill: spec.skill,
150
151
  harness: adapter.name,
@@ -158,7 +159,8 @@ export async function runSkillModel(opts) {
158
159
  ...(partial ? { partial: true } : {}),
159
160
  // Only the scenarios this run actually measured: a --only run must not claim
160
161
  // coverage of scenarios it skipped.
161
- source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona }),
162
+ source_hashes: hashes,
163
+ ...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
162
164
  scenarios: scenarioResults,
163
165
  ...(arm.name === NONE_ARM.name ? {} : {
164
166
  arm: {
@@ -192,11 +194,11 @@ export async function runSkillModel(opts) {
192
194
  * ends the last assistant section.
193
195
  */
194
196
  export function hasEmptyAssistantTurn(transcript) {
195
- const sections = transcript.split(/^<<< ASSISTANT:\s*$/m).slice(1);
197
+ const sections = transcript.split(/^<<< ASSISTANT:[ \t]*$/m).slice(1);
196
198
  if (sections.length === 0)
197
199
  return false;
198
200
  return sections.some((sec) => {
199
- const body = sec.split(/^(?:>>> |=== SEEDED GATES ===|\[pi exited )/m)[0];
201
+ const body = sec.split(/^(?:>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*|=== SEEDED GATES ===[ \t]*|\[pi exited [^\]]+\][ \t]*)$/m)[0];
200
202
  return body.trim() === "";
201
203
  });
202
204
  }
@@ -394,6 +396,9 @@ async function runRep(scenario, rep, repCount, ctx) {
394
396
  }
395
397
  }
396
398
  const repSuffix = repCount > 1 ? rep : undefined;
399
+ const outputGate = !adapterFailure && !noResponse && !infrastructureFailure
400
+ ? evaluateOutputGates(scenario, transcript)
401
+ : { status: "PASS", failure: null, assertions: [], lines: [] };
397
402
  writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
398
403
  if (scenario.mode === "seeded") {
399
404
  // The workspace is torn down in the `finally` below, so this is the only
@@ -438,10 +443,11 @@ async function runRep(scenario, rep, repCount, ctx) {
438
443
  // timeout that caused it, and it is that text, not the real reason, that would
439
444
  // reach the results record. The verdict is ERROR either way.
440
445
  let objective;
441
- if (scenario.traceAssert && !adapterFailure) {
442
- const assertionResults = [];
443
- let status = "PASS";
446
+ if ((scenario.traceAssert || hasOutputGates(scenario)) && !adapterFailure && !noResponse && !infrastructureFailure) {
447
+ const assertionResults = [...outputGate.assertions];
448
+ let status = outputGate.status;
444
449
  let traceMeta = {};
450
+ const outputMeta = outputGate.outputSha256 ? { output_sha256: outputGate.outputSha256 } : {};
445
451
  if (scenario.traceAssert) {
446
452
  if (traces.length > 0) {
447
453
  writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
@@ -457,12 +463,15 @@ async function runRep(scenario, rep, repCount, ctx) {
457
463
  }
458
464
  else {
459
465
  const gate = evaluateTraceGates(scenario.traceAssert, merged);
460
- status = gate.status;
466
+ if (gate.status === "ERROR" || status === "ERROR")
467
+ status = "ERROR";
468
+ else if (gate.status === "FAIL" || status === "FAIL")
469
+ status = "FAIL";
461
470
  assertionResults.push(...gate.assertions);
462
471
  traceMeta = { trace_version: merged.trace_version, trace_sha256: merged.trace_sha256 };
463
472
  }
464
473
  }
465
- objective = { status, ...traceMeta, assertions: assertionResults };
474
+ objective = { status, ...traceMeta, ...outputMeta, assertions: assertionResults };
466
475
  if (status !== "PASS") {
467
476
  const details = assertionResults.filter((result) => result.status === status).map((result) => result.detail);
468
477
  gatePrefix = `objective: ${details.join("; ") || "structured evidence could not be evaluated"}`;
package/dist/seeded.d.ts CHANGED
@@ -82,6 +82,28 @@ export interface SeededOutcome {
82
82
  export declare function changedLines(diff: string): string;
83
83
  /** Whether a scenario declares any needle gate at all. */
84
84
  export declare function hasNeedleGates(scenario: Scenario): boolean;
85
+ /** Whether a scenario declares any final-assistant-message gate. */
86
+ export declare function hasOutputGates(scenario: Scenario): boolean;
87
+ /**
88
+ * The final assistant turn exactly as the transcript artifact records its text.
89
+ *
90
+ * Delimiter-shaped model output is ambiguous in this plain-text artifact. Refuse
91
+ * it instead of truncating the output and turning a forbidden suffix into PASS.
92
+ */
93
+ export declare function finalAssistantMessage(transcript: string, scenario: Scenario): string | null;
94
+ export interface OutputGateResult {
95
+ status: "PASS" | "FAIL" | "ERROR";
96
+ failure: string | null;
97
+ outputSha256?: string;
98
+ assertions: {
99
+ kind: "output_matches" | "output_excludes" | "output_evidence";
100
+ status: "PASS" | "FAIL" | "ERROR";
101
+ detail: string;
102
+ }[];
103
+ lines: string[];
104
+ }
105
+ /** Evaluate final-message regex gates without exposing the matched text. */
106
+ export declare function evaluateOutputGates(scenario: Scenario, transcript: string): OutputGateResult;
85
107
  /**
86
108
  * Evaluate `diff_contains` / `diff_excludes` against a staged diff: the trailer lines
87
109
  * to report, and the first failure (null when every needle is satisfied).
package/dist/seeded.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { copyFileSync, statSync } from "node:fs";
2
3
  import { extname, isAbsolute, join, resolve } from "node:path";
3
4
  import { exec } from "./util/exec.js";
@@ -60,6 +61,74 @@ export function changedLines(diff) {
60
61
  export function hasNeedleGates(scenario) {
61
62
  return (scenario.assert?.diff_contains?.length ?? 0) > 0 || (scenario.assert?.diff_excludes?.length ?? 0) > 0;
62
63
  }
64
+ /** Whether a scenario declares any final-assistant-message gate. */
65
+ export function hasOutputGates(scenario) {
66
+ return (scenario.assert?.output_matches?.length ?? 0) > 0 || (scenario.assert?.output_excludes?.length ?? 0) > 0;
67
+ }
68
+ /**
69
+ * The final assistant turn exactly as the transcript artifact records its text.
70
+ *
71
+ * Delimiter-shaped model output is ambiguous in this plain-text artifact. Refuse
72
+ * it instead of truncating the output and turning a forbidden suffix into PASS.
73
+ */
74
+ export function finalAssistantMessage(transcript, scenario) {
75
+ const assistantMarker = /^<<< ASSISTANT:[ \t]*$/gm;
76
+ const userMarker = /^>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*$/gm;
77
+ const gateMarker = /^=== SEEDED GATES ===[ \t]*$/gm;
78
+ const exitMarker = /^\[pi exited [^\]]+\][ \t]*$/gm;
79
+ const assistantCount = [...transcript.matchAll(assistantMarker)].length;
80
+ const userCount = [...transcript.matchAll(userMarker)].length;
81
+ const gateCount = [...transcript.matchAll(gateMarker)].length;
82
+ const exitCount = [...transcript.matchAll(exitMarker)].length;
83
+ const expectedGates = scenario.mode === "seeded" ? 1 : 0;
84
+ if (assistantCount !== scenario.turns.length || userCount !== scenario.turns.length || gateCount !== expectedGates || exitCount !== 0) {
85
+ return null;
86
+ }
87
+ const sections = transcript.split(/^<<< ASSISTANT:[ \t]*$/m);
88
+ return sections[sections.length - 1]
89
+ .split(/^(?:>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*|=== SEEDED GATES ===[ \t]*|\[pi exited [^\]]+\][ \t]*)$/m)[0]
90
+ .trim();
91
+ }
92
+ /** Evaluate final-message regex gates without exposing the matched text. */
93
+ export function evaluateOutputGates(scenario, transcript) {
94
+ if (!hasOutputGates(scenario))
95
+ return { status: "PASS", failure: null, assertions: [], lines: [] };
96
+ const output = finalAssistantMessage(transcript, scenario);
97
+ if (output === null) {
98
+ const detail = "final assistant message is missing or transcript delimiters are ambiguous";
99
+ return {
100
+ status: "ERROR", failure: `output evidence: ${detail}`,
101
+ assertions: [{ kind: "output_evidence", status: "ERROR", detail }],
102
+ lines: [` output: ERROR (${detail})`],
103
+ };
104
+ }
105
+ const assertions = [];
106
+ const lines = [];
107
+ let failure = null;
108
+ for (const pattern of scenario.assert?.output_matches ?? []) {
109
+ const ok = new RegExp(pattern, "m").test(output);
110
+ const detail = ok ? `final assistant message matches ${JSON.stringify(pattern)}` : `final assistant message does not match ${JSON.stringify(pattern)}`;
111
+ assertions.push({ kind: "output_matches", status: ok ? "PASS" : "FAIL", detail });
112
+ lines.push(` output_matches ${JSON.stringify(pattern)}: ${ok ? "MATCH" : "MISSING"}`);
113
+ if (!ok && !failure)
114
+ failure = `output_matches ${JSON.stringify(pattern)} did not match the final assistant message`;
115
+ }
116
+ for (const pattern of scenario.assert?.output_excludes ?? []) {
117
+ const ok = !new RegExp(pattern, "m").test(output);
118
+ const detail = ok ? `final assistant message excludes ${JSON.stringify(pattern)}` : `final assistant message matches forbidden regex ${JSON.stringify(pattern)}`;
119
+ assertions.push({ kind: "output_excludes", status: ok ? "PASS" : "FAIL", detail });
120
+ lines.push(` output_excludes ${JSON.stringify(pattern)}: ${ok ? "ABSENT" : "PRESENT"}`);
121
+ if (!ok && !failure)
122
+ failure = `output_excludes ${JSON.stringify(pattern)} matched the final assistant message`;
123
+ }
124
+ return {
125
+ status: failure ? "FAIL" : "PASS",
126
+ failure,
127
+ outputSha256: createHash("sha256").update(output).digest("hex"),
128
+ assertions,
129
+ lines,
130
+ };
131
+ }
63
132
  /**
64
133
  * Evaluate `diff_contains` / `diff_excludes` against a staged diff: the trailer lines
65
134
  * to report, and the first failure (null when every needle is satisfied).
package/dist/sources.d.ts CHANGED
@@ -54,7 +54,7 @@ export declare const FIXTURE_PREFIX = "fixture:";
54
54
  * | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
55
55
  * | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
56
56
  * | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
57
- * | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (no subject call; judges fail→pass reps) |
57
+ * | `gates:<id>` | diff/output regex gates and `assert.trace` | gate wrong, saved evidence still valid | `regate` (no subject call; judges fail→pass reps) |
58
58
  * | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
59
59
  *
60
60
  * Why this matters more than it looks: with one key, lint had exactly one remedy for
@@ -105,7 +105,7 @@ export declare function dirSha256(dir: string): string | null;
105
105
  export declare function stimulusDigest(s: Scenario): string;
106
106
  export declare function rubricDigest(s: Scenario): string;
107
107
  export declare function policyDigest(s: Scenario): string;
108
- /** Null when the scenario declares no needle gates — no key is recorded for it. */
108
+ /** Null when the scenario declares no offline-replayable gates — no key is recorded for it. */
109
109
  export declare function gatesDigest(s: Scenario): string | null;
110
110
  /** The spec-level judge persona, which is rubric for every scenario at once. */
111
111
  export declare function personaDigest(persona: string): string;
@@ -151,6 +151,8 @@ export interface SourceContext {
151
151
  * problem to report, not run's to crash on.
152
152
  */
153
153
  export declare function sourceHashes(ctx: SourceContext): Record<string, string>;
154
+ /** Per-hash root provenance for split --skills/--specs runs. */
155
+ export declare function sourceHashRoots(hashes: Record<string, string>): Record<string, "skills" | "specs">;
154
156
  /**
155
157
  * The current hash for a recorded key, or null when the source is gone.
156
158
  *
package/dist/sources.js CHANGED
@@ -58,7 +58,7 @@ export const FIXTURE_PREFIX = "fixture:";
58
58
  * | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
59
59
  * | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
60
60
  * | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
61
- * | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (no subject call; judges fail→pass reps) |
61
+ * | `gates:<id>` | diff/output regex gates and `assert.trace` | gate wrong, saved evidence still valid | `regate` (no subject call; judges fail→pass reps) |
62
62
  * | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
63
63
  *
64
64
  * Why this matters more than it looks: with one key, lint had exactly one remedy for
@@ -163,7 +163,7 @@ function walk(dir, prefix = "") {
163
163
  * A scenario's fields, sorted into the four buckets, as canonical JSON.
164
164
  *
165
165
  * One function so the exhaustive-destructure trick below covers all four digests at
166
- * once: adding a field to `Scenario` or `SeededAssert` fails the build **here** until
166
+ * once: adding a field to `Scenario` or `ScenarioAssert` fails the build **here** until
167
167
  * someone decides which bucket — and therefore which remedy — it belongs to. A field
168
168
  * nobody assigned is a permanent staleness blind spot, and a field assigned to the
169
169
  * wrong bucket is worse than that: it would tell a user `rescore` is enough when the
@@ -180,14 +180,14 @@ function facets(s) {
180
180
  // anywhere would charge a re-run (or at best a re-score) for editing a label,
181
181
  // which is the exact trap the facet split was built to remove.
182
182
  void _coversIsMetadata;
183
- const { vitest, diff_contains, diff_excludes, post_test, ...restAssert } = assert ?? {};
183
+ const { vitest, diff_contains, diff_excludes, output_matches, output_excludes, post_test, ...restAssert } = assert ?? {};
184
184
  const _assertExhaustive = restAssert;
185
185
  void _assertExhaustive;
186
186
  // `traceAssert` is a GATE, not stimulus: it is evaluated against a trace the run
187
187
  // already saved, so `regate` can re-answer it without re-running the subject model.
188
188
  // Note the asymmetry with `env.extensions` in Phase 3, which IS stimulus — one
189
189
  // changes what gets executed, the other only what we conclude from it.
190
- const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined;
190
+ const hasGates = diff_contains !== undefined || diff_excludes !== undefined || output_matches !== undefined || output_excludes !== undefined || traceAssert !== undefined;
191
191
  return {
192
192
  // `vitest` and the `post_test` PATH are stimulus, not gates: both change what the
193
193
  // run executes in the workspace, and neither can be re-evaluated from a saved
@@ -214,6 +214,8 @@ function facets(s) {
214
214
  gates: hasGates
215
215
  ? JSON.stringify([
216
216
  id, diff_contains ?? null, diff_excludes ?? null,
217
+ ...(output_matches ? [["output_matches", output_matches]] : []),
218
+ ...(output_excludes ? [["output_excludes", output_excludes]] : []),
217
219
  ...(traceAssert ? [traceAssert] : []),
218
220
  ])
219
221
  : null,
@@ -231,7 +233,7 @@ export function rubricDigest(s) {
231
233
  export function policyDigest(s) {
232
234
  return sha(facets(s).policy);
233
235
  }
234
- /** Null when the scenario declares no needle gates — no key is recorded for it. */
236
+ /** Null when the scenario declares no offline-replayable gates — no key is recorded for it. */
235
237
  export function gatesDigest(s) {
236
238
  const g = facets(s).gates;
237
239
  return g === null ? null : sha(g);
@@ -348,6 +350,10 @@ export function sourceHashes(ctx) {
348
350
  }
349
351
  return hashes;
350
352
  }
353
+ /** Per-hash root provenance for split --skills/--specs runs. */
354
+ export function sourceHashRoots(hashes) {
355
+ return Object.fromEntries(Object.keys(hashes).map((key) => [key, key === SKILL_KEY || key === SKILL_PROMPT_KEY ? "skills" : "specs"]));
356
+ }
351
357
  /**
352
358
  * The current hash for a recorded key, or null when the source is gone.
353
359
  *
package/dist/spec.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { WorkspaceKind } from "./workspace.js";
2
2
  import { type TraceAssert } from "./trace-gates.js";
3
3
  export type ScenarioMode = "inline" | "seeded";
4
- export interface SeededAssert {
4
+ export interface ScenarioAssert {
5
5
  vitest?: boolean;
6
6
  diff_contains?: string[];
7
7
  /**
@@ -11,6 +11,10 @@ export interface SeededAssert {
11
11
  * from the model's prose.
12
12
  */
13
13
  diff_excludes?: string[];
14
+ /** Regular expressions that must all match the final assistant message. */
15
+ output_matches?: string[];
16
+ /** Regular expressions that must not match the final assistant message. */
17
+ output_excludes?: string[];
14
18
  /**
15
19
  * A test file copied into the workspace AFTER the agent finishes, then run.
16
20
  * The model never sees it, so it cannot write code shaped to pass it — this
@@ -28,12 +32,12 @@ export interface Scenario {
28
32
  turns: string[];
29
33
  checklist: string[];
30
34
  fixture?: string;
31
- assert?: SeededAssert;
35
+ assert?: ScenarioAssert;
32
36
  /**
33
37
  * Objective assertions over the execution trace.
34
38
  *
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.
39
+ * Deliberately NOT part of `ScenarioAssert`: trace assertions have their own
40
+ * closed schema and opt execution into structured capture.
37
41
  * Declaring it opts the scenario into structured (`--mode json`) execution.
38
42
  */
39
43
  traceAssert?: TraceAssert;
package/dist/spec.js CHANGED
@@ -11,6 +11,21 @@ export class SpecError extends Error {
11
11
  function isStringArray(v) {
12
12
  return Array.isArray(v) && v.every((x) => typeof x === "string");
13
13
  }
14
+ function parseRegexList(v, id, field, file) {
15
+ assertStringList(v, id, field, file);
16
+ for (const pattern of v) {
17
+ if (pattern === "") {
18
+ throw new SpecError(`scenario \`${id}\` \`${field}\` patterns must not be empty`, file);
19
+ }
20
+ try {
21
+ new RegExp(pattern, "m");
22
+ }
23
+ catch (e) {
24
+ throw new SpecError(`scenario \`${id}\` \`${field}\` contains invalid regular expression ${JSON.stringify(pattern)} — ${e.message}`, file);
25
+ }
26
+ }
27
+ return v;
28
+ }
14
29
  /**
15
30
  * Require a non-empty list of strings, with a targeted error. A common authoring
16
31
  * trap: an unquoted "key: value" list item parses as a YAML mapping, not a string
@@ -176,23 +191,39 @@ export function parseSpec(text, file) {
176
191
  workspace: "none",
177
192
  remote: false,
178
193
  };
179
- // `assert.trace` is legal for inline AND seeded scenarios — it reads the
180
- // execution trace, which every run produces, not a staged diff.
194
+ // Output and trace assertions are legal for inline AND seeded scenarios.
195
+ if (s.assert !== undefined && (s.assert === null || typeof s.assert !== "object" || Array.isArray(s.assert))) {
196
+ throw new SpecError(`scenario \`${id}\` \`assert\` must be a mapping`, file);
197
+ }
181
198
  const rawAssert = s.assert;
182
199
  if (rawAssert?.trajectory !== undefined) {
183
200
  throw new SpecError(`scenario \`${id}\` uses removed \`assert.trajectory\`; delete it or replace it with an active objective gate`, file);
184
201
  }
202
+ const allowedAssertKeys = new Set([
203
+ "vitest", "diff_contains", "diff_excludes", "post_test", "trace", "output_matches", "output_excludes",
204
+ ]);
205
+ for (const key of Object.keys(rawAssert ?? {})) {
206
+ if (!allowedAssertKeys.has(key)) {
207
+ throw new SpecError(`scenario \`${id}\` has unknown \`assert\` key \`${key}\``, file);
208
+ }
209
+ }
185
210
  if (rawAssert?.trace !== undefined) {
186
211
  scenario.traceAssert = parseTraceAssert(rawAssert.trace, `${file}: scenario \`${id}\``);
187
212
  }
213
+ const assertObj = {};
214
+ if (rawAssert?.output_matches !== undefined) {
215
+ assertObj.output_matches = parseRegexList(rawAssert.output_matches, id, "assert.output_matches", file);
216
+ }
217
+ if (rawAssert?.output_excludes !== undefined) {
218
+ assertObj.output_excludes = parseRegexList(rawAssert.output_excludes, id, "assert.output_excludes", file);
219
+ }
188
220
  if (mode === "seeded") {
189
221
  if (typeof s.fixture !== "string" || s.fixture.length === 0) {
190
222
  throw new SpecError(`seeded scenario \`${id}\` requires a \`fixture\` path`, file);
191
223
  }
192
224
  scenario.fixture = s.fixture;
193
- const a = s.assert;
225
+ const a = rawAssert;
194
226
  if (a) {
195
- const assertObj = {};
196
227
  if (a.vitest !== undefined)
197
228
  assertObj.vitest = a.vitest === true;
198
229
  if (a.diff_contains !== undefined) {
@@ -232,9 +263,10 @@ export function parseSpec(text, file) {
232
263
  }
233
264
  assertObj.post_test = a.post_test.trim();
234
265
  }
235
- scenario.assert = assertObj;
236
266
  }
237
267
  }
268
+ if (Object.keys(assertObj).length > 0)
269
+ scenario.assert = assertObj;
238
270
  if (s.env && typeof s.env === "object" && Object.hasOwn(s.env, "event_sources")) {
239
271
  throw new SpecError(`scenario \`${id}\` uses removed \`env.event_sources\`; it is no longer collected`, file);
240
272
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,6 +80,12 @@
80
80
  "type": "string"
81
81
  }
82
82
  },
83
+ "source_hash_roots": {
84
+ "type": "object",
85
+ "additionalProperties": {
86
+ "enum": ["skills", "specs"]
87
+ }
88
+ },
83
89
  "effective_grade": {
84
90
  "$ref": "#/$defs/grade"
85
91
  },
@@ -347,6 +353,10 @@
347
353
  "trace_sha256": {
348
354
  "type": "string"
349
355
  },
356
+ "output_sha256": {
357
+ "type": "string",
358
+ "pattern": "^[a-fA-F0-9]{64}$"
359
+ },
350
360
  "rep_trace_sha256": {
351
361
  "type": "array",
352
362
  "items": {
@@ -354,6 +364,13 @@
354
364
  "pattern": "^[a-fA-F0-9]{64}$"
355
365
  }
356
366
  },
367
+ "rep_output_sha256": {
368
+ "type": "array",
369
+ "items": {
370
+ "type": "string",
371
+ "pattern": "^[a-fA-F0-9]{64}$"
372
+ }
373
+ },
357
374
  "assertions": {
358
375
  "type": "array",
359
376
  "items": {
@@ -81,6 +81,12 @@
81
81
  "type": "string"
82
82
  }
83
83
  },
84
+ "source_hash_roots": {
85
+ "type": "object",
86
+ "additionalProperties": {
87
+ "enum": ["skills", "specs"]
88
+ }
89
+ },
84
90
  "effective_grade": {
85
91
  "$ref": "#/$defs/grade"
86
92
  },
@@ -374,6 +380,10 @@
374
380
  "trace_sha256": {
375
381
  "type": "string"
376
382
  },
383
+ "output_sha256": {
384
+ "type": "string",
385
+ "pattern": "^[a-fA-F0-9]{64}$"
386
+ },
377
387
  "trajectory_version": {
378
388
  "const": "1.0"
379
389
  },
@@ -394,6 +404,13 @@
394
404
  "pattern": "^[a-fA-F0-9]{64}$"
395
405
  }
396
406
  },
407
+ "rep_output_sha256": {
408
+ "type": "array",
409
+ "items": {
410
+ "type": "string",
411
+ "pattern": "^[a-fA-F0-9]{64}$"
412
+ }
413
+ },
397
414
  "assertions": {
398
415
  "type": "array",
399
416
  "items": {
@@ -157,6 +157,22 @@
157
157
  "minLength": 1
158
158
  }
159
159
  },
160
+ "output_matches": {
161
+ "type": "array",
162
+ "minItems": 1,
163
+ "items": {
164
+ "type": "string",
165
+ "minLength": 1
166
+ }
167
+ },
168
+ "output_excludes": {
169
+ "type": "array",
170
+ "minItems": 1,
171
+ "items": {
172
+ "type": "string",
173
+ "minLength": 1
174
+ }
175
+ },
160
176
  "post_test": {
161
177
  "type": "string"
162
178
  },