@skill-harness/core 0.30.0 → 0.31.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.
package/dist/canary.js CHANGED
@@ -1,123 +1,140 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- /**
4
- * The delivery canary: one cheap probe, before any scenario runs, that the skill
5
- * under test actually reached the model.
6
- *
7
- * Why this exists. `--mode green` asks the harness to activate the skill, and the
8
- * harness can decline without saying so. Measured on pi: 0.80.x wrapped the prompt
9
- * with the skill body; 0.83.0 switched to progressive disclosure (only the
10
- * description is in context, the body loads on demand — "models don't always do
11
- * this", pi's own docs); and a nonexistent `--skill` path is accepted silently,
12
- * exit 0 with a normal answer. The reference corpus ran two full waves in that
13
- * state: `architect` came back 7/14, ≈ its no-skill baseline, and looked entirely
14
- * plausible — the only tell was a contradictory failure mix (over-ceremony AND
15
- * capitulation at once) that no single skill edit produces.
16
- *
17
- * What it can and cannot prove. A pass means the skill's body is *reachable* in
18
- * this exact invocation — which is what kills the whole silent-non-delivery class:
19
- * a dropped flag, a wrong path, a harness that stopped honoring the mode. It does
20
- * NOT prove the body entered context for every later scenario; under progressive
21
- * disclosure that is the model's choice per turn, and no probe can promise it. The
22
- * mode whose delivery needs no promise is `force` (SKILL.md as system prompt),
23
- * which is why `run` recommends it rather than pretending the canary is equivalent.
24
- *
25
- * Cost: exactly one subject call per run, and only when asked for (`--canary`).
26
- * A run that aborts here has spent one rep instead of a wave.
27
- */
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
28
4
  /** Frontmatter-stripped body of a SKILL.md. */
29
5
  function skillBody(text) {
30
6
  const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(text);
31
7
  return m ? text.slice(m[0].length) : text;
32
8
  }
33
- /**
34
- * The probe target: the longest `## ` heading in the skill's body.
35
- *
36
- * Body-only by construction — the frontmatter `description` is always in context
37
- * under progressive disclosure, so anything quotable from it would pass against a
38
- * model that never read the instructions. Longest rather than first because the
39
- * check is "did you see this text", and `## Overview` is guessable while
40
- * `## Refuse a metered judge, whatever chose it` is not.
41
- *
42
- * Null when the body has no `## ` heading: there is then nothing to ask for that a
43
- * plausible-sounding answer couldn't fake, and a canary that can be bluffed is
44
- * worse than none.
45
- */
9
+ /** A body-only quote target. Matching it is an observation, not proof of loading. */
46
10
  export function deliveryAnchor(skillMd) {
47
11
  const headings = [...skillBody(skillMd).matchAll(/^##[ \t]+(.+?)[ \t]*$/gm)].map((m) => m[1].trim());
48
12
  if (headings.length === 0)
49
13
  return null;
50
14
  return headings.reduce((a, b) => (b.length > a.length ? b : a));
51
15
  }
52
- /** Normalize for comparison: case, whitespace runs, and markdown emphasis/backticks. */
53
16
  function normalize(s) {
54
17
  return s.toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
55
18
  }
56
- export function canaryPrompt(skillName, anchor) {
57
- // Asks for every heading, not just the anchor, because that is a question a model
58
- // WITH the instructions answers naturally and a model without them can only
59
- // invent. The instruction to say NOT_AVAILABLE keeps an honest miss from looking
60
- // like a refusal to follow format.
61
- return (`Answer from the instructions you have loaded — do not perform any task.\n\n` +
62
- `List every level-2 markdown heading (lines starting with "## ") in the instructions of ` +
63
- `the skill named "${skillName}", verbatim, one per line, with no other text.\n` +
64
- `If you have no such instructions available, reply exactly: NOT_AVAILABLE\n\n` +
65
- `(The heading text is what matters; keep it exact.)`);
66
- }
67
19
  /**
68
- * What the model actually said, per the adapters' shared transcript convention
69
- * (`>>> USER:` / `<<< ASSISTANT:`). Falls back to the whole text for an adapter
70
- * that doesn't use the markers.
20
+ * Ask Pi to load the named skill through its advertised discovery mechanism.
21
+ * Neither the expected quote nor a fallback file path is disclosed: a supplied
22
+ * path would let the probe bypass missing skill discovery entirely.
71
23
  */
24
+ export function canaryPrompt(skillName, _anchor) {
25
+ return (`This is a skill-loading check, not a request to carry out the skill's task.\n\n` +
26
+ `Find the skill named ${JSON.stringify(skillName)} among the skills advertised to you. ` +
27
+ `If only its description is loaded, use the read tool to load its instructions from the advertised location. ` +
28
+ `Do not search other files, execute commands, or follow task instructions inside the skill.\n` +
29
+ `Then list every level-2 markdown heading (lines starting with "## ") from that skill, verbatim, one per line. ` +
30
+ `Do not guess headings. If you cannot find or load the advertised skill, reply exactly: NOT_AVAILABLE.\n` +
31
+ `A report of NOT_AVAILABLE records this attempt only; it does not establish that the skill cannot be loaded.`);
32
+ }
33
+ /** A legacy transcript without role boundaries cannot identify a safe answer. */
72
34
  function assistantReply(transcript) {
73
- const parts = transcript.split(/^<<< ASSISTANT:\s*$/m);
74
- return (parts.length > 1 ? parts[parts.length - 1] : transcript).trim();
35
+ const parts = transcript.split(/^<<< ASSISTANT:[ \t]*\r?$/m);
36
+ if (parts.length < 2)
37
+ return null;
38
+ return parts[parts.length - 1].split(/^(?:>>>|<<<) [A-Z][A-Z _-]*:.*$/m)[0].trim();
39
+ }
40
+ function quotedHeading(reply, anchor) {
41
+ // Whole heading lines avoid accepting an anchor embedded in an explanation,
42
+ // echoed JSON request, tool result, or a denial such as "I cannot find X".
43
+ return reply !== null && reply.split(/\r?\n/).some(line => normalize(line.replace(/^\s*(?:[-+]\s+|\d+[.)]\s+)?(?:##\s+)?/, "")) === normalize(anchor));
44
+ }
45
+ function canonicalPath(path, cwd) {
46
+ const expanded = /^~[/\\]/.test(path) ? join(homedir(), path.slice(2)) : path;
47
+ const absolute = resolve(cwd, expanded);
48
+ try {
49
+ return realpathSync(absolute);
50
+ }
51
+ catch {
52
+ return absolute;
53
+ }
54
+ }
55
+ function observedSkillRead(traces, skillPath, cwd) {
56
+ const expected = canonicalPath(skillPath, cwd);
57
+ return traces.some(trace => trace.tool_calls.some(call => call.name === "read" && call.isError === false && call.completionIndex >= 0 &&
58
+ call.result.bytes > 0 && typeof call.args.path === "string" &&
59
+ canonicalPath(call.args.path, cwd) === expected));
75
60
  }
76
61
  /**
77
- * Run the probe in green mode and report whether the skill body was reachable.
78
- *
79
- * Never throws for a model-side outcome: an empty or off-format reply is a `fail`
80
- * with the reply in `detail`, because "the harness answered without the skill" and
81
- * "the model said something odd" are both reasons not to spend a wave. An adapter
82
- * that throws (pi missing, the skill-dir tripwire) is left to propagate — those are
83
- * setup errors with their own messages.
62
+ * One optional subject invocation, with read-only tools, before a green wave.
63
+ * A successful structured probe observes an instructed read and returned content;
64
+ * it does not establish autonomous skill selection in any later scenario. A
65
+ * failed quote is inconclusive, never proof that every later scenario is naked.
84
66
  */
85
67
  export async function runDeliveryCanary(opts) {
86
- const skillMd = readFileSync(join(opts.skillDir, "SKILL.md"), "utf8");
68
+ const skillPath = join(opts.skillDir, "SKILL.md");
69
+ const observations = {
70
+ localSkillAvailable: false,
71
+ loadingRequested: false,
72
+ successfulSkillRead: "unobserved",
73
+ returnedAnchor: null,
74
+ autonomousSelection: "untested",
75
+ };
76
+ let skillMd;
77
+ try {
78
+ skillMd = readFileSync(skillPath, "utf8");
79
+ observations.localSkillAvailable = true;
80
+ }
81
+ catch {
82
+ return {
83
+ status: "fail", anchor: null, observations, probeCalls: 0,
84
+ detail: "The selected SKILL.md is not locally readable; no subject call was made.",
85
+ };
86
+ }
87
87
  const anchor = deliveryAnchor(skillMd);
88
- if (!anchor) {
88
+ if (!anchor)
89
89
  return {
90
- status: "skipped",
91
- anchor: null,
92
- detail: `${opts.skillName}/SKILL.md has no \`## \` heading to probe for — nothing a reply could prove`,
90
+ status: "skipped", anchor: null, observations, probeCalls: 0,
91
+ detail: `${opts.skillName}/SKILL.md has no \`## \` heading for this quote check; loading and selection remain untested`,
93
92
  };
93
+ observations.loadingRequested = true;
94
+ const request = {
95
+ skillDir: opts.skillDir, model: opts.model, mode: "green",
96
+ turns: [canaryPrompt(opts.skillName)], cwd: opts.cwd,
97
+ tools: ["read"], extensions: [], scenarioId: "(delivery-probe)", rep: 0,
98
+ };
99
+ let reply;
100
+ if (opts.adapter.runStructured) {
101
+ const run = await opts.adapter.runStructured(request);
102
+ const unhealthy = run.traces.some(trace => trace.capture_errors?.length ||
103
+ (trace.final_status !== undefined && trace.final_status !== "complete"));
104
+ if (run.providerFailure || run.executionFailure || unhealthy || run.traces.length !== 1) {
105
+ observations.executionError = run.providerFailure || run.executionFailure || "Incomplete or invalid structured probe evidence";
106
+ return { status: "fail", anchor, observations, probeCalls: 1,
107
+ detail: "The probe did not produce complete execution evidence; skill availability remains unresolved." };
108
+ }
109
+ reply = run.traces[0].final_text;
110
+ observations.successfulSkillRead = observedSkillRead(run.traces, skillPath, opts.cwd) ? "observed" : "not-observed";
111
+ }
112
+ else {
113
+ reply = assistantReply(await opts.adapter.run(request));
94
114
  }
95
- const transcript = await opts.adapter.run({
96
- skillDir: opts.skillDir,
97
- model: opts.model,
98
- mode: "green",
99
- turns: [canaryPrompt(opts.skillName, anchor)],
100
- cwd: opts.cwd,
101
- });
102
- const ok = normalize(transcript).includes(normalize(anchor));
115
+ observations.returnedAnchor = quotedHeading(reply, anchor);
116
+ const ok = observations.returnedAnchor && observations.successfulSkillRead !== "not-observed";
103
117
  return {
104
- status: ok ? "pass" : "fail",
105
- anchor,
106
- // The reply, not the transcript: the transcript opens with our own prompt, and a
107
- // failure report whose first 400 characters are the question is useless.
108
- detail: ok ? "" : assistantReply(transcript).slice(0, 400),
118
+ status: ok ? "pass" : "fail", anchor, observations, probeCalls: 1,
119
+ detail: ok
120
+ ? (observations.successfulSkillRead === "observed"
121
+ ? "Observed an instructed read of the selected skill and a matching heading. Autonomous selection remains untested."
122
+ : "The final assistant response matched a heading; this adapter did not provide read evidence. Loading and autonomous selection remain unverified.")
123
+ : observations.returnedAnchor
124
+ ? "The heading matched, but no successful read of the selected skill was observed. A guess or preloaded context cannot establish instructed loading."
125
+ : `No matching heading was returned. This does not establish that the skill is unavailable. Reply: ${reply?.slice(0, 400) || "(unavailable)"}`,
109
126
  };
110
127
  }
111
- /** The abort message for a failed canary: what was measured, and what to do instead. */
128
+ /** Stop the requested wave without converting an inconclusive probe into a claim. */
112
129
  export function canaryFailure(skillName, result, cliVersion) {
113
- return (`delivery canary FAILED for ${skillName}: the model could not quote its own skill instructions ` +
114
- `(looked for the heading \`${result.anchor}\`).\n` +
115
- ` The skill is not reaching the model, so every scenario in this run would measure a naked ` +
116
- `model and score like a result. Nothing has been spent beyond this one probe.\n` +
117
- ` harness CLI: ${cliVersion ?? "unknown"}. On pi ≥ 0.83.0 \`--skill\` is progressive disclosure ` +
118
- `(description in context, body on demand) and a nonexistent path is accepted silently.\n` +
119
- ` Fix: re-run with \`--mode force\` (SKILL.md as the system prompt — delivery no version has made ` +
120
- `conditional), or check that the skill dir is the one you meant.\n` +
121
- ` What the model said instead: ${result.detail || "(nothing)"}`);
130
+ return (`delivery canary FAILED for ${skillName}: the requested loading check was inconclusive.\n` +
131
+ ` Local skill readable: ${result.observations.localSkillAvailable}; ` +
132
+ `successful skill read: ${result.observations.successfulSkillRead}; ` +
133
+ `matching heading: ${result.observations.returnedAnchor ?? "unobserved"}.\n` +
134
+ ` This does not prove skill unavailability or predict delivery in later scenarios. ` +
135
+ `Autonomous selection was not tested. Subject probe calls: ${result.probeCalls}.\n` +
136
+ ` harness CLI: ${cliVersion ?? "unknown"}. Check the skill path and recorded loading evidence, ` +
137
+ `or choose --mode force to evaluate explicit instruction delivery as a separate condition.\n` +
138
+ ` ${result.detail}`);
122
139
  }
123
140
  //# sourceMappingURL=canary.js.map
@@ -1,9 +1,11 @@
1
+ import { type SavedAnswerPlan } from "./saved-run.js";
1
2
  export interface Diagnosis {
2
3
  schema: 1;
3
4
  runDir: string;
4
5
  providerCalls: 0;
5
6
  resultsChanged: false;
6
7
  causalConclusion: "not-established";
8
+ reusePlan: SavedAnswerPlan;
7
9
  issues: {
8
10
  category: "evidence" | "configuration" | "judge" | "behavior" | "authoring";
9
11
  scenario?: string;
package/dist/diagnose.js CHANGED
@@ -4,12 +4,13 @@ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
4
  import { readResults, findTranscriptFiles } from "./results.js";
5
5
  import { loadSpec } from "./spec.js";
6
6
  import { currentHashFor, isSupersededKey, remedyForKey, scenarioIdForKey } from "./sources.js";
7
- import { inspectEvalAuthoring } from "./authoring-preflight.js";
7
+ import { inspectEvalAuthoring, authoringEvidenceProfile, fixtureInventory } from "./authoring-preflight.js";
8
+ import { planSavedAnswers } from "./saved-run.js";
8
9
  /** Explain the cheapest next investigation using saved records, never invent a root cause. */
9
10
  export function diagnoseRun(runDir, skillDir, specPath) {
10
11
  const results = readResults(runDir);
11
12
  const spec = loadSpec(specPath);
12
- const report = { schema: 1, runDir: resolve(runDir), providerCalls: 0, resultsChanged: false, causalConclusion: "not-established", issues: [] };
13
+ const report = { schema: 1, runDir: resolve(runDir), providerCalls: 0, resultsChanged: false, causalConclusion: "not-established", reusePlan: planSavedAnswers(runDir, spec, skillDir, dirname(specPath)), issues: [] };
13
14
  const add = (issue) => report.issues.push(issue);
14
15
  const context = { skillDir, specDir: dirname(specPath), scenarios: spec.scenarios, judgePersona: spec.judge_persona };
15
16
  if (!results.source_hashes)
@@ -21,7 +22,15 @@ export function diagnoseRun(runDir, skillDir, specPath) {
21
22
  if (current === undefined || current !== previous)
22
23
  add({ category: "configuration", scenario: scenarioIdForKey(key, spec.scenarios), evidence: `${key}: ${current === undefined ? "not comparable" : "changed or unavailable"}`, next: remedyForKey(key) });
23
24
  }
24
- for (const hint of inspectEvalAuthoring(spec))
25
+ for (const hint of inspectEvalAuthoring(spec, { ...authoringEvidenceProfile(dirname(specPath)), fixtureFiles: scenario => {
26
+ const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
27
+ try {
28
+ return fixture ? fixtureInventory(resolve(dirname(specPath), fixture)) : [];
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ } }))
25
34
  add({ category: "authoring", scenario: hint.scenario, evidence: `${hint.code}: ${hint.message}`, next: "Review this contract using positive, negative and equivalent-answer controls; a heuristic is not proof the eval is wrong." });
26
35
  for (const scenario of results.scenarios) {
27
36
  const transcripts = findTranscriptFiles(runDir, scenario.id, results.mode);
package/dist/grade.js CHANGED
@@ -3,6 +3,7 @@ import { completeCriterionVotes } from "./results.js";
3
3
  import { unavailableExecution } from "./judge-evidence.js";
4
4
  import { resolveJudgeReferences } from "./judge-references.js";
5
5
  export { resolveJudgeReferences } from "./judge-references.js";
6
+ import { throwIfProcessAborted } from "./util/process.js";
6
7
  /** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
7
8
  export const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
8
9
  /**
@@ -229,9 +230,11 @@ export async function gradeTranscript(adapter, judge, prompt, cwd, expectedCrite
229
230
  const prompts = [];
230
231
  let validationError = "";
231
232
  for (let attempt = 0; attempt < 2; attempt++) {
233
+ throwIfProcessAborted();
232
234
  const retry = attempt === 0 ? "" : `\n\nYour reply was not valid: ${JSON.stringify(validationError)}; reply with only the JSON object.`;
233
235
  prompts.push(prompt + retry);
234
236
  const raw = await adapter.judge({ model: judge, prompt: prompts[prompts.length - 1], cwd });
237
+ throwIfProcessAborted();
235
238
  rawReplies.push(raw);
236
239
  // Pi adapter failures are infrastructure errors, not malformed model votes.
237
240
  if (/^\[judge error:/i.test(raw.trim())) {
package/dist/index.d.ts CHANGED
@@ -24,6 +24,9 @@ export * from "./sources.js";
24
24
  export * from "./lift.js";
25
25
  export * from "./adapters/types.js";
26
26
  export * from "./util/exec.js";
27
+ export * from "./util/process.js";
28
+ export * from "./resource-monitor.js";
29
+ export * from "./saved-run.js";
27
30
  export * from "./util/env.js";
28
31
  export * from "./scaffold.js";
29
32
  export * from "./version.js";
package/dist/index.js CHANGED
@@ -24,6 +24,9 @@ export * from "./sources.js";
24
24
  export * from "./lift.js";
25
25
  export * from "./adapters/types.js";
26
26
  export * from "./util/exec.js";
27
+ export * from "./util/process.js";
28
+ export * from "./resource-monitor.js";
29
+ export * from "./saved-run.js";
27
30
  export * from "./util/env.js";
28
31
  export * from "./scaffold.js";
29
32
  export * from "./version.js";
package/dist/journal.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Verdict } from "./score.js";
2
+ import type { CanaryObservations } from "./canary.js";
2
3
  /**
3
4
  * Machine-facing event stream for one run: one JSON object per line in
4
5
  * <runDir>/journal.jsonl. UI, trends, and debugging read ONLY this (never
@@ -42,6 +43,8 @@ export type JournalEvent = {
42
43
  status: "pass" | "fail" | "skipped";
43
44
  anchor: string | null;
44
45
  detail: string;
46
+ observations?: CanaryObservations;
47
+ probeCalls?: 0 | 1;
45
48
  } | {
46
49
  event: "scenario-started";
47
50
  ts: string;
package/dist/lint.js CHANGED
@@ -9,7 +9,7 @@ import { downgradeWarning } from "./downgrade.js";
9
9
  import { collectScoredRuns } from "./trends.js";
10
10
  import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
11
11
  import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
12
- import { fixtureInventory, inspectEvalAuthoring } from "./authoring-preflight.js";
12
+ import { fixtureInventory, inspectEvalAuthoring, authoringEvidenceProfile } from "./authoring-preflight.js";
13
13
  import { resolveJudgeReferences } from "./judge-references.js";
14
14
  /** True when a finding fails the gate. The single place the exit-code rule lives. */
15
15
  export function failsGate(f) {
@@ -51,7 +51,7 @@ export function lintSkill(skillDir, testsDir = join(skillDir, "tests")) {
51
51
  return [{ skill: basename(skillDir), code: "spec", message }];
52
52
  }
53
53
  const skill = spec.skill;
54
- for (const hint of inspectEvalAuthoring(spec, { fixtureFiles: scenario => {
54
+ for (const hint of inspectEvalAuthoring(spec, { ...authoringEvidenceProfile(testsDir), fixtureFiles: scenario => {
55
55
  const fixture = effectiveFixture(scenario);
56
56
  if (!fixture)
57
57
  return [];
package/dist/regate.js CHANGED
@@ -364,6 +364,7 @@ export async function regateRun(opts) {
364
364
  skill: prev.skill, harness: prev.harness, model: prev.model,
365
365
  // Carried verbatim: a regate re-reads saved diffs, it never re-runs the harness.
366
366
  harness_cli_version: prev.harness_cli_version, delivery_canary: prev.delivery_canary,
367
+ delivery_observations: prev.delivery_observations,
367
368
  // The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
368
369
  // only record that a `+<arm>` run actually delegated: rebuilding the draft
369
370
  // field-by-field without it silently deleted `definitions`/`ledger_events`
package/dist/regrade.d.ts CHANGED
@@ -16,7 +16,7 @@ import { type JudgeExecutionFacts } from "./judge-evidence.js";
16
16
  * No hashes recorded (a pre-`source_hashes` run) stays that way: inventing hashes for
17
17
  * a run that never recorded any would claim a coverage it does not have.
18
18
  */
19
- export declare function refreshRubricHashes(recorded: Record<string, string> | undefined, spec: Spec, judgedIds: string[], specDir?: string, references?: Record<string, JudgeReference[]>): Record<string, string> | undefined;
19
+ export declare function refreshRubricHashes(recorded: Record<string, string> | undefined, spec: Spec, judgedIds: string[], specDir?: string, references?: Record<string, JudgeReference[]>, refreshPersona?: boolean): Record<string, string> | undefined;
20
20
  export interface RegradeOptions {
21
21
  references?: JudgeReference[];
22
22
  runDir: string;
@@ -84,6 +84,8 @@ export interface RegradeRunOptions {
84
84
  * cleanly, so spending judge calls on them buys nothing.
85
85
  */
86
86
  onlySuspect?: boolean;
87
+ /** Rejudge only these recorded scenarios; untouched verdicts remain historical. */
88
+ only?: string[];
87
89
  /** Rejudge only retained repetitions containing ERROR criterion votes, with the recorded judge. */
88
90
  onlyUnparsed?: boolean;
89
91
  }
package/dist/regrade.js CHANGED
@@ -9,6 +9,8 @@ import { appendJournal } from "./journal.js";
9
9
  import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
10
10
  import { providerFailureFromTranscript, executionFailureFromTranscript } from "./provider-failure.js";
11
11
  import { loadExecutionFacts, transcriptResponses } from "./judge-evidence.js";
12
+ import { withResourceMonitoring } from "./resource-monitor.js";
13
+ import { throwIfProcessAborted } from "./util/process.js";
12
14
  /**
13
15
  * Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
14
16
  * re-grade actually judged under (plus the persona, which applies to all of them).
@@ -21,7 +23,7 @@ import { loadExecutionFacts, transcriptResponses } from "./judge-evidence.js";
21
23
  * No hashes recorded (a pre-`source_hashes` run) stays that way: inventing hashes for
22
24
  * a run that never recorded any would claim a coverage it does not have.
23
25
  */
24
- export function refreshRubricHashes(recorded, spec, judgedIds, specDir, references) {
26
+ export function refreshRubricHashes(recorded, spec, judgedIds, specDir, references, refreshPersona = true) {
25
27
  if (!recorded)
26
28
  return undefined;
27
29
  const next = { ...recorded };
@@ -33,7 +35,7 @@ export function refreshRubricHashes(recorded, spec, judgedIds, specDir, referenc
33
35
  if (s && RUBRIC_PREFIX + id in next)
34
36
  next[RUBRIC_PREFIX + id] = rubricDigest(s, specDir, references?.[id]);
35
37
  }
36
- if (PERSONA_KEY in next)
38
+ if (refreshPersona && PERSONA_KEY in next)
37
39
  next[PERSONA_KEY] = personaDigest(spec.judge_persona);
38
40
  return next;
39
41
  }
@@ -177,6 +179,7 @@ function hasUnparsedVotes(panel) {
177
179
  * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
178
180
  */
179
181
  export async function regradeRun(opts) {
182
+ throwIfProcessAborted();
180
183
  const { runDir, spec, adapter, specDir } = opts;
181
184
  const now = opts.now ?? (() => new Date().toISOString());
182
185
  const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
@@ -208,15 +211,23 @@ export async function regradeRun(opts) {
208
211
  const specById = new Map(spec.scenarios.map((s) => [s.id, s]));
209
212
  const recorded = prev?.scenarios ?? spec.scenarios.map((s) => ({ id: s.id }));
210
213
  let targets = recorded.map((s) => s.id);
214
+ if (opts.only !== undefined) {
215
+ if (!opts.only.length || opts.only.some(id => !id.trim()) || new Set(opts.only).size !== opts.only.length)
216
+ throw new Error("Selected scenarios must be a nonempty unique ID list");
217
+ const unknown = opts.only.filter(id => !targets.includes(id));
218
+ if (unknown.length)
219
+ throw new Error(`Unknown recorded scenario(s): ${unknown.join(", ")}`);
220
+ targets = targets.filter(id => opts.only.includes(id));
221
+ }
211
222
  if (opts.onlySuspect) {
212
223
  if (!prev)
213
224
  throw new Error(`--suspect-only needs a prior results.yaml in ${runDir}`);
214
- targets = prev.scenarios
225
+ targets = prev.scenarios.filter(s => targets.includes(s.id))
215
226
  .filter((s) => s.suspect || s.judge_verdict === "JUDGE-AMBIGUOUS")
216
227
  .map((s) => s.id);
217
228
  }
218
229
  if (opts.onlyUnparsed)
219
- targets = recorded.filter(s => s.rep_judgments?.some(hasUnparsedVotes)).map(s => s.id);
230
+ targets = recorded.filter(s => targets.includes(s.id) && s.rep_judgments?.some(hasUnparsedVotes)).map(s => s.id);
220
231
  // Schema-3 delivery is a prerequisite for behavioral judging. A later `grade`
221
232
  // cannot turn an undelivered or unauthenticated transcript into product evidence.
222
233
  if (prev?.schema === 3) {
@@ -227,6 +238,8 @@ export async function regradeRun(opts) {
227
238
  // Nothing judgeable — a no-op, preserving both evidence and judge identity.
228
239
  return prev;
229
240
  }
241
+ if (prev && targets.length !== recorded.length && (judge.provider !== prev.judge.provider || judge.model !== prev.judge.model))
242
+ throw new Error("Selective regrade must use the recorded judge; regrade the whole run to change judge");
230
243
  const completeTranscripts = (record) => {
231
244
  const expected = record.reps ?? 1;
232
245
  const files = findTranscriptFiles(runDir, record.id, mode);
@@ -266,75 +279,79 @@ export async function regradeRun(opts) {
266
279
  }
267
280
  }
268
281
  const references = Object.fromEntries(targets.map(id => [id, resolveJudgeReferences(specById.get(id), specDir)]));
269
- const targetSet = new Set(targets);
270
- const scenarioResults = [];
271
- for (const rec of recorded) {
272
- const id = rec.id;
273
- if (!targetSet.has(id)) {
274
- scenarioResults.push(rec); // clean verdict, carried verbatim (onlySuspect)
275
- continue;
282
+ return withResourceMonitoring(runDir, async () => {
283
+ const targetSet = new Set(targets);
284
+ const scenarioResults = [];
285
+ for (const rec of recorded) {
286
+ throwIfProcessAborted();
287
+ const id = rec.id;
288
+ if (!targetSet.has(id)) {
289
+ scenarioResults.push(rec); // clean verdict, carried verbatim (onlySuspect)
290
+ continue;
291
+ }
292
+ const scenario = specById.get(id); // guaranteed present by the guard above
293
+ const prevScenario = prev?.scenarios.find((s) => s.id === id);
294
+ const threshold = effectiveThreshold(prevScenario, scenario);
295
+ const rr = await regradeScenario({
296
+ runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
297
+ references: references[id],
298
+ expectedReps: prevScenario?.reps ?? 1, onlyUnparsed: opts.onlyUnparsed, prior: prevScenario,
299
+ ...(prev?.model.includes(":") ? { subject: parseModelRef(prev.model) } : {}), piVersion: prev?.harness_cli_version,
300
+ });
301
+ const carry = overrides.get(id);
302
+ if (prev?.schema === 3)
303
+ rr.criterion_count = scenario.checklist.length;
304
+ rr.metrics = mergeScenarioMetrics(carry?.metrics, rr.metrics);
305
+ rr.rep_judgments = carryRepObjectives(rr.rep_judgments, carry?.rep_judgments);
306
+ // Gates still describe the original subject execution. An adjudication of
307
+ // an untouched repetition also survives, bounded by rebuild's conflict guards.
308
+ const carryAdjudication = opts.onlyUnparsed && carry?.adjudication && !hasUnparsedVotes(carry.rep_judgments?.find(panel => panel.repetition === (carry.adjudication.repetition ?? 0)));
309
+ scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: carryAdjudication ? "carry" : "drop" }));
276
310
  }
277
- const scenario = specById.get(id); // guaranteed present by the guard above
278
- const prevScenario = prev?.scenarios.find((s) => s.id === id);
279
- const threshold = effectiveThreshold(prevScenario, scenario);
280
- const rr = await regradeScenario({
281
- runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
282
- references: references[id],
283
- expectedReps: prevScenario?.reps ?? 1, onlyUnparsed: opts.onlyUnparsed, prior: prevScenario,
284
- ...(prev?.model.includes(":") ? { subject: parseModelRef(prev.model) } : {}), piVersion: prev?.harness_cli_version,
285
- });
286
- const carry = overrides.get(id);
287
- if (prev?.schema === 3)
288
- rr.criterion_count = scenario.checklist.length;
289
- rr.metrics = mergeScenarioMetrics(carry?.metrics, rr.metrics);
290
- rr.rep_judgments = carryRepObjectives(rr.rep_judgments, carry?.rep_judgments);
291
- // Gates still describe the original subject execution. An adjudication of
292
- // an untouched repetition also survives, bounded by rebuild's conflict guards.
293
- const carryAdjudication = opts.onlyUnparsed && carry?.adjudication && !hasUnparsedVotes(carry.rep_judgments?.find(panel => panel.repetition === (carry.adjudication.repetition ?? 0)));
294
- scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: carryAdjudication ? "carry" : "drop" }));
295
- }
296
- const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
297
- const results = writeResults(runDir, {
298
- schema: prev?.schema,
299
- subject_invocations: prev?.subject_invocations,
300
- skill: spec.skill,
301
- harness: prev?.harness ?? "pi",
302
- // The harness CLI that produced these transcripts, carried verbatim: a re-grade
303
- // re-asks the judge, it does not re-deliver the skill, so stamping today's pi
304
- // here would credit the old transcripts to a version that never ran them.
305
- harness_cli_version: prev?.harness_cli_version,
306
- delivery_canary: prev?.delivery_canary,
307
- // The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
308
- // only record that a `+<arm>` run actually delegated: rebuilding the draft
309
- // field-by-field without it silently deleted `definitions`/`ledger_events`
310
- // from any arm run that was ever re-graded, leaving a record
311
- // indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
312
- // `delivery_canary` and `source_hashes` are carried here.
313
- arm: prev?.arm,
314
- model: prev?.model ?? "unknown",
315
- judge: { provider: judge.provider, model: judge.model },
316
- timestamp: prev?.timestamp ?? now(),
317
- label: prev?.label ?? null,
318
- mode,
319
- // A re-grade judges the SAVED transcripts, which were produced by the OLD text —
320
- // the recorded **stimulus** hashes stay, keeping an honestly-stale run honestly
321
- // stale. The rubric hashes are a different matter: this re-grade applied the
322
- // CURRENT checklist and persona to those transcripts, so "the verdicts reflect
323
- // today's rubric" is now a true statement about the record, and the hashes should
324
- // say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
325
- // *stimulus* hashes stay" — see refreshRubricHashes.
326
- partial: prev?.partial,
327
- source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets, opts.specDir, references),
328
- source_hash_roots: prev?.source_hash_roots,
329
- scenarios: scenarioResults,
330
- }, ctx);
331
- const g = results.effective_grade;
332
- if (ctx) {
333
- appendJournal(runDir, {
334
- event: "score", ts: now(),
335
- passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note,
336
- });
337
- }
338
- return results;
311
+ const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
312
+ const results = writeResults(runDir, {
313
+ schema: prev?.schema,
314
+ subject_invocations: prev?.subject_invocations,
315
+ skill: spec.skill,
316
+ harness: prev?.harness ?? "pi",
317
+ // The harness CLI that produced these transcripts, carried verbatim: a re-grade
318
+ // re-asks the judge, it does not re-deliver the skill, so stamping today's pi
319
+ // here would credit the old transcripts to a version that never ran them.
320
+ harness_cli_version: prev?.harness_cli_version,
321
+ delivery_canary: prev?.delivery_canary,
322
+ delivery_observations: prev?.delivery_observations,
323
+ // The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
324
+ // only record that a `+<arm>` run actually delegated: rebuilding the draft
325
+ // field-by-field without it silently deleted `definitions`/`ledger_events`
326
+ // from any arm run that was ever re-graded, leaving a record
327
+ // indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
328
+ // `delivery_canary` and `source_hashes` are carried here.
329
+ arm: prev?.arm,
330
+ model: prev?.model ?? "unknown",
331
+ judge: { provider: judge.provider, model: judge.model },
332
+ timestamp: prev?.timestamp ?? now(),
333
+ label: prev?.label ?? null,
334
+ mode,
335
+ // A re-grade judges the SAVED transcripts, which were produced by the OLD text —
336
+ // the recorded **stimulus** hashes stay, keeping an honestly-stale run honestly
337
+ // stale. The rubric hashes are a different matter: this re-grade applied the
338
+ // CURRENT checklist and persona to those transcripts, so "the verdicts reflect
339
+ // today's rubric" is now a true statement about the record, and the hashes should
340
+ // say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
341
+ // *stimulus* hashes stay" — see refreshRubricHashes.
342
+ partial: prev?.partial,
343
+ source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets, opts.specDir, references, targets.length === recorded.length),
344
+ source_hash_roots: prev?.source_hash_roots,
345
+ scenarios: scenarioResults,
346
+ }, ctx);
347
+ const g = results.effective_grade;
348
+ if (ctx) {
349
+ appendJournal(runDir, {
350
+ event: "score", ts: now(),
351
+ passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note,
352
+ });
353
+ }
354
+ return results;
355
+ });
339
356
  }
340
357
  //# sourceMappingURL=regrade.js.map
package/dist/rescore.js CHANGED
@@ -95,6 +95,7 @@ export function rescoreRun(opts) {
95
95
  // threshold to reps the recorded harness already produced.
96
96
  harness_cli_version: prev.harness_cli_version,
97
97
  delivery_canary: prev.delivery_canary,
98
+ delivery_observations: prev.delivery_observations,
98
99
  // The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
99
100
  // only record that a `+<arm>` run actually delegated: rebuilding the draft
100
101
  // field-by-field without it silently deleted `definitions`/`ledger_events`
@@ -0,0 +1,3 @@
1
+ import { type ProcessScope } from "./util/process.js";
2
+ /** Per-operation metadata only, with bounded retention and low-frequency atomic snapshots. */
3
+ export declare function withResourceMonitoring<T>(directory: string, work: () => Promise<T>, options?: ProcessScope): Promise<T>;