@skill-harness/cli 0.10.0 → 0.12.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/README.md CHANGED
@@ -24,6 +24,7 @@ npm i -g @skill-harness/cli
24
24
  | `skill-harness grade <run-dir> [--judge p:m]` | re-grade saved transcripts |
25
25
  | `skill-harness review <skill> --skills <root> [--port N]` | serve the interactive review UI |
26
26
  | `skill-harness add-test <skill> --skills <root> --id ID --title T ...` | scaffold a new scenario |
27
+ | `skill-harness qualification prepare\|start\|status\|poll\|validate\|abort ...` | durable external-config qualification lifecycle |
27
28
 
28
29
  ## More
29
30
 
@@ -28,6 +28,7 @@ export function effective(cell) {
28
28
  // Mechanical evidence outranks a prose judge. Objective PASS deliberately
29
29
  // forces nothing — the checklist judge still decides the behavioral rubric.
30
30
  if (cell.objective && cell.objective.status === "ERROR") return "ERROR";
31
+ if (cell.objective && cell.objective.status === "NOT-MEASURED") return "NOT-MEASURED";
31
32
  if (cell.objective && cell.objective.status === "FAIL") return "FAIL";
32
33
  return cell.judge_verdict;
33
34
  }
@@ -53,6 +54,7 @@ export function gradeColumn(col, shipBar, critical) {
53
54
  let bFails = 0;
54
55
  let suspect = 0;
55
56
  let errors = 0;
57
+ let notMeasured = 0;
56
58
 
57
59
  for (const id of Object.keys(col.cells)) {
58
60
  const cell = col.cells[id];
@@ -62,6 +64,10 @@ export function gradeColumn(col, shipBar, critical) {
62
64
  continue; // excluded, blocks ship
63
65
  }
64
66
  const verdict = effective(cell);
67
+ if (verdict === "NOT-MEASURED") {
68
+ notMeasured++;
69
+ continue;
70
+ }
65
71
  if (verdict === "ERROR" || verdict === "JUDGE-AMBIGUOUS") {
66
72
  errors++;
67
73
  continue;
@@ -76,7 +82,7 @@ export function gradeColumn(col, shipBar, critical) {
76
82
  }
77
83
 
78
84
  if (col.partial === true) {
79
- return { passed: 0, total: 0, pct: 0, letter: "-", ship: false, criticalFails, bFails, suspect, errors };
85
+ return { passed: 0, total: 0, pct: 0, letter: "-", ship: false, criticalFails, bFails, suspect, errors, notMeasured };
80
86
  }
81
87
 
82
88
  const pct = total > 0 ? Math.round((passed * 100) / total) : 0;
@@ -89,9 +95,10 @@ export function gradeColumn(col, shipBar, critical) {
89
95
  (!shipBar.no_critical_fail || criticalFails === 0) &&
90
96
  bFails === 0 &&
91
97
  suspect === 0 &&
92
- errors === 0;
98
+ errors === 0 &&
99
+ notMeasured === 0;
93
100
 
94
- return { passed, total, pct, letter, ship, criticalFails, bFails, suspect, errors };
101
+ return { passed, total, pct, letter, ship, criticalFails, bFails, suspect, errors, notMeasured };
95
102
  }
96
103
 
97
104
  /**
@@ -107,7 +114,7 @@ export function gradeColumn(col, shipBar, critical) {
107
114
  */
108
115
  export function liftClass(liftCell, greenCell) {
109
116
  const conclusive = (verdict, suspect) =>
110
- !suspect && verdict !== "ERROR" && verdict !== "JUDGE-AMBIGUOUS";
117
+ !suspect && verdict !== "ERROR" && verdict !== "NOT-MEASURED" && verdict !== "JUDGE-AMBIGUOUS";
111
118
 
112
119
  const redOk = conclusive(liftCell.red, liftCell.redSuspect);
113
120
  const greenOk = conclusive(effective(greenCell), !!greenCell.suspect && !greenCell.override);
@@ -25,6 +25,7 @@
25
25
  .cell.PASS { background: rgba(31,139,76,.22); }
26
26
  .cell.FAIL { background: rgba(192,57,43,.24); }
27
27
  .cell.ERROR { background: rgba(107,114,128,.22); }
28
+ .cell.NOT-MEASURED { background: rgba(180,83,9,.18); }
28
29
  .cell.empty { color: var(--dim); cursor: default; font-weight: 400; }
29
30
  .cell .ov { display: block; font-size: 11px; color: var(--over); font-weight: 600; }
30
31
  .cell .ov.unsaved { color: #b45309; font-style: italic; }
@@ -49,7 +50,7 @@
49
50
  amber rather than red — missing evidence is not a failing gate. */
50
51
  .cell .lm.obj-pass { color: var(--pass); }
51
52
  .cell .lm.obj-fail { color: var(--fail); }
52
- .cell .lm.obj-error { color: #b45309; }
53
+ .cell .lm.obj-error, .cell .lm.obj-not-measured { color: #b45309; }
53
54
  /* Adjudication. `unresolved` is amber for the same reason `suspect` is: it must
54
55
  never read as a clean PASS or FAIL. */
55
56
  .cell .lm.adj-settled { color: var(--dim); }
@@ -94,6 +95,7 @@
94
95
  .tgrid td.tc.PASS { color: #16a34a; }
95
96
  .tgrid td.tc.FAIL { color: #dc2626; }
96
97
  .tgrid td.tc.ERROR { color: #a855f7; }
98
+ .tgrid td.tc.NOT-MEASURED { color: #b45309; }
97
99
  .tgrid td.tc.suspect { color: #b45309; }
98
100
  .tgrid td.tc.absent { color: var(--dim, #bbb); }
99
101
  .dim { color: var(--dim, #888); font-weight: 400; }
@@ -429,7 +431,7 @@ function sparkline(runs) {
429
431
  function renderTrends(data) {
430
432
  const box = document.getElementById("trends");
431
433
  if (!data.models.length) { box.innerHTML = `<div class="empty-state">No runs yet.</div>`; return; }
432
- const glyph = { PASS: "✓", FAIL: "✗", ERROR: "!" };
434
+ const glyph = { PASS: "✓", FAIL: "✗", ERROR: "!", "NOT-MEASURED": "∅" };
433
435
  let html = "";
434
436
  for (const m of data.models) {
435
437
  const last = m.runs[m.runs.length - 1].grade;
package/dist/cli.d.ts CHANGED
@@ -30,13 +30,15 @@ export declare function cmdGrade(args: Args, adapterOverride?: HarnessAdapter):
30
30
  * Reps are the measurement; thresholds are policy. When policy changes, recompute rather
31
31
  * than reconcile two numbers in prose.
32
32
  */
33
+ export declare function cmdJudgeAgreement(args: Args): void;
33
34
  export declare function cmdMutationTest(): Promise<void>;
34
35
  /**
35
- * Re-evaluate needle gates against the saved staged diffs — free, except for the reps
36
- * whose gate verdict flips from fail to pass, which the judge never saw and must now
37
- * be shown. Prints the cost before making those calls.
36
+ * Re-evaluate needle gates against the saved staged diffs — no subject call. A rep
37
+ * whose gate verdict flips from fail to pass was never judged and costs one judge call.
38
+ * The completion summary reports how many judge calls were made.
38
39
  */
39
40
  export declare function cmdRegate(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
41
+ export declare function cmdScreen(args: Args): Promise<void>;
40
42
  export declare function cmdInit(args: Args): Promise<void>;
41
43
  export declare function cmdSuggest(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
42
44
  /**
package/dist/cli.js CHANGED
@@ -3,10 +3,12 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync
3
3
  import { load as yamlLoad } from "js-yaml";
4
4
  import { basename, dirname, join, resolve, relative } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
- import { discover, resolveSkill, loadSpec, parseSpec, appendScenario, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, restampSkill, resolveAdjudicationJudges, adjudicateRun, judgeResemblesSubject, computeCoverage, formatCoverage, selectAffected, formatAffected, gitDiff, exec, HARNESS_VERSION, defaultJudge, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, isScoredMode, runTrajectoryMutationSelfTest, } from "@skill-harness/core";
6
+ import { discover, resolveSkill, loadSpec, parseSpec, appendScenario, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, restampSkill, resolveAdjudicationJudges, adjudicateRun, judgeResemblesSubject, computeCoverage, formatCoverage, selectAffected, formatAffected, gitDiff, exec, HARNESS_VERSION, defaultJudge, assertJudgeAllowed, judgeAgreement, assertNotDowngraded, downgradeWarning, isScoredMode, runTrajectoryMutationSelfTest, resolveArm, isFreeOfflineCommand, screenResults, formatScreen, } from "@skill-harness/core";
7
7
  import { getAdapter } from "@skill-harness/adapters";
8
8
  import { serveReview } from "./serve.js";
9
+ import { runSelfScreeningMutationCases } from "./mutation-catalogue.js";
9
10
  import { runCompareCommand } from "./compare.js";
11
+ import { cmdQualification } from "./qualification.js";
10
12
  const DEFAULT_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro";
11
13
  // The judge default lives in core (`defaultJudge()`), which resolves
12
14
  // SKILL_HARNESS_JUDGE over a baked value — it was duplicated in three places
@@ -151,10 +153,15 @@ export async function cmdRun(args) {
151
153
  throw new Error(`harness \`${harnessName}\` is not on PATH`);
152
154
  const mode = flagStr(args, "mode", "green") || "green";
153
155
  const canary = flagBool(args, "canary");
156
+ const structured = flagBool(args, "structured");
154
157
  const label = flagStr(args, "label") || null;
155
158
  const parallel = Math.max(1, Number(flagStr(args, "parallel", "1")) || 1);
156
159
  const { reps, passThreshold } = parseRunTuning(args);
157
160
  const modelTokens = resolveModels(args);
161
+ // Resolved ONCE, before the skill loop: a typo'd arm name fails before any
162
+ // token is spent on any skill.
163
+ const armName = flagStr(args, "arm") || null;
164
+ const arm = resolveArm(root, armName);
158
165
  const skills = target === "all"
159
166
  ? discover(root).filter((s) => s.hasSpec)
160
167
  : [resolveSkill(root, target)];
@@ -201,6 +208,9 @@ export async function cmdRun(args) {
201
208
  passThreshold,
202
209
  only,
203
210
  canary,
211
+ structured,
212
+ arm,
213
+ skillsRoot: root,
204
214
  onProgress: (m) => console.log(m),
205
215
  });
206
216
  summaries.push(summary);
@@ -380,9 +390,24 @@ export async function cmdGrade(args, adapterOverride) {
380
390
  * Reps are the measurement; thresholds are policy. When policy changes, recompute rather
381
391
  * than reconcile two numbers in prose.
382
392
  */
393
+ export function cmdJudgeAgreement(args) {
394
+ const raw = args._[0];
395
+ if (!raw)
396
+ throw new Error("usage: skill-harness judge-agreement <run-dir>");
397
+ const runDir = resolve(raw);
398
+ if (!existsSync(runDir))
399
+ throw new Error(`run dir not found: ${runDir}`);
400
+ const report = judgeAgreement(readResults(runDir));
401
+ for (const cell of report.cells) {
402
+ console.log(` ${cell.status === "agree" ? "=" : cell.status === "disagree" ? "≠" : "?"} ${cell.scenario}: ${cell.status} — ${cell.detail}${cell.judges.length ? ` (${cell.judges.join(" vs ")})` : ""}`);
403
+ }
404
+ console.log(`\njudge agreement: ${report.agree} agree / ${report.disagree} disagree / ${report.error} error; ${report.rate === null ? "n/a" : `${(report.rate * 100).toFixed(1)}%`} across ${report.comparable} comparable scenario(s).`);
405
+ console.log("offline report only; no model or judge calls.");
406
+ }
383
407
  export async function cmdMutationTest() {
384
- const report = runTrajectoryMutationSelfTest();
385
- console.log(`trajectory assertion mutation self-test: baseline ${report.baseline}`);
408
+ const trajectory = runTrajectoryMutationSelfTest();
409
+ const report = { baseline: trajectory.baseline, cases: [...trajectory.cases, ...await runSelfScreeningMutationCases()] };
410
+ console.log(`mutation self-test: baseline ${report.baseline}`);
386
411
  for (const test of report.cases) {
387
412
  console.log(` ${test.detected ? "✓" : "✗"} ${test.id}: ${test.status} — ${test.detail}`);
388
413
  }
@@ -415,9 +440,9 @@ async function cmdRescore(args) {
415
440
  console.log(`\n${runDirs.length} run(s) re-scored, ${moved} verdict(s) moved.`);
416
441
  }
417
442
  /**
418
- * Re-evaluate needle gates against the saved staged diffs — free, except for the reps
419
- * whose gate verdict flips from fail to pass, which the judge never saw and must now
420
- * be shown. Prints the cost before making those calls.
443
+ * Re-evaluate needle gates against the saved staged diffs — no subject call. A rep
444
+ * whose gate verdict flips from fail to pass was never judged and costs one judge call.
445
+ * The completion summary reports how many judge calls were made.
421
446
  */
422
447
  export async function cmdRegate(args, adapterOverride) {
423
448
  const runDirs = args._;
@@ -454,7 +479,7 @@ export async function cmdRegate(args, adapterOverride) {
454
479
  moved += changes.length;
455
480
  calls += judgeCalls;
456
481
  }
457
- // The cost line matters: regate is advertised as free, and it is — except for the
482
+ // The cost line matters: regate makes no subject call, but fail→pass reps require a judge.
458
483
  // flipped reps, which it must not spend silently.
459
484
  console.log(`\n${runDirs.length} run(s) re-gated, ${moved} verdict(s) moved, ${calls} judge call(s) (no model re-runs).`);
460
485
  }
@@ -509,6 +534,13 @@ async function cmdRestamp(args) {
509
534
  console.log(`\n${runs} run(s) examined: ${upgraded} upgraded, ${unprovable} left alone, ${unchanged} already current.` +
510
535
  `${partial > 0 ? ` (${partial} upgraded only in part.)` : ""} No models were called.`);
511
536
  }
537
+ export async function cmdScreen(args) {
538
+ if (args._.length === 0)
539
+ throw new Error("usage: skill-harness screen <run-dir> [<run-dir> ...]");
540
+ const results = args._.map(runDir => readResults(resolve(runDir)));
541
+ console.log(formatScreen(screenResults(results)));
542
+ console.log(`\n${results.length} retained result(s) screened; 0 subject calls, 0 judge calls.`);
543
+ }
512
544
  async function cmdStability(args) {
513
545
  const root = flagStr(args, "skills", process.cwd());
514
546
  const target = args._[0] ?? "all";
@@ -852,12 +884,20 @@ export async function cmdLint(args) {
852
884
  * prints none.
853
885
  */
854
886
  export function help() {
887
+ const free = (command) => {
888
+ if (!isFreeOfflineCommand(command))
889
+ throw new Error(`${command} is not a free/offline command`);
890
+ return "free, offline";
891
+ };
855
892
  return `skill-harness ${HARNESS_VERSION} — test/optimize loop for agent skills (pi harness)
856
893
 
857
894
  run <skill|all> --skills <root> [--model prov:model ...] [--models file] [--only A1,D2]
858
895
  [--affected --base <git-ref>] run only the scenarios a change could touch (partial; never SHIPs)
859
896
  [--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
860
897
  [--canary] green only: spend ONE probe proving the skill reached the model, and abort the run if it did not
898
+ [--structured] record subject tokens, cost and wall time (needs pi --mode json)
899
+ [--arm <name>] measure under a named arm from <skills-root>/tests/arms.yaml
900
+ (loads its extensions, seeds pi-daddy definitions, tags the run dir)
861
901
  compare <skill|all> --reference <git-ref-or-skills-root> --candidate <skills-root>
862
902
  [--model prov:model ...] [--mode green|force] [--judge prov:model] [--reps N] [--only IDs | --affected --base ref]
863
903
  [--output dir] [--max-subject-token-increase R] [--max-wall-time-increase R] [--max-tool-call-increase R]
@@ -866,19 +906,26 @@ export function help() {
866
906
  [--auto-rejudge] [--secondary-judge p:m] [--tie-break-judge p:m]
867
907
  ask again about untrustworthy cells (ambiguous / contradictory / non-unanimous /
868
908
  ship-deciding). OFF by default; prints the exact MAX extra call count first.
869
- mutation-test prove trajectory assertions turn red (free, offline)
870
- rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
871
- regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
872
- restamp <skill|all> --skills <root> [--from <git-ref>] record the model-visible skill digest on runs that still match (free, offline; one-time migration)
873
- stability <skill|all> --skills <root> [--window N] [--all] run-over-run verdict flips per scenario (free, offline)
909
+ mutation-test prove trajectory + results/delivery/screen gates turn red (${free("mutation-test")})
910
+ judge-agreement <run-dir> compare two distinct persisted judge votes per scenario (${free("judge-agreement")})
911
+ rescore <run-dir>... re-score saved reps vs current spec thresholds (${free("rescore")})
912
+ regate <run-dir>... [--judge prov:model] re-evaluate saved gates (no subject call; judges fail→pass reps)
913
+ restamp <skill|all> --skills <root> [--from <git-ref>] record the model-visible skill digest on runs that still match (${free("restamp")}; one-time migration)
914
+ stability <skill|all> --skills <root> [--window N] [--all] run-over-run verdict flips per scenario (${free("stability")})
915
+ screen <run-dir>... retained control/treatment + criterion rates (${free("screen")})
874
916
  review <skill> --skills <root> [--port N] serve the interactive review UI
875
917
  add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
876
- init <skill> --skills <root> [--force] scaffold a commented template spec (free, offline)
918
+ init <skill> --skills <root> [--force] scaffold a commented template spec (${free("init")})
877
919
  suggest <skill> --skills <root> [--model prov:model] [--force] LLM-draft a spec from SKILL.md (spends tokens)
878
- list --skills <root> discovered skills + spec status
879
- lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (CI gate; exits non-zero on findings)
880
- coverage <skill|all> --skills <root> [--strict] which instruction sections have a declared test (free, offline)
881
- affected <skill> --skills <root> [--base ref] which scenarios a change could touch (free, offline)
920
+ list --skills <root> discovered skills + spec status (${free("list")})
921
+ lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (${free("lint")}; CI gate; exits non-zero on findings)
922
+ coverage <skill|all> --skills <root> [--strict] which instruction sections have a declared test (${free("coverage")})
923
+ affected <skill> --skills <root> [--base ref] which scenarios a change could touch (${free("affected")})
924
+ qualification <prepare|start|status|poll|validate|panel|cell|abort> durable qualification-runner-v1 lifecycle + offline panel collapse
925
+ prepare --spool DIR --config FILE --request FILE [--expected-config-sha256 HEX] (required in production)
926
+ start|status|poll|abort --spool DIR --id ID (abort also requires --reason ID)
927
+ start may resume only with --continuation-authority-file <private-0600-prebound-one-use-capability>
928
+ validate --spool DIR (all calls use external, schema-validated arms; no automatic retry)
882
929
 
883
930
  version print ${HARNESS_VERSION} and exit (also --version / -v)
884
931
 
@@ -897,10 +944,12 @@ export async function main(argv) {
897
944
  case "compare": return cmdCompare(args);
898
945
  case "grade": return cmdGrade(args);
899
946
  case "mutation-test": return cmdMutationTest();
947
+ case "judge-agreement": return cmdJudgeAgreement(args);
900
948
  case "rescore": return cmdRescore(args);
901
949
  case "regate": return cmdRegate(args);
902
950
  case "restamp": return cmdRestamp(args);
903
951
  case "stability": return cmdStability(args);
952
+ case "screen": return cmdScreen(args);
904
953
  case "review": return cmdReview(args);
905
954
  case "add-test": return cmdAddTest(args);
906
955
  case "init": return cmdInit(args);
@@ -909,6 +958,7 @@ export async function main(argv) {
909
958
  case "lint": return cmdLint(args);
910
959
  case "coverage": return cmdCoverage(args);
911
960
  case "affected": return cmdAffected(args);
961
+ case "qualification": return cmdQualification(args);
912
962
  case "version":
913
963
  case "--version":
914
964
  case "-v":
@@ -0,0 +1,8 @@
1
+ export interface PermanentMutationCase {
2
+ id: string;
3
+ detected: boolean;
4
+ status: "FAIL" | "ERROR";
5
+ detail: string;
6
+ }
7
+ /** Permanent non-trajectory mutation cases exercised by the free mutation-test command. */
8
+ export declare function runSelfScreeningMutationCases(): Promise<PermanentMutationCase[]>;
@@ -0,0 +1,95 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { parseSpec, planAdjudication, runSkillModel, screenResults, validateResults, } from "@skill-harness/core";
5
+ import { authenticatePromptObservation, bindPromptObservation, normalizePromptPayload, observeProviderPayload, promptCaptureIsTrusted, } from "@skill-harness/adapters";
6
+ const h = "a".repeat(64);
7
+ const deliveryAssertion = (status = "PASS") => ({ kind: "skill_delivered", status, detail: status });
8
+ function validV3() {
9
+ return {
10
+ schema: 3, skill: "mutation", harness: "pi", model: "fake:m", judge: { provider: "fake", model: "j" },
11
+ timestamp: "t", label: null, mode: "force", effective_grade: { passed: 1, total: 1, pct: 100, letter: "A", ship: true, note: "" },
12
+ subject_invocations: [{ scenario_id: "A1", repetition: 0, prompt: { capture_version: "prompt-provenance-v1", request_index: 0, raw_sha256: h, normalized_sha256: h, normalization_rule: "cwd-line-v1", bytes: 1, contract_sha256: h, contract_bytes: 1, contract_occurrences: 1, mechanism: "append-system-prompt", status: "PASS" } }],
13
+ scenarios: [{ id: "A1", criterion_count: 1, judge_verdict: "PASS", judge_reason: "ok", suspect: false, override: null, note: "", objective: { status: "PASS", assertions: [deliveryAssertion()] }, rep_judgments: [{ repetition: 0, recorded_verdict: "PASS", objective: { status: "PASS", assertions: [deliveryAssertion()] }, judgments: [{ ordinal: 1, judge: { provider: "fake", model: "j" }, verdict: "PASS", reason: "ok", suspect: false, criteria: [{ index: 1, verdict: "PASS", reason: "ok" }] }] }] }],
14
+ };
15
+ }
16
+ function catches(id, mutate, detail) {
17
+ const result = structuredClone(validV3());
18
+ mutate(result);
19
+ let detected = false;
20
+ try {
21
+ validateResults(result);
22
+ }
23
+ catch {
24
+ detected = true;
25
+ }
26
+ return { id, detected, status: detected ? "FAIL" : "ERROR", detail };
27
+ }
28
+ function screenFile(id, verdicts) {
29
+ const result = validV3();
30
+ result.scenarios[0].id = id;
31
+ result.scenarios[0].reps = verdicts.length;
32
+ result.subject_invocations = verdicts.map((_, repetition) => ({ ...structuredClone(result.subject_invocations[0]), scenario_id: id, repetition }));
33
+ result.scenarios[0].rep_judgments = verdicts.map((verdict, repetition) => ({ repetition, recorded_verdict: verdict, objective: { status: "PASS", assertions: [deliveryAssertion()] }, judgments: [{ ordinal: 1, judge: { provider: "fake", model: "j" }, verdict, reason: verdict, suspect: false, criteria: [{ index: 1, verdict, reason: verdict }] }] }));
34
+ return result;
35
+ }
36
+ async function objectiveSuppressesJudge() {
37
+ const dir = mkdtempSync(join(tmpdir(), "skill-harness-mutation-delivery-"));
38
+ let judgeCalls = 0;
39
+ try {
40
+ mkdirSync(join(dir, "tests"), { recursive: true });
41
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: mutation\ndescription: fixture\n---\n\n## Contract\nDo it.\n", "utf8");
42
+ const specPath = join(dir, "tests/specification.yaml");
43
+ writeFileSync(specPath, "skill: mutation\njudge_persona: strict\nship_bar: { total: 1, min_pass: 1, no_critical_fail: true }\ncritical: []\nscenarios:\n - id: A1\n title: delivery\n turns: [hi]\n checklist: [does it]\n", "utf8");
44
+ const spec = parseSpec(readFileSync(specPath, "utf8"), specPath);
45
+ const adapter = {
46
+ name: "pi", observesPrompts: true, available: async () => true,
47
+ run: async (req) => { req.onPromptObservation?.({ capture_version: "prompt-provenance-v1", request_index: 0, raw_sha256: h, normalized_sha256: h, normalization_rule: "cwd-line-v1", bytes: 1, contract_sha256: h, contract_bytes: 1, contract_occurrences: 0, mechanism: "append-system-prompt", status: "NOT-MEASURED" }); return ">>> USER:\nhi\n\n<<< ASSISTANT:\nanswer\n"; },
48
+ judge: async () => { judgeCalls++; return "VERDICT: PASS\nREASON: wrong"; },
49
+ };
50
+ const out = await runSkillModel({ spec, skillDir: dir, specPath, adapter, model: { provider: "fake", model: "m" }, modelToken: "fake:m", judge: { provider: "fake-judge", model: "j" }, mode: "force", timestamp: "2026-09-04T01:00:00.000Z" });
51
+ const detected = judgeCalls === 0 && out.results.scenarios[0].judge_verdict === "NOT-MEASURED" && out.results.effective_grade.total === 0;
52
+ return { id: "delivery-objective-suppresses-judge", detected, status: detected ? "FAIL" : "ERROR", detail: "undelivered subject must never reach the judge or efficacy denominator" };
53
+ }
54
+ finally {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ }
58
+ /** Permanent non-trajectory mutation cases exercised by the free mutation-test command. */
59
+ export async function runSelfScreeningMutationCases() {
60
+ const contract = "## Contract\nDo it\n";
61
+ const one = observeProviderPayload({ instructions: contract }, contract, "append-system-prompt", 0);
62
+ const forged = { ...one, contract_sha256: "f".repeat(64) };
63
+ const ceiling = screenFile("C", ["PASS", "PASS", "PASS", "PASS", "FAIL"]);
64
+ for (const observation of ceiling.subject_invocations)
65
+ observation.prompt = { ...observation.prompt, mechanism: "none", contract_occurrences: 0, status: "PASS" };
66
+ const undelivered = screenFile("U", ["PASS", "PASS"]);
67
+ undelivered.subject_invocations[1].prompt = { ...undelivered.subject_invocations[1].prompt, contract_occurrences: 0, status: "NOT-MEASURED" };
68
+ const suspect = screenFile("S", ["PASS"]);
69
+ suspect.scenarios[0].rep_judgments[0].judgments[0].suspect = true;
70
+ const cases = [
71
+ catches("schema-v3-missing-observation-rejected", r => { delete r.subject_invocations; }, "schema v3 must reject missing delivery evidence"),
72
+ catches("schema-v3-panel-divergence-rejected", r => { r.scenarios[0].rep_judgments[0].recorded_verdict = "FAIL"; }, "stored panel result must equal recomputed clean votes"),
73
+ catches("schema-v3-missing-criterion-rejected", r => { r.scenarios[0].rep_judgments[0].judgments[0].criteria.pop(); }, "criterion omissions must remain explicit"),
74
+ catches("schema-v3-per-repetition-objective-rejected", r => { delete r.scenarios[0].rep_judgments[0].objective; }, "aggregate objective cannot hide a repetition"),
75
+ catches("schema-v3-top-level-verdict-rejected", r => { const panel = r.scenarios[0].rep_judgments[0]; panel.judgments[0].verdict = "FAIL"; panel.judgments[0].criteria = panel.judgments[0].criteria.map(vote => ({ ...vote, verdict: "FAIL" })); panel.recorded_verdict = "FAIL"; }, "top-level verdict must match retained repetition evidence"),
76
+ catches("schema-v3-unsupported-pass-rejected", r => { r.scenarios[0].rep_judgments[0].judgments = []; }, "behavioral PASS requires a retained clean judgment"),
77
+ catches("schema-v3-adjudication-state-rejected", r => { const primary = r.scenarios[0].rep_judgments[0].judgments[0]; r.scenarios[0].adjudication = { repetition: 0, trigger: "ship_deciding", state: "unresolved", judgments: [primary, { ...structuredClone(primary), ordinal: 2 }] }; }, "adjudication state and verdict must match clean votes"),
78
+ { id: "delivery-zero-not-measured", detected: observeProviderPayload({ instructions: "none" }, contract, "append-system-prompt", 0).status === "NOT-MEASURED", status: "FAIL", detail: "zero delivery is not product failure" },
79
+ { id: "delivery-duplicate-not-measured", detected: observeProviderPayload({ instructions: contract + contract }, contract, "append-system-prompt", 0).status === "NOT-MEASURED", status: "FAIL", detail: "duplicate delivery is not valid measurement" },
80
+ { id: "delivery-unobservable-error", detected: observeProviderPayload({}, contract, "append-system-prompt", 0).status === "ERROR", status: "FAIL", detail: "unsupported payload is instrument error" },
81
+ await objectiveSuppressesJudge(),
82
+ { id: "delivery-error-suppresses-adjudication", detected: planAdjudication({ cells: [{ id: "A1", verdict: "ERROR", reason: "instrument", suspect: false, deliveryStatus: "ERROR" }], scenarios: [{ id: "A1", title: "A1", critical: false, mode: "inline", turns: ["x"], checklist: ["y"], workspace: "none", remote: false }], shipBar: { total: 1, min_pass: 1, no_critical_fail: true }, critical: [], tieBreakAvailable: true }).maxAdditionalCalls === 0, status: "FAIL", detail: "instrument delivery ERROR cannot authorize adjudication spend" },
83
+ { id: "observer-normalization-scope", detected: JSON.stringify(normalizePromptPayload({ instructions: "Keep /a\nCurrent working directory: /a\nKeep /a" }, "cwd-line-v1")) === JSON.stringify({ instructions: "Keep /a\nCurrent working directory:<normalized>\nKeep /a" }), status: "FAIL", detail: "normalization changes only the cwd line" },
84
+ { id: "observer-contract-binding", detected: bindPromptObservation(authenticatePromptObservation(forged, "key"), contract, "append-system-prompt", 0, "key").status === "ERROR", status: "FAIL", detail: "child contract identity cannot replace parent binding" },
85
+ { id: "observer-mac-tamper-rejected", detected: bindPromptObservation({ ...authenticatePromptObservation(one, "key"), mac: "0".repeat(64) }, contract, "append-system-prompt", 0, "key").status === "ERROR", status: "FAIL", detail: "complete child observation requires parent authentication" },
86
+ { id: "observer-extension-forgery-rejected", detected: !promptCaptureIsTrusted(1), status: "FAIL", detail: "same-process subject extensions cannot produce trusted provenance" },
87
+ { id: "observer-argv-wiring", detected: promptCaptureIsTrusted(0) && !promptCaptureIsTrusted(1), status: "FAIL", detail: "observer is enabled only on the authenticated extension-free path" },
88
+ { id: "screen-ceiling-boundary", detected: screenResults([ceiling]).scenarios[0].classification === "CEILING", status: "FAIL", detail: "four of five control passes is CEILING" },
89
+ { id: "screen-undelivered-filter", detected: (() => { const row = screenResults([undelivered]).scenarios[0]; return row.treatment.n === 1 && row.not_measured === 1; })(), status: "FAIL", detail: "undelivered reps have a bucket outside efficacy" },
90
+ { id: "screen-suspect-filter", detected: screenResults([suspect]).scenarios[0].control.n === 0, status: "FAIL", detail: "suspect-only panels do not enter rates" },
91
+ { id: "screen-unsupported-pass-filter", detected: (() => { const unsupported = screenFile("P", ["PASS"]); unsupported.scenarios[0].rep_judgments[0].judgments = []; return screenResults([unsupported]).scenarios[0].control.n === 0; })(), status: "FAIL", detail: "recorded PASS without a clean vote does not enter efficacy" },
92
+ ];
93
+ return cases.map(test => ({ ...test, status: test.detected ? "FAIL" : "ERROR" }));
94
+ }
95
+ //# sourceMappingURL=mutation-catalogue.js.map
@@ -0,0 +1,7 @@
1
+ export interface QualificationCliArgs {
2
+ _: string[];
3
+ flags: Record<string, string | true>;
4
+ }
5
+ export declare function qualificationSupervisorRuntimeArgs(mode: "production" | "test", scriptPath: string): string[];
6
+ export declare function consumeQualificationContinuationAuthority(path: string): string;
7
+ export declare function cmdQualification(args: QualificationCliArgs): Promise<void>;
@@ -0,0 +1,210 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { abortQualificationInvocation, checkQualificationAuthentication, pollQualificationInvocation, prepareQualificationInvocation, qualificationCanonicalJson, recordQualificationCell, recordQualificationJudgePanel, qualificationInvocationStatus, readQualificationSpoolConfig, superviseQualificationInvocation, validateQualificationRunnerSpool, verifyQualificationExecutable, } from "@skill-harness/core";
5
+ function flag(args, name) {
6
+ const value = args.flags[name];
7
+ return typeof value === "string" && value.length > 0 ? value : undefined;
8
+ }
9
+ function required(args, name) {
10
+ const value = flag(args, name);
11
+ if (!value)
12
+ throw new Error(`qualification ${args._[0] ?? "command"} requires --${name} <value>`);
13
+ return value;
14
+ }
15
+ function integerFlag(args, name, fallback) {
16
+ const raw = flag(args, name);
17
+ if (raw === undefined)
18
+ return fallback;
19
+ const value = Number(raw);
20
+ if (!Number.isSafeInteger(value) || value < 0)
21
+ throw new Error(`qualification --${name} must be a non-negative integer`);
22
+ return value;
23
+ }
24
+ export function qualificationSupervisorRuntimeArgs(mode, scriptPath) {
25
+ // Production supervisors execute built JavaScript with no inherited Node
26
+ // loader/import/debug hooks. Source-mode tests need exactly the repository's
27
+ // tsx import and nothing from the parent process.execArgv.
28
+ return mode === "test" && scriptPath.endsWith(".ts") ? ["--import", "tsx"] : [];
29
+ }
30
+ export function consumeQualificationContinuationAuthority(path) {
31
+ let fd;
32
+ try {
33
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
34
+ const stat = fstatSync(fd);
35
+ if (!stat.isFile())
36
+ throw new Error("qualification continuation authority must be a regular non-symlink file");
37
+ if (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()))
38
+ throw new Error("qualification continuation authority file must be mode 0600 and owned by the qualification user");
39
+ if (stat.size < 32 || stat.size > 4096)
40
+ throw new Error("qualification continuation authority must be 32..4096 bytes");
41
+ const authority = readFileSync(fd, "utf8");
42
+ const pathStat = lstatSync(path);
43
+ if (pathStat.isSymbolicLink() || pathStat.dev !== stat.dev || pathStat.ino !== stat.ino)
44
+ throw new Error("qualification continuation authority path changed while it was consumed");
45
+ unlinkSync(path);
46
+ if (!authority.trim())
47
+ throw new Error("qualification continuation authority must not be blank");
48
+ return authority;
49
+ }
50
+ finally {
51
+ if (fd !== undefined)
52
+ closeSync(fd);
53
+ }
54
+ }
55
+ function print(value) {
56
+ process.stdout.write(`${qualificationCanonicalJson(value)}\n`);
57
+ }
58
+ export async function cmdQualification(args) {
59
+ const operation = args._[0];
60
+ if (!operation)
61
+ throw new Error("usage: skill-harness qualification <prepare|start|status|poll|validate|panel|cell|abort> ...");
62
+ if (operation === "prepare") {
63
+ const invocation = prepareQualificationInvocation({
64
+ spool_dir: required(args, "spool"),
65
+ config_path: required(args, "config"),
66
+ request_path: required(args, "request"),
67
+ expected_configuration_sha256: flag(args, "expected-config-sha256"),
68
+ });
69
+ print({ invocation_id: invocation.invocation_id, phase: "prepared", consumed_calls: 0 });
70
+ return;
71
+ }
72
+ const spool = required(args, "spool");
73
+ if (operation === "validate") {
74
+ print(validateQualificationRunnerSpool(spool));
75
+ return;
76
+ }
77
+ if (operation === "panel") {
78
+ print(recordQualificationJudgePanel({
79
+ spool_dir: spool,
80
+ panel_id: required(args, "panel-id"),
81
+ member_invocation_ids: required(args, "members").split(",").filter(Boolean),
82
+ }));
83
+ return;
84
+ }
85
+ if (operation === "cell") {
86
+ print(recordQualificationCell({
87
+ spool_dir: spool,
88
+ cell_id: required(args, "cell-id"),
89
+ }));
90
+ return;
91
+ }
92
+ const id = required(args, "id");
93
+ if (operation === "status") {
94
+ print(qualificationInvocationStatus(spool, id));
95
+ return;
96
+ }
97
+ if (operation === "poll") {
98
+ print(await pollQualificationInvocation({
99
+ spool_dir: spool,
100
+ invocation_id: id,
101
+ wait_ms: integerFlag(args, "wait-ms", 0),
102
+ interval_ms: Math.max(5, integerFlag(args, "interval-ms", 100)),
103
+ }));
104
+ return;
105
+ }
106
+ if (operation === "abort") {
107
+ print(await abortQualificationInvocation({
108
+ spool_dir: spool,
109
+ invocation_id: id,
110
+ reason: required(args, "reason"),
111
+ }));
112
+ return;
113
+ }
114
+ if (operation === "__supervise") {
115
+ const fdRaw = flag(args, "authority-fd");
116
+ if (fdRaw !== "3")
117
+ throw new Error("qualification internal authority descriptor must be fd 3");
118
+ let authority;
119
+ try {
120
+ authority = JSON.parse(readFileSync(3, "utf8"));
121
+ }
122
+ catch {
123
+ throw new Error("qualification internal authority payload is invalid JSON");
124
+ }
125
+ finally {
126
+ closeSync(3);
127
+ }
128
+ if (!authority || typeof authority !== "object" || Array.isArray(authority) ||
129
+ Object.keys(authority).sort().join(",") !== "authentication_authority,continuation_authority") {
130
+ throw new Error("qualification internal authority payload is not closed");
131
+ }
132
+ const payload = authority;
133
+ if (typeof payload.authentication_authority !== "string" ||
134
+ (payload.continuation_authority !== null && typeof payload.continuation_authority !== "string")) {
135
+ throw new Error("qualification internal authority payload has invalid fields");
136
+ }
137
+ await superviseQualificationInvocation({
138
+ spool_dir: spool,
139
+ invocation_id: id,
140
+ child_env: process.env,
141
+ authentication_authority: payload.authentication_authority,
142
+ continuation_authority: payload.continuation_authority ?? undefined,
143
+ });
144
+ return;
145
+ }
146
+ if (operation === "start") {
147
+ const config = readQualificationSpoolConfig(spool);
148
+ const before = qualificationInvocationStatus(spool, id);
149
+ if (before.phase === "terminal" || ((before.phase === "running" || before.phase === "launch-claimed") && before.supervisor_alive === true)) {
150
+ print(before);
151
+ return;
152
+ }
153
+ const needsContinuation = before.attempt === 1 && before.supervisor_alive !== true;
154
+ if (needsContinuation && !flag(args, "continuation-authority-file")) {
155
+ throw new Error("qualification interrupted claimed invocation requires --continuation-authority-file");
156
+ }
157
+ if (config.mode === "production") {
158
+ const pinned = verifyQualificationExecutable(config.runner.executable);
159
+ const running = realpathSync(process.argv[1]);
160
+ if (running !== pinned.realpath) {
161
+ throw new Error(`qualification runner executable mismatch: configuration pins ${pinned.realpath}, running ${running}`);
162
+ }
163
+ }
164
+ const auth = await checkQualificationAuthentication({ spool_dir: spool, invocation_id: id });
165
+ if (flag(args, "continuation-authority") !== undefined)
166
+ throw new Error("qualification continuation authority must use --continuation-authority-file, never argv text");
167
+ const continuationFile = flag(args, "continuation-authority-file");
168
+ const continuation = continuationFile ? consumeQualificationContinuationAuthority(continuationFile) : undefined;
169
+ const childArgs = [
170
+ ...qualificationSupervisorRuntimeArgs(config.mode, process.argv[1]),
171
+ process.argv[1],
172
+ "qualification", "__supervise",
173
+ "--spool", spool,
174
+ "--id", id,
175
+ "--authority-fd", "3",
176
+ ];
177
+ const supervisor = spawn(process.execPath, childArgs, {
178
+ cwd: process.cwd(),
179
+ env: auth.child_env,
180
+ detached: true,
181
+ stdio: ["ignore", "ignore", "ignore", "pipe"],
182
+ });
183
+ await new Promise((resolve, reject) => {
184
+ supervisor.once("spawn", resolve);
185
+ supervisor.once("error", reject);
186
+ });
187
+ const authorityPipe = supervisor.stdio[3];
188
+ if (!authorityPipe || !("write" in authorityPipe))
189
+ throw new Error("qualification private authority pipe was not created");
190
+ authorityPipe.end(JSON.stringify({ authentication_authority: auth.launch_authority, continuation_authority: continuation ?? null }));
191
+ supervisor.unref();
192
+ const deadline = Date.now() + integerFlag(args, "ack-wait-ms", 1500);
193
+ const priorSupervisor = qualificationCanonicalJson(before.supervisor);
194
+ let status = qualificationInvocationStatus(spool, id);
195
+ const acknowledged = () => needsContinuation
196
+ ? status.phase === "terminal" || (status.supervisor_alive === true && qualificationCanonicalJson(status.supervisor) !== priorSupervisor)
197
+ : status.phase !== "prepared";
198
+ while (!acknowledged() && Date.now() < deadline) {
199
+ await sleep(10);
200
+ status = qualificationInvocationStatus(spool, id);
201
+ }
202
+ if (!acknowledged()) {
203
+ throw new Error(`qualification supervisor did not durably acknowledge ${id} before the acknowledgement deadline`);
204
+ }
205
+ print(status);
206
+ return;
207
+ }
208
+ throw new Error(`unknown qualification operation: ${operation}`);
209
+ }
210
+ //# sourceMappingURL=qualification.js.map
package/dist/serve.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync, existsSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn } from "node:child_process";
6
- import { collectReport, renderReport, collectTrends, readResults, writeResults, applyOverride, preserveTranscript, findTranscriptFiles, ensureResultsGitignore, appendJournal, loadSpec, regradeScenario, refreshRubricHashes, findJudgeRawFiles, effectiveThreshold, scoreContextFor, isScoredMode, rebuildScenarioResult, mergeScenarioMetrics, envFlag, planAdjudication, adjudicateRun, assertJudgeAllowed, cellsFromResults, } from "@skill-harness/core";
6
+ import { collectReport, renderReport, collectTrends, readResults, writeResults, applyOverride, preserveTranscript, findTranscriptFiles, ensureResultsGitignore, appendJournal, loadSpec, regradeScenario, appendJudgeHistory, refreshRubricHashes, findJudgeRawFiles, effectiveThreshold, scoreContextFor, isScoredMode, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, envFlag, planAdjudication, adjudicateRun, assertJudgeAllowed, cellsFromResults, } from "@skill-harness/core";
7
7
  import { getAdapter } from "@skill-harness/adapters";
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  /** Locate assets/report.template.html relative to dist/ or src/. */
@@ -115,17 +115,23 @@ export async function serveReview(opts) {
115
115
  res.writeHead(404).end("unknown scenario");
116
116
  return;
117
117
  }
118
+ const prev = results.scenarios.find((s) => s.id === body.scenarioId);
119
+ if (!prev) {
120
+ res.writeHead(404).end("scenario not in this run");
121
+ return;
122
+ }
123
+ const delivery = prev.objective?.assertions.find(assertion => assertion.kind === "skill_delivered");
124
+ if (results.schema === 3 && delivery?.status !== "PASS") {
125
+ res.writeHead(400, { "content-type": "application/json" });
126
+ res.end(JSON.stringify({ ok: false, error: `scenario ${body.scenarioId} is ${delivery?.status ?? "ERROR"}: delivery-gated evidence cannot be re-judged` }));
127
+ return;
128
+ }
118
129
  const adapter = opts.adapter ?? getAdapter(results.harness);
119
130
  if (!(await adapter.available())) {
120
131
  res.writeHead(400, { "content-type": "application/json" });
121
132
  res.end(JSON.stringify({ ok: false, error: `harness \`${results.harness}\` is not on PATH` }));
122
133
  return;
123
134
  }
124
- const prev = results.scenarios.find((s) => s.id === body.scenarioId);
125
- if (!prev) {
126
- res.writeHead(404).end("scenario not in this run");
127
- return;
128
- }
129
135
  const threshold = effectiveThreshold(prev, scenario);
130
136
  try {
131
137
  const rr = await regradeScenario({
@@ -133,17 +139,33 @@ export async function serveReview(opts) {
133
139
  specDir: dirname(specPath), threshold, mode: results.mode,
134
140
  expectedReps: prev.reps ?? 1,
135
141
  });
136
- const merged = results.scenarios.map((s) =>
137
- // Same contract as `grade`, through the same choke point.
138
- s.id === body.scenarioId
139
- ? rebuildScenarioResult({ ...rr, metrics: mergeScenarioMetrics(s.metrics, rr.metrics) }, s, { objective: "carry", adjudication: "drop" })
140
- : s);
142
+ const merged = results.scenarios.map((s) => {
143
+ if (s.id !== body.scenarioId)
144
+ return s;
145
+ const judge_history = appendJudgeHistory(s, results.judge, {
146
+ judge: results.judge,
147
+ verdict: rr.judge_verdict,
148
+ reason: rr.judge_reason,
149
+ suspect: rr.suspect,
150
+ criteria: rr.rep_judgments?.find(panel => panel.repetition === 0)?.judgments[0]?.criteria,
151
+ });
152
+ // Same contract as `grade`, through the same choke point.
153
+ return rebuildScenarioResult({ ...rr, metrics: mergeScenarioMetrics(s.metrics, rr.metrics), rep_judgments: carryRepObjectives(rr.rep_judgments, s.rep_judgments), judge_history }, s, { objective: "carry", adjudication: "drop" });
154
+ });
141
155
  const written = writeResults(column.runDir, {
156
+ schema: results.schema, subject_invocations: results.subject_invocations,
142
157
  skill: results.skill, harness: results.harness, model: results.model, judge: results.judge,
143
158
  timestamp: results.timestamp, label: results.label, mode: results.mode, scenarios: merged,
144
159
  partial: results.partial,
145
160
  // Provenance survives a UI re-judge, same as it does through `grade`.
146
161
  harness_cli_version: results.harness_cli_version, delivery_canary: results.delivery_canary,
162
+ // The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
163
+ // only record that a `+<arm>` run actually delegated: rebuilding the draft
164
+ // field-by-field without it silently deleted `definitions`/`ledger_events`
165
+ // from any arm run that was ever re-graded, leaving a record
166
+ // indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
167
+ // `delivery_canary` and `source_hashes` are carried here.
168
+ arm: results.arm,
147
169
  // Recorded hashes were being dropped here entirely, which silently
148
170
  // retired the staleness gate for any run re-judged from the UI. Carried,
149
171
  // with the one `rubric:` key this re-judge actually applied refreshed —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "skill-harness CLI — run, grade, review, and lint agent-skill scenarios on the pi harness",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,10 +41,10 @@
41
41
  "node": ">=20"
42
42
  },
43
43
  "scripts": {
44
- "prepack": "rm -rf ./assets && mkdir -p ./assets && cp ../../assets/report.* ./assets/ && cp ../../LICENSE ./LICENSE"
44
+ "prepack": "node ../../scripts/release-pack.mjs --guard"
45
45
  },
46
46
  "dependencies": {
47
- "@skill-harness/core": "0.10.0",
48
- "@skill-harness/adapters": "0.10.0"
47
+ "@skill-harness/core": "0.12.0",
48
+ "@skill-harness/adapters": "0.12.0"
49
49
  }
50
50
  }