@skill-harness/core 0.19.0 → 0.21.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,10 +1,8 @@
1
1
  /**
2
- * Data contracts for the pi-native regression capture program.
2
+ * Persisted data contracts for Pi execution traces.
3
3
  *
4
- * Types only — no behavior. Phase 0 of
5
- * `docs/superpowers/plans/2026-08-07-pi-native-regression-capture-program.md`
6
- * fixes the shapes before any parser, capture UI, or gate evaluator is written,
7
- * because both shapes get persisted and a persisted shape is expensive to move.
4
+ * Types only — no behavior. Both shapes are versioned because persisted formats
5
+ * are expensive to move and readers must fail closed on unsupported versions.
8
6
  *
9
7
  * Both carry an explicit version. The pi event stream they derive from is not a
10
8
  * stable public contract (measured against pi 0.83.0; see
@@ -1,10 +1,8 @@
1
1
  /**
2
- * Data contracts for the pi-native regression capture program.
2
+ * Persisted data contracts for Pi execution traces.
3
3
  *
4
- * Types only — no behavior. Phase 0 of
5
- * `docs/superpowers/plans/2026-08-07-pi-native-regression-capture-program.md`
6
- * fixes the shapes before any parser, capture UI, or gate evaluator is written,
7
- * because both shapes get persisted and a persisted shape is expensive to move.
4
+ * Types only — no behavior. Both shapes are versioned because persisted formats
5
+ * are expensive to move and readers must fail closed on unsupported versions.
8
6
  *
9
7
  * Both carry an explicit version. The pi event stream they derive from is not a
10
8
  * stable public contract (measured against pi 0.83.0; see
@@ -1,30 +1,35 @@
1
+ import { type ModelRef } from "./adapters/types.js";
1
2
  /**
2
- * The judge used when nothing else says otherwise: Opus through the **`claude-code`
3
- * provider**, which authenticates with the user's Claude subscription (OAuth via
4
- * `claude -p`) rather than a metered API key.
3
+ * The judge used when nothing else says otherwise: a Pi `openai-codex` model,
4
+ * authenticated through the user's ChatGPT subscription rather than a metered API
5
+ * key. Subjects and judges now share the sole supported harness executable: `pi`.
5
6
  *
6
- * The model is deliberately the strongest available — judging is the one place
7
- * where a weak model silently corrupts every number in a scorecard. The *provider*
8
- * is what changed in 0.3.3: the default was `anthropic:claude-opus-4-8`, a metered
9
- * API, and it billed a corpus once by accident because nothing in the tool surface
10
- * distinguishes "the flag I forgot" from "the flag I meant". A default must not be
11
- * able to spend money that was not asked for. The metered path is still one flag
12
- * away (`--judge anthropic:claude-opus-4-8`) for anyone who wants it — an API key
13
- * scales past a subscription's rate limits, which matters for a large `--reps` run.
7
+ * A default must not be able to spend money that was not explicitly authorized.
8
+ * Direct API providers remain available only through the metered-judge opt-in.
14
9
  */
15
- export declare const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
10
+ export declare const BAKED_DEFAULT_JUDGE = "openai-codex:gpt-5.6-sol";
16
11
  /**
17
12
  * Resolve the default judge: `SKILL_HARNESS_JUDGE` if set, else the baked value.
18
13
  * An explicit `--judge` always wins over both — this is only the default.
19
14
  *
20
15
  * The env layer exists because this harness is built for someone who steers a
21
16
  * process rather than typing every flag: judge policy belongs to the repo or the
22
- * shell, set once. It is also how you opt *into* the metered API deliberately
23
- * (`SKILL_HARNESS_JUDGE=anthropic:claude-opus-4-8`) instead of by forgetting a
24
- * flag. Read through `readEnv`, so the pre-rename `SKILL_CHECK_JUDGE` keeps working
17
+ * shell, set once. It is also how you opt *into* a different judge deliberately
18
+ * instead of repeating a flag. Read through `readEnv`, so the pre-rename
19
+ * `SKILL_CHECK_JUDGE` keeps working
25
20
  * with the usual one-time notice.
26
21
  *
27
22
  * Resolved per call, not at module load: tests and long-lived processes (the pi
28
23
  * extension) must see an env change without a reload.
29
24
  */
30
25
  export declare function defaultJudge(): string;
26
+ /**
27
+ * Choose a judge for an artifact-only operation when the caller supplied no
28
+ * explicit override. Retained results may name the removed direct Claude
29
+ * adapter; keep those files readable, but execute their next judge call through
30
+ * today's Pi default and let the rewrite record that current judge.
31
+ */
32
+ export declare function recordedJudgeOrDefault(recorded?: ModelRef): {
33
+ judge: ModelRef;
34
+ migratedFrom?: ModelRef;
35
+ };
package/dist/defaults.js CHANGED
@@ -1,28 +1,23 @@
1
+ import { parseModelRef } from "./adapters/types.js";
1
2
  import { readEnv } from "./util/env.js";
2
3
  /**
3
- * The judge used when nothing else says otherwise: Opus through the **`claude-code`
4
- * provider**, which authenticates with the user's Claude subscription (OAuth via
5
- * `claude -p`) rather than a metered API key.
4
+ * The judge used when nothing else says otherwise: a Pi `openai-codex` model,
5
+ * authenticated through the user's ChatGPT subscription rather than a metered API
6
+ * key. Subjects and judges now share the sole supported harness executable: `pi`.
6
7
  *
7
- * The model is deliberately the strongest available — judging is the one place
8
- * where a weak model silently corrupts every number in a scorecard. The *provider*
9
- * is what changed in 0.3.3: the default was `anthropic:claude-opus-4-8`, a metered
10
- * API, and it billed a corpus once by accident because nothing in the tool surface
11
- * distinguishes "the flag I forgot" from "the flag I meant". A default must not be
12
- * able to spend money that was not asked for. The metered path is still one flag
13
- * away (`--judge anthropic:claude-opus-4-8`) for anyone who wants it — an API key
14
- * scales past a subscription's rate limits, which matters for a large `--reps` run.
8
+ * A default must not be able to spend money that was not explicitly authorized.
9
+ * Direct API providers remain available only through the metered-judge opt-in.
15
10
  */
16
- export const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
11
+ export const BAKED_DEFAULT_JUDGE = "openai-codex:gpt-5.6-sol";
17
12
  /**
18
13
  * Resolve the default judge: `SKILL_HARNESS_JUDGE` if set, else the baked value.
19
14
  * An explicit `--judge` always wins over both — this is only the default.
20
15
  *
21
16
  * The env layer exists because this harness is built for someone who steers a
22
17
  * process rather than typing every flag: judge policy belongs to the repo or the
23
- * shell, set once. It is also how you opt *into* the metered API deliberately
24
- * (`SKILL_HARNESS_JUDGE=anthropic:claude-opus-4-8`) instead of by forgetting a
25
- * flag. Read through `readEnv`, so the pre-rename `SKILL_CHECK_JUDGE` keeps working
18
+ * shell, set once. It is also how you opt *into* a different judge deliberately
19
+ * instead of repeating a flag. Read through `readEnv`, so the pre-rename
20
+ * `SKILL_CHECK_JUDGE` keeps working
26
21
  * with the usual one-time notice.
27
22
  *
28
23
  * Resolved per call, not at module load: tests and long-lived processes (the pi
@@ -31,4 +26,17 @@ export const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
31
26
  export function defaultJudge() {
32
27
  return readEnv("JUDGE") ?? BAKED_DEFAULT_JUDGE;
33
28
  }
29
+ /**
30
+ * Choose a judge for an artifact-only operation when the caller supplied no
31
+ * explicit override. Retained results may name the removed direct Claude
32
+ * adapter; keep those files readable, but execute their next judge call through
33
+ * today's Pi default and let the rewrite record that current judge.
34
+ */
35
+ export function recordedJudgeOrDefault(recorded) {
36
+ if (!recorded)
37
+ return { judge: parseModelRef(defaultJudge()) };
38
+ if (recorded.provider !== "claude-code")
39
+ return { judge: recorded };
40
+ return { judge: parseModelRef(defaultJudge()), migratedFrom: recorded };
41
+ }
34
42
  //# sourceMappingURL=defaults.js.map
@@ -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
@@ -5,9 +5,8 @@ import { redactArgs, redactText } from "./redaction.js";
5
5
  /**
6
6
  * Parse pi's `--mode json` event stream into an `ExecutionTraceV1`.
7
7
  *
8
- * Measured against pi 0.83.0; see `docs/pi-native-capture-design-2026-08-08.md`
9
- * and the fixtures in `packages/adapters/test/fixtures/pi-json/`. Three findings
10
- * from that spike are baked in here and each one is a bug if removed:
8
+ * Pinned by the fixtures in `packages/adapters/test/fixtures/pi-json/`. Three
9
+ * event-shape constraints are baked in here and each one is a bug if removed:
11
10
  *
12
11
  * 1. **Line-at-a-time, never buffered.** `message_update` re-sends the entire
13
12
  * accumulated message on every delta, so the stream is quadratic in output
package/dist/grade.d.ts CHANGED
@@ -54,6 +54,6 @@ export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef
54
54
  /**
55
55
  * Grade a transcript in a fresh, isolated, throwaway workspace — never the
56
56
  * subject's scenario dir — so the judge can't ingest repo context the subject
57
- * left behind (matters for CLI judges that read cwd, e.g. claude-code).
57
+ * left behind (matters for provider CLIs that read cwd, including Pi).
58
58
  */
59
59
  export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string): Promise<GradeResult>;
package/dist/grade.js CHANGED
@@ -139,7 +139,7 @@ export async function gradeTranscript(adapter, judge, prompt, cwd) {
139
139
  /**
140
140
  * Grade a transcript in a fresh, isolated, throwaway workspace — never the
141
141
  * subject's scenario dir — so the judge can't ingest repo context the subject
142
- * left behind (matters for CLI judges that read cwd, e.g. claude-code).
142
+ * left behind (matters for provider CLIs that read cwd, including Pi).
143
143
  */
144
144
  export async function judgeInWorkspace(adapter, judge, prompt, specDir) {
145
145
  const ws = createWorkspace("none", { specDir });
@@ -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"));
@@ -31,8 +31,8 @@ export interface JudgeAllowOpts {
31
31
  *
32
32
  * That third path is latent rather than live in the corpus this was built against:
33
33
  * checked 2026-08-05, all ~140 committed `results.yaml` in `principal-pi-skills`
34
- * record `provider: claude-code`, because its owner always passed the subscription
35
- * judge explicitly. The old default was reachable, not taken. Worth stating
34
+ * recorded the then-supported subscription judge explicitly. The old default was
35
+ * reachable, not taken. Worth stating
36
36
  * precisely — "your whole archive bills on regrade" would have been a scarier claim
37
37
  * than the evidence supports.
38
38
  *
@@ -3,9 +3,8 @@ import { BAKED_DEFAULT_JUDGE } from "./defaults.js";
3
3
  /**
4
4
  * Judge providers that cannot bill a per-token API.
5
5
  *
6
- * `claude-code` shells out to the `claude` CLI, authenticated with the user's
7
- * Claude subscription (OAuth). `openai-codex` stays in Pi and authenticates with
8
- * the user's ChatGPT subscription (OAuth). `ollama`/`lmstudio`/`llamacpp`/`local`
6
+ * `openai-codex` runs through Pi and authenticates with the user's ChatGPT
7
+ * subscription (OAuth). `ollama`/`lmstudio`/`llamacpp`/`local`
9
8
  * are local runtimes. Everything else — including direct `openai` — is assumed
10
9
  * to charge.
11
10
  *
@@ -14,7 +13,7 @@ import { BAKED_DEFAULT_JUDGE } from "./defaults.js";
14
13
  * being wrong in that direction produces a surprise invoice while being wrong in
15
14
  * this direction produces one extra flag.
16
15
  */
17
- const FREE_JUDGE_PROVIDERS = new Set(["claude-code", "openai-codex", "ollama", "lmstudio", "llamacpp", "local"]);
16
+ const FREE_JUDGE_PROVIDERS = new Set(["openai-codex", "ollama", "lmstudio", "llamacpp", "local"]);
18
17
  /** Whether judging with this ref can charge a per-token API. */
19
18
  export function isMeteredJudge(judge) {
20
19
  return !FREE_JUDGE_PROVIDERS.has(judge.provider);
@@ -40,8 +39,8 @@ export function allowMeteredJudge() {
40
39
  *
41
40
  * That third path is latent rather than live in the corpus this was built against:
42
41
  * checked 2026-08-05, all ~140 committed `results.yaml` in `principal-pi-skills`
43
- * record `provider: claude-code`, because its owner always passed the subscription
44
- * judge explicitly. The old default was reachable, not taken. Worth stating
42
+ * recorded the then-supported subscription judge explicitly. The old default was
43
+ * reachable, not taken. Worth stating
45
44
  * precisely — "your whole archive bills on regrade" would have been a scarier claim
46
45
  * than the evidence supports.
47
46
  *
@@ -49,6 +48,9 @@ export function allowMeteredJudge() {
49
48
  * is what a run *is*.
50
49
  */
51
50
  export function assertJudgeAllowed(judge, opts) {
51
+ if (judge.provider === "claude-code") {
52
+ throw new Error("judge provider `claude-code` was removed; choose a provider configured in Pi, such as `openai-codex`");
53
+ }
52
54
  if (!isMeteredJudge(judge))
53
55
  return;
54
56
  if (opts.allowMetered || allowMeteredJudge())
@@ -56,7 +58,7 @@ export function assertJudgeAllowed(judge, opts) {
56
58
  const token = `${judge.provider}:${judge.model}`;
57
59
  throw new Error(`refusing to judge with ${token}: \`${judge.provider}\` bills a per-token API key, and it came from ${opts.source}.\n` +
58
60
  ` Judging is meant to cost nothing you did not ask for.\n` +
59
- ` • judge on your Claude subscription instead: --judge ${BAKED_DEFAULT_JUDGE}\n` +
61
+ ` • judge through Pi on your subscription: --judge ${BAKED_DEFAULT_JUDGE}\n` +
60
62
  ` • allow the metered API for this command: --allow-metered-judge\n` +
61
63
  ` • allow it for this repo or shell: export SKILL_HARNESS_ALLOW_METERED_JUDGE=1`);
62
64
  }
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.js CHANGED
@@ -284,6 +284,7 @@ export async function regateRun(opts) {
284
284
  // Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
285
285
  // and policy were not re-decided here, so their hashes stay exactly as recorded.
286
286
  source_hashes: refreshGateHashes(prev.source_hashes, targets),
287
+ source_hash_roots: prev.source_hash_roots,
287
288
  scenarios,
288
289
  }, ctx);
289
290
  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/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
@@ -238,6 +238,8 @@ export interface ResultsFile {
238
238
  * simply don't carry it, and are never retroactively flagged.
239
239
  */
240
240
  source_hashes?: Record<string, string>;
241
+ /** For split-root runs, identifies whether each hash came from --skills or --specs. */
242
+ source_hash_roots?: Record<string, "skills" | "specs">;
241
243
  effective_grade: GradeSummary;
242
244
  scenarios: ScenarioResult[];
243
245
  /** 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,6 +1,6 @@
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";
@@ -19,10 +19,8 @@ import { aggregateMetrics } from "./metrics.js";
19
19
  import { providerFailureFromTranscript } from "./provider-failure.js";
20
20
  /**
21
21
  * The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
22
- * `PI_GRANTS_LEDGER` at exactly this filename inside the run dir (see
23
- * `../principal-pi-skills/tests/arms.yaml` and CODEX-ARMS-RUNBOOK.md §4.3), so
24
- * a fixed name here is what makes the count readable without parsing the arm's
25
- * own `env` map back out.
22
+ * `PI_GRANTS_LEDGER` at exactly this filename inside the run directory. A fixed
23
+ * name keeps the count readable without parsing the arm's `env` map back out.
26
24
  */
27
25
  const LEDGER_FILENAME = "pi-daddy.ledger.jsonl";
28
26
  /**
@@ -70,7 +68,7 @@ export async function runSkillModel(opts) {
70
68
  `(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
71
69
  }
72
70
  const arm = opts.arm ?? NONE_ARM;
73
- 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);
74
72
  mkdirSync(runDir, { recursive: true });
75
73
  ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
76
74
  // Which harness CLI delivered the skill, asked once per run and recorded with the
@@ -147,6 +145,7 @@ export async function runSkillModel(opts) {
147
145
  return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
148
146
  });
149
147
  const ctx = scoreContextFor({ mode, partial }, spec);
148
+ const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona });
150
149
  const results = writeResults(runDir, {
151
150
  skill: spec.skill,
152
151
  harness: adapter.name,
@@ -160,7 +159,8 @@ export async function runSkillModel(opts) {
160
159
  ...(partial ? { partial: true } : {}),
161
160
  // Only the scenarios this run actually measured: a --only run must not claim
162
161
  // coverage of scenarios it skipped.
163
- 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) } : {}),
164
164
  scenarios: scenarioResults,
165
165
  ...(arm.name === NONE_ARM.name ? {} : {
166
166
  arm: {
package/dist/sources.d.ts CHANGED
@@ -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
@@ -348,6 +348,10 @@ export function sourceHashes(ctx) {
348
348
  }
349
349
  return hashes;
350
350
  }
351
+ /** Per-hash root provenance for split --skills/--specs runs. */
352
+ export function sourceHashRoots(hashes) {
353
+ return Object.fromEntries(Object.keys(hashes).map((key) => [key, key === SKILL_KEY || key === SKILL_PROMPT_KEY ? "skills" : "specs"]));
354
+ }
351
355
  /**
352
356
  * The current hash for a recorded key, or null when the source is gone.
353
357
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.19.0",
3
+ "version": "0.21.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
  },
@@ -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
  },