@skill-harness/core 0.11.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.
Files changed (77) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/types.d.ts +19 -0
  3. package/dist/adjudication.d.ts +9 -1
  4. package/dist/adjudication.js +41 -33
  5. package/dist/arms.d.ts +1 -1
  6. package/dist/capture-trace-types.d.ts +2 -0
  7. package/dist/command-cost.d.ts +9 -0
  8. package/dist/command-cost.js +23 -0
  9. package/dist/comparison.d.ts +1 -0
  10. package/dist/comparison.js +9 -3
  11. package/dist/execution-trace.js +9 -1
  12. package/dist/grade.d.ts +2 -0
  13. package/dist/grade.js +2 -1
  14. package/dist/index.d.ts +12 -0
  15. package/dist/index.js +12 -0
  16. package/dist/journal.d.ts +1 -1
  17. package/dist/judge-agreement.d.ts +19 -0
  18. package/dist/judge-agreement.js +31 -0
  19. package/dist/judge-policy.js +5 -3
  20. package/dist/lift.js +1 -1
  21. package/dist/lint.js +1 -1
  22. package/dist/prompt-normalization.d.ts +6 -0
  23. package/dist/prompt-normalization.js +14 -0
  24. package/dist/qualification-capture.d.ts +44 -0
  25. package/dist/qualification-capture.js +163 -0
  26. package/dist/qualification-config.d.ts +233 -0
  27. package/dist/qualification-config.js +694 -0
  28. package/dist/qualification-lock.d.ts +15 -0
  29. package/dist/qualification-lock.js +87 -0
  30. package/dist/qualification-oauth-directory.d.ts +79 -0
  31. package/dist/qualification-oauth-directory.js +444 -0
  32. package/dist/qualification-panel-store.d.ts +14 -0
  33. package/dist/qualification-panel-store.js +188 -0
  34. package/dist/qualification-panels.d.ts +99 -0
  35. package/dist/qualification-panels.js +91 -0
  36. package/dist/qualification-process.d.ts +13 -0
  37. package/dist/qualification-process.js +188 -0
  38. package/dist/qualification-runner.d.ts +203 -0
  39. package/dist/qualification-runner.js +2037 -0
  40. package/dist/qualification-store.d.ts +187 -0
  41. package/dist/qualification-store.js +833 -0
  42. package/dist/qualification-validation.d.ts +12 -0
  43. package/dist/qualification-validation.js +8 -0
  44. package/dist/regate.js +42 -10
  45. package/dist/regrade.d.ts +3 -1
  46. package/dist/regrade.js +37 -5
  47. package/dist/reps.d.ts +4 -2
  48. package/dist/reps.js +13 -4
  49. package/dist/rescore.js +2 -1
  50. package/dist/results.d.ts +53 -7
  51. package/dist/results.js +210 -7
  52. package/dist/run.js +66 -17
  53. package/dist/score.d.ts +4 -3
  54. package/dist/score.js +13 -4
  55. package/dist/screen.d.ts +31 -0
  56. package/dist/screen.js +105 -0
  57. package/dist/seeded.d.ts +2 -1
  58. package/dist/seeded.js +1 -0
  59. package/dist/sources.d.ts +7 -1
  60. package/dist/sources.js +13 -5
  61. package/dist/spec.d.ts +1 -1
  62. package/dist/spec.js +1 -1
  63. package/dist/stability.js +1 -1
  64. package/dist/trajectory-gates.d.ts +14 -2
  65. package/dist/trajectory-gates.js +26 -6
  66. package/dist/vote-panel.d.ts +33 -0
  67. package/dist/vote-panel.js +41 -0
  68. package/dist/workspace.js +4 -1
  69. package/package.json +3 -3
  70. package/schemas/qualification-cell-result-v1.schema.json +32 -0
  71. package/schemas/qualification-config-v1.schema.json +244 -0
  72. package/schemas/qualification-invocation-request-v1.schema.json +59 -0
  73. package/schemas/qualification-judge-panel-result-v1.schema.json +42 -0
  74. package/schemas/results-v2.schema.json +19 -21
  75. package/schemas/results-v3.schema.json +654 -0
  76. package/schemas/specification-v1.schema.json +1 -1
  77. package/schemas/trajectory-event-v1.schema.json +20 -1
package/dist/screen.js ADDED
@@ -0,0 +1,105 @@
1
+ import { deliveryStatusForObservations } from "./results.js";
2
+ import { collapseVotePanel } from "./vote-panel.js";
3
+ function classify(rate) {
4
+ if (rate.n === 0)
5
+ return "UNKNOWN";
6
+ const p = rate.passes / rate.n;
7
+ if (p >= 0.8)
8
+ return "CEILING";
9
+ if (p <= 0.1)
10
+ return "FLOOR";
11
+ if (p >= 0.2 && p <= 0.7)
12
+ return "INFORMATIVE";
13
+ return "UNKNOWN";
14
+ }
15
+ /** Pure/offline: derive only from v3 retained invocation and vote evidence. */
16
+ export function screenResults(results) {
17
+ const rows = new Map();
18
+ const criteria = new Map();
19
+ for (const result of results) {
20
+ const observations = result.schema === 3 ? result.subject_invocations : [];
21
+ for (const scenario of result.scenarios) {
22
+ const rowKey = `${result.skill}\u0000${result.model}\u0000${scenario.id}`;
23
+ const row = rows.get(rowKey) ?? { control: { passes: 0, n: 0 }, treatment: { passes: 0, n: 0 }, not_measured: 0 };
24
+ for (const rep of scenario.rep_judgments ?? []) {
25
+ const repObservations = observations.filter(observation => observation.scenario_id === scenario.id && observation.repetition === rep.repetition);
26
+ const deliveryStatus = deliveryStatusForObservations(repObservations);
27
+ if (deliveryStatus === "NOT-MEASURED")
28
+ row.not_measured++;
29
+ if (deliveryStatus !== "PASS")
30
+ continue;
31
+ const terminalAttempt = Math.max(...repObservations.map(observation => observation.attempt ?? 0));
32
+ const mechanisms = new Set(repObservations.filter(observation => (observation.attempt ?? 0) === terminalAttempt).map(observation => observation.prompt.mechanism));
33
+ if (mechanisms.size !== 1)
34
+ continue;
35
+ const rate = mechanisms.has("none") ? row.control : row.treatment;
36
+ let panelVerdict = rep.recorded_verdict;
37
+ if (rep.judgments.length > 0) {
38
+ const clean = rep.judgments.filter(judgment => !judgment.suspect && (judgment.verdict === "PASS" || judgment.verdict === "FAIL"));
39
+ if (clean.length === 0)
40
+ continue;
41
+ panelVerdict = clean.length === 1 ? clean[0].verdict : collapseVotePanel(rep.judgments).verdict ?? "JUDGE-AMBIGUOUS";
42
+ }
43
+ else if (rep.objective?.status === "FAIL" && rep.recorded_verdict === "FAIL") {
44
+ panelVerdict = "FAIL"; // objective behavioral evidence needs no prose judge
45
+ }
46
+ else
47
+ continue;
48
+ if (scenario.adjudication?.repetition === rep.repetition) {
49
+ if (scenario.adjudication.state === "unresolved" || !scenario.adjudication.verdict)
50
+ continue;
51
+ const adjudicated = collapseVotePanel(scenario.adjudication.judgments);
52
+ if (adjudicated.state !== scenario.adjudication.state || adjudicated.verdict !== scenario.adjudication.verdict)
53
+ continue;
54
+ panelVerdict = adjudicated.verdict;
55
+ }
56
+ const effective = rep.objective?.status === "ERROR" ? "ERROR" : rep.objective?.status === "NOT-MEASURED" ? "NOT-MEASURED" : rep.objective?.status === "FAIL" ? "FAIL" : panelVerdict;
57
+ if (effective !== "PASS" && effective !== "FAIL")
58
+ continue;
59
+ rate.n++;
60
+ if (effective === "PASS")
61
+ rate.passes++;
62
+ for (const judgment of rep.judgments) {
63
+ if (judgment.suspect || (judgment.verdict !== "PASS" && judgment.verdict !== "FAIL"))
64
+ continue;
65
+ for (const vote of judgment.criteria ?? [])
66
+ addCriterion(criteria, result.skill, result.model, scenario.id, vote);
67
+ }
68
+ }
69
+ const adjudicatedRep = scenario.adjudication?.repetition;
70
+ const adjudicationDelivery = result.schema === 3 && adjudicatedRep !== undefined
71
+ ? deliveryStatusForObservations(observations.filter(observation => observation.scenario_id === scenario.id && observation.repetition === adjudicatedRep))
72
+ : "ERROR";
73
+ for (const judgment of adjudicationDelivery === "PASS" ? scenario.adjudication?.judgments.filter(judgment => judgment.ordinal > 1) ?? [] : []) {
74
+ if (judgment.suspect || (judgment.verdict !== "PASS" && judgment.verdict !== "FAIL"))
75
+ continue;
76
+ for (const vote of judgment.criteria ?? [])
77
+ addCriterion(criteria, result.skill, result.model, scenario.id, vote);
78
+ }
79
+ rows.set(rowKey, row);
80
+ }
81
+ }
82
+ return {
83
+ scenarios: [...rows.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, row]) => { const [skill, model, id] = key.split("\u0000"); return { skill, model, id, ...row, classification: classify(row.control) }; }),
84
+ criteria: [...criteria.values()].sort((a, b) => a.scenario_id.localeCompare(b.scenario_id) || a.criterion - b.criterion).map(x => ({ ...x, fail_rate: x.failures / x.n })),
85
+ };
86
+ }
87
+ function addCriterion(map, skill, model, id, vote) {
88
+ if (vote.verdict === "ERROR")
89
+ return;
90
+ const key = `${skill}\u0000${model}\u0000${id}:${vote.index}`, row = map.get(key) ?? { skill, model, scenario_id: id, criterion: vote.index, failures: 0, n: 0 };
91
+ row.n++;
92
+ if (vote.verdict === "FAIL")
93
+ row.failures++;
94
+ map.set(key, row);
95
+ }
96
+ export function formatScreen(report) {
97
+ const lines = ["skill/model/scenario control treatment not-measured class"];
98
+ for (const s of report.scenarios)
99
+ lines.push(`${s.skill}/${s.model}/${s.id} ${s.control.passes}/${s.control.n} ${s.treatment.passes}/${s.treatment.n} ${s.not_measured} ${s.classification}`);
100
+ lines.push("", "skill/model/criterion failures/n fail-rate");
101
+ for (const c of report.criteria)
102
+ lines.push(`${c.skill}/${c.model}/${c.scenario_id}.${c.criterion} ${c.failures}/${c.n} ${(c.fail_rate * 100).toFixed(1)}%`);
103
+ return lines.join("\n");
104
+ }
105
+ //# sourceMappingURL=screen.js.map
package/dist/seeded.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Scenario } from "./spec.js";
2
2
  import type { ExecutionTraceV1 } from "./capture-trace-types.js";
3
3
  import type { TrajectoryEventV1 } from "./trajectory-gates.js";
4
- import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
4
+ import type { HarnessAdapter, ModelRef, RunMode, PromptProvenance } from "./adapters/types.js";
5
5
  import { type ExecResult } from "./util/exec.js";
6
6
  interface SeededOpts {
7
7
  skillDir: string;
@@ -37,6 +37,7 @@ interface SeededOpts {
37
37
  armExtensions?: string[];
38
38
  /** The arm's env for the subject process; `<run-dir>` already substituted by the caller. */
39
39
  armEnv?: Record<string, string>;
40
+ onPromptObservation?: (observation: PromptProvenance) => void;
40
41
  }
41
42
  /**
42
43
  * Result of one vitest invocation — deliberately `ExecResult`, not a narrower
package/dist/seeded.js CHANGED
@@ -175,6 +175,7 @@ export async function runSeeded(scenario, opts) {
175
175
  ],
176
176
  eventSources: scenario.eventSources,
177
177
  ...(opts.armEnv ? { armEnv: opts.armEnv } : {}),
178
+ ...(opts.onPromptObservation ? { onPromptObservation: opts.onPromptObservation } : {}),
178
179
  };
179
180
  // A trace-gated seeded scenario runs through the structured path so the tool
180
181
  // calls are recorded; everything downstream (gates, diff, transcript) is
package/dist/sources.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Scenario } from "./spec.js";
2
+ export { PROMPT_NORMALIZATION_SOURCE_DIGEST, PROMPT_NORMALIZATION_SOURCE_KEY } from "./prompt-normalization.js";
2
3
  /**
3
4
  * What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
4
5
  * lint can prove a published result still describes the current inputs.
@@ -53,7 +54,7 @@ export declare const FIXTURE_PREFIX = "fixture:";
53
54
  * | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
54
55
  * | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
55
56
  * | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
56
- * | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (free; judges only flipped reps) |
57
+ * | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (no subject call; judges fail→pass reps) |
57
58
  * | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
58
59
  *
59
60
  * Why this matters more than it looks: with one key, lint had exactly one remedy for
@@ -190,6 +191,11 @@ export declare const SKILL_KEY = "SKILL.md";
190
191
  */
191
192
  export declare const SKILL_PROMPT_KEY = "skill:prompt";
192
193
  export declare const PROMPT_PREFIX = "prompt:";
194
+ /** A prompt document split into its frontmatter text (null when it has none) and its body. */
195
+ export declare function splitPromptDoc(text: string): {
196
+ frontmatter: string | null;
197
+ body: string;
198
+ };
193
199
  /**
194
200
  * The digest of a prompt document: its body verbatim, plus every frontmatter field the
195
201
  * model can actually receive.
package/dist/sources.js CHANGED
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { readFileSync, readdirSync } from "node:fs";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
4
  import yaml from "js-yaml";
5
+ import { PROMPT_NORMALIZATION_SOURCE_DIGEST, PROMPT_NORMALIZATION_SOURCE_KEY } from "./prompt-normalization.js";
6
+ export { PROMPT_NORMALIZATION_SOURCE_DIGEST, PROMPT_NORMALIZATION_SOURCE_KEY } from "./prompt-normalization.js";
5
7
  /**
6
8
  * What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
7
9
  * lint can prove a published result still describes the current inputs.
@@ -56,7 +58,7 @@ export const FIXTURE_PREFIX = "fixture:";
56
58
  * | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
57
59
  * | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
58
60
  * | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
59
- * | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (free; judges only flipped reps) |
61
+ * | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (no subject call; judges fail→pass reps) |
60
62
  * | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
61
63
  *
62
64
  * Why this matters more than it looks: with one key, lint had exactly one remedy for
@@ -182,7 +184,7 @@ function facets(s) {
182
184
  const _assertExhaustive = restAssert;
183
185
  void _assertExhaustive;
184
186
  // `traceAssert` is a GATE, not stimulus: it is evaluated against a trace the run
185
- // already saved, so `regate` (free) can re-answer it without re-running the model.
187
+ // already saved, so `regate` can re-answer it without re-running the subject model.
186
188
  // Note the asymmetry with `env.extensions` in Phase 3, which IS stimulus — one
187
189
  // changes what gets executed, the other only what we conclude from it.
188
190
  const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined || trajectoryAssert !== undefined;
@@ -194,7 +196,7 @@ function facets(s) {
194
196
  // below. Changing which extensions load changes what the model can DO, so the
195
197
  // old transcripts describe a different agent and only a re-run can answer.
196
198
  // Changing an assertion only changes what we conclude from evidence already on
197
- // disk, which `regate` can redo for free.
199
+ // disk, which `regate` can redo without a subject re-run.
198
200
  // APPENDED CONDITIONALLY, never as a fixed slot. This tuple is positional and
199
201
  // its hash is stored in every published results.yaml, so adding an
200
202
  // unconditional element re-hashes every scenario that never used the field —
@@ -363,6 +365,8 @@ export function sourceHashes(ctx) {
363
365
  * from drifting: a new key kind is defined once, for both sides.
364
366
  */
365
367
  export function currentHashFor(key, ctx) {
368
+ if (key === PROMPT_NORMALIZATION_SOURCE_KEY)
369
+ return PROMPT_NORMALIZATION_SOURCE_DIGEST;
366
370
  if (key === SKILL_KEY)
367
371
  return fileSha256(resolve(ctx.skillDir, "SKILL.md"));
368
372
  if (key === SKILL_PROMPT_KEY)
@@ -408,6 +412,8 @@ export function currentHashFor(key, ctx) {
408
412
  }
409
413
  /** Human label for a recorded key, used in lint messages. */
410
414
  export function describeSourceKey(key) {
415
+ if (key === PROMPT_NORMALIZATION_SOURCE_KEY)
416
+ return "the prompt normalization rule registry";
411
417
  if (key === PERSONA_KEY)
412
418
  return "the judge persona";
413
419
  // Deliberately the same label as the raw-bytes key: only one of the pair is ever
@@ -439,6 +445,8 @@ export function describeSourceKey(key) {
439
445
  * in place. Naming the actual remedy is what converts that into a free command.
440
446
  */
441
447
  export function remedyForKey(key) {
448
+ if (key === PROMPT_NORMALIZATION_SOURCE_KEY)
449
+ return "re-run — prompt provenance cannot be recomputed without the original model-visible payload";
442
450
  if (key === PERSONA_KEY) {
443
451
  return "re-grade each model's saved transcripts (`grade <run-dir>`) — judge-only, no model spend";
444
452
  }
@@ -449,7 +457,7 @@ export function remedyForKey(key) {
449
457
  return "re-score the saved reps (`rescore <run-dir>`) — free, offline";
450
458
  }
451
459
  if (key.startsWith(GATES_PREFIX)) {
452
- return "re-evaluate the needles against the saved diffs (`regate <run-dir>`) — free, and it judges only the reps whose gate verdict flipped";
460
+ return "re-evaluate gates against saved artifacts (`regate <run-dir>`) — no subject call; fail→pass reps require judge calls";
453
461
  }
454
462
  // A pre-split run recorded one hash over stimulus + rubric + policy + gates, so
455
463
  // which of them moved is genuinely unknowable from the record. Naming a cheap
@@ -498,7 +506,7 @@ const CAPABILITY_KEYS = new Set(["allowed-tools", "tools"]);
498
506
  /** Opening `---` on line 1 only — a `---` further down is a horizontal rule. */
499
507
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
500
508
  /** A prompt document split into its frontmatter text (null when it has none) and its body. */
501
- function splitPromptDoc(text) {
509
+ export function splitPromptDoc(text) {
502
510
  const m = FRONTMATTER_RE.exec(text);
503
511
  // Unterminated frontmatter falls through to "all body", the same call
504
512
  // instruction-coverage.ts makes: treat the whole file as content rather than lose it.
package/dist/spec.d.ts CHANGED
@@ -23,7 +23,7 @@ export interface SeededAssert {
23
23
  }
24
24
  export interface TrajectoryEventSource {
25
25
  /** Native format adapter registered by the harness adapter. */
26
- adapter: "normalized-v1" | "principal-assurance-v1" | "pi-daddy-v1";
26
+ adapter: "normalized-v1" | "principal-assurance-v1" | "pi-daddy-v1" | "pi-daddy-ledger-v3";
27
27
  /** Workspace-relative file/glob. Never an absolute path or traversal. */
28
28
  path: string;
29
29
  /** Missing evidence is an error by default; false is for optional supplemental ledgers. */
package/dist/spec.js CHANGED
@@ -97,7 +97,7 @@ function resolveEventSources(env, id, file) {
97
97
  throw new SpecError(`scenario \`${id}\` env.event_sources must be a non-empty list`, file);
98
98
  }
99
99
  const allowedAdapters = new Set([
100
- "normalized-v1", "principal-assurance-v1", "pi-daddy-v1",
100
+ "normalized-v1", "principal-assurance-v1", "pi-daddy-v1", "pi-daddy-ledger-v3",
101
101
  ]);
102
102
  return raw.map((entry, index) => {
103
103
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
package/dist/stability.js CHANGED
@@ -9,7 +9,7 @@ function conclusive(v) {
9
9
  // Same rule lift.ts applies, for the same reason: ERROR is a harness failure and an
10
10
  // unresolved misfire is a judge failure. Counting either as a side of a flip would
11
11
  // report infrastructure noise as behavioural instability.
12
- return !v.suspect && v.verdict !== "ERROR" && v.verdict !== "JUDGE-AMBIGUOUS";
12
+ return !v.suspect && v.verdict !== "ERROR" && v.verdict !== "NOT-MEASURED" && v.verdict !== "JUDGE-AMBIGUOUS";
13
13
  }
14
14
  function pointFor(r, s, verdict) {
15
15
  const reps = s.reps ?? 1;
@@ -1,6 +1,8 @@
1
1
  import { type ArgPredicate, type CountConstraint } from "./trace-gates.js";
2
2
  /** Adapter-neutral event contract used by objective trajectory assertions. */
3
- export declare const TRAJECTORY_EVENT_VERSION: "1.0";
3
+ export declare const LEGACY_TRAJECTORY_EVENT_VERSION: "1.0";
4
+ export declare const TRAJECTORY_EVENT_VERSION: "1.1";
5
+ export type TrajectoryEventVersion = typeof LEGACY_TRAJECTORY_EVENT_VERSION | typeof TRAJECTORY_EVENT_VERSION;
4
6
  export declare const TRAJECTORY_ASSERT_VERSION: "1.0";
5
7
  export interface TrajectoryApproval {
6
8
  id?: string;
@@ -26,7 +28,7 @@ export interface TrajectoryDigests {
26
28
  * needs to know whether the source was pi, pi-daddy, or principal assurance.
27
29
  */
28
30
  export interface TrajectoryEventV1 {
29
- event_version: typeof TRAJECTORY_EVENT_VERSION;
31
+ event_version: TrajectoryEventVersion;
30
32
  seq: number;
31
33
  type: string;
32
34
  source: string;
@@ -38,6 +40,16 @@ export interface TrajectoryEventV1 {
38
40
  finding_id?: string;
39
41
  parent_id?: string;
40
42
  child_id?: string;
43
+ /** Unique pi-daddy v3 execution occurrence; never synthesized for v2/0.17. */
44
+ execution_id?: string;
45
+ /** Explicit v3 execution parent. Null is a meaningful root identity. */
46
+ parent_execution_id?: string | null;
47
+ /** Unique v3 occurrence whose output composed this task. */
48
+ task_from_execution_id?: string;
49
+ /** Identity of a pi-daddy v3 workflow_fact occurrence. */
50
+ workflow_fact_id?: string;
51
+ /** Immutable v3 nonterminal lifecycle deadline. */
52
+ deadline_at?: string;
41
53
  phase?: string;
42
54
  tool?: string;
43
55
  capability?: string;
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { parsePredicate, testPredicate } from "./trace-gates.js";
3
3
  /** Adapter-neutral event contract used by objective trajectory assertions. */
4
- export const TRAJECTORY_EVENT_VERSION = "1.0";
4
+ export const LEGACY_TRAJECTORY_EVENT_VERSION = "1.0";
5
+ export const TRAJECTORY_EVENT_VERSION = "1.1";
5
6
  export const TRAJECTORY_ASSERT_VERSION = "1.0";
6
7
  export function trajectoryEventsSha256(events) {
7
8
  return createHash("sha256").update(stableStringify(events)).digest("hex");
@@ -434,7 +435,7 @@ export function runTrajectoryMutationSelfTest() {
434
435
  const h = "a".repeat(40), t = "b".repeat(40), p = "1".repeat(64);
435
436
  const cases = [
436
437
  { id: "remove-required-event", assertion: { version: "1.0", require: [{ event: "risk_classified" }] }, good: [e(1, "risk_classified")], mutate: () => [e(1, "unrelated")] },
437
- { id: "add-forbidden-tool-side-effect", assertion: { version: "1.0", forbid: [{ event: "tool_called", where: { tool: { equals: "rm" } } }, { event: "side_effect_performed" }] }, good: [e(1, "tool_called", { tool: "read" })], mutate: (events) => [...events, e(2, "side_effect_performed", { attributes: { destructive: true } })] },
438
+ { id: "add-forbidden-tool-side-effect-approval", assertion: { version: "1.0", forbid: [{ event: "tool_called", where: { tool: { equals: "rm" } } }, { event: "side_effect_approved" }] }, good: [e(1, "tool_called", { tool: "read" })], mutate: (events) => [...events, e(2, "side_effect_approved", { attributes: { action: "migration" } })] },
438
439
  { id: "reorder-transition", assertion: { version: "1.0", ordered: [[{ event: "phase_started" }, { event: "phase_completed" }]] }, good: [e(1, "phase_started"), e(2, "phase_completed")], mutate: () => [e(2, "phase_started"), e(1, "phase_completed")] },
439
440
  { id: "substitute-workspace-id", assertion: { version: "1.0", correlate: [{ left: { event: "code_changed" }, right: { event: "evidence_recorded" }, same: ["workspace_id"] }] }, good: [e(1, "code_changed", { workspace_id: "ws-1" }), e(2, "evidence_recorded", { workspace_id: "ws-1" })], mutate: (events) => events.map((x) => x.type === "evidence_recorded" ? { ...x, workspace_id: "ws-2" } : x) },
440
441
  { id: "concurrent-writer", assertion: { version: "1.0", forbid: [{ event: "writer_lease_conflict" }] }, good: [e(1, "writer_lease_acquired", { workspace_id: "ws-1" })], mutate: (events) => [...events, e(2, "writer_lease_conflict", { workspace_id: "ws-1" })] },
@@ -447,7 +448,13 @@ export function runTrajectoryMutationSelfTest() {
447
448
  { id: "remove-requirement-coverage", assertion: { version: "1.0", coverage: [{ requirements: ["AUTH-7"], events: { event: "evidence_recorded" } }] }, good: [e(1, "evidence_recorded", { requirements: ["AUTH-7"] })], mutate: (events) => events.map((x) => ({ ...x, requirements: [] })) },
448
449
  { id: "mutate-superseded-task", assertion: { version: "1.0", forbid_after: [{ anchor: { event: "task_packet_superseded" }, forbidden: [{ event: "code_changed" }, { event: "repair_started" }], same: ["task_id"] }] }, good: [e(1, "task_packet_superseded", { task_id: "old" }), e(2, "code_changed", { task_id: "new" })], mutate: (events) => events.map((x) => x.type === "code_changed" ? { ...x, task_id: "old" } : x) },
449
450
  { id: "reuse-context-id", assertion: { version: "1.0", unique: [{ events: { event: "review_recorded" }, fields: ["context_id"] }] }, good: [e(1, "review_recorded", { context_id: "c1" }), e(2, "review_recorded", { context_id: "c2" })], mutate: (events) => events.map((x) => x.seq === 2 ? { ...x, context_id: "c1" } : x) },
450
- { id: "mismatch-finalization-identity", assertion: { version: "1.0", correlate: [{ left: { event: "gate_evaluated", where: { "attributes.gate": { equals: "finalize" } } }, right: { event: "finalization_completed" }, same: ["digests.head", "digests.tree"], order: "before" }] }, good: [e(1, "gate_evaluated", { digests: { head: h, tree: t }, attributes: { gate: "finalize", result: "pass" } }), e(2, "finalization_completed", { digests: { head: h, tree: t } })], mutate: (events) => events.map((x) => x.type === "finalization_completed" ? { ...x, digests: { head: h, tree: "d".repeat(40) } } : x) },
451
+ { id: "mismatch-finalization-identity", assertion: { version: "1.0", correlate: [{ left: { event: "gate_evaluated", where: { "attributes.gate": { equals: "finalize" } } }, right: { event: "finalization_completed" }, same: ["digests.head", "digests.tree"], order: "before" }] }, good: [e(1, "gate_evaluated", { digests: { head: h, tree: t }, attributes: { gate: "finalize", code: "OK" } }), e(2, "finalization_completed", { digests: { head: h, tree: t } })], mutate: (events) => events.map((x) => x.type === "finalization_completed" ? { ...x, digests: { head: h, tree: "d".repeat(40) } } : x) },
452
+ { id: "v3-blocked-critical-code", assertion: { version: "1.0", require: [{ event: "gate_evaluated", where: { "attributes.code": { equals: "BLOCKED_CRITICAL_ASSURANCE" } } }] }, good: [e(1, "gate_evaluated", { attributes: { gate: "finalize", code: "BLOCKED_CRITICAL_ASSURANCE", missing_count: 2 } })], mutate: (events) => events.map((x) => ({ ...x, attributes: { ...x.attributes, code: "OK" } })) },
453
+ { id: "v3-stale-gate-must-block", assertion: { version: "1.0", require: [{ event: "gate_evaluated", where: { "attributes.code": { matches: "^BLOCKED_" } } }] }, good: [e(1, "gate_evaluated", { attributes: { gate: "finalize", code: "BLOCKED_ASSURANCE" } })], mutate: (events) => events.map((x) => ({ ...x, attributes: { ...x.attributes, code: "OK" } })) },
454
+ { id: "v3-finalize-gate-must-be-ok", assertion: { version: "1.0", require: [{ event: "gate_evaluated", where: { "attributes.gate": { equals: "finalize" }, "attributes.code": { equals: "OK" } } }] }, good: [e(1, "gate_evaluated", { attributes: { gate: "finalize", code: "OK" } })], mutate: (events) => events.map((x) => ({ ...x, attributes: { ...x.attributes, code: "BLOCKED_ASSURANCE" } })) },
455
+ { id: "v3-discard-requires-explicit-request", assertion: { version: "1.0", forbid: [{ event: "finish_selected", where: { "attributes.choice": { equals: "discard" }, "attributes.explicit_request": { equals: false } } }] }, good: [e(1, "finish_selected", { attributes: { choice: "discard", explicit_request: true } })], mutate: (events) => events.map((x) => ({ ...x, attributes: { ...x.attributes, explicit_request: false } })) },
456
+ { id: "v3-side-effect-approval-and-gate", assertion: { version: "1.0", ordered: [[{ event: "side_effect_approved", where: { "attributes.action": { equals: "migration" } } }, { event: "gate_evaluated", where: { "attributes.gate": { equals: "side-effect" }, "attributes.code": { equals: "OK" } } }]] }, good: [e(1, "side_effect_approved", { attributes: { action: "migration" } }), e(2, "gate_evaluated", { attributes: { gate: "side-effect", code: "OK" } })], mutate: (events) => events.map((x) => x.type === "gate_evaluated" ? { ...x, attributes: { ...x.attributes, code: "BLOCKED_ASSURANCE" } } : x) },
457
+ { id: "v3-governed-spawn-started", assertion: { version: "1.0", require: [{ event: "child_started", where: { source: { equals: "pi-daddy-v3" }, "attributes.state": { equals: "starting" } } }] }, good: [e(1, "child_started", { source: "pi-daddy-v3", attributes: { state: "starting" } })], mutate: (events) => events.map((x) => ({ ...x, attributes: { ...x.attributes, state: "failed" } })) },
451
458
  ];
452
459
  const results = cases.map((testCase) => {
453
460
  const baseline = evaluateTrajectoryGates(testCase.assertion, testCase.good);
@@ -461,9 +468,11 @@ export function runTrajectoryMutationSelfTest() {
461
468
  // ---------------------------------------------------------------------------
462
469
  // Internals
463
470
  // ---------------------------------------------------------------------------
471
+ const V11_EVENT_KEYS = new Set(["execution_id", "parent_execution_id", "task_from_execution_id", "workflow_fact_id", "deadline_at"]);
464
472
  const EVENT_KEYS = new Set([
465
473
  "event_version", "seq", "type", "source", "at", "run_id", "task_id", "workspace_id", "context_id", "finding_id",
466
- "parent_id", "child_id", "phase", "tool", "capability", "requested_capabilities", "effective_capabilities", "refusal_code",
474
+ "parent_id", "child_id", ...V11_EVENT_KEYS,
475
+ "phase", "tool", "capability", "requested_capabilities", "effective_capabilities", "refusal_code",
467
476
  "exit_code", "digests", "approval", "requirements", "attributes",
468
477
  ]);
469
478
  const APPROVAL_KEYS = new Set(["id", "capability", "subject", "source", "scope", "approved_at", "expires_at", "used_at"]);
@@ -479,8 +488,14 @@ function validateEvent(event) {
479
488
  const unknown = Object.keys(object).find((key) => !EVENT_KEYS.has(key));
480
489
  if (unknown)
481
490
  return `unknown field ${unknown}`;
482
- if (event.event_version !== TRAJECTORY_EVENT_VERSION)
491
+ if (event.event_version !== LEGACY_TRAJECTORY_EVENT_VERSION && event.event_version !== TRAJECTORY_EVENT_VERSION) {
483
492
  return `unsupported event_version ${String(event.event_version)}`;
493
+ }
494
+ if (event.event_version === LEGACY_TRAJECTORY_EVENT_VERSION) {
495
+ const versionedField = Object.keys(object).find((key) => V11_EVENT_KEYS.has(key));
496
+ if (versionedField)
497
+ return `${versionedField} requires event_version ${TRAJECTORY_EVENT_VERSION}`;
498
+ }
484
499
  if (!Number.isInteger(event.seq) || event.seq < 1)
485
500
  return "seq must be a positive integer";
486
501
  if (typeof event.type !== "string" || !event.type)
@@ -489,10 +504,15 @@ function validateEvent(event) {
489
504
  return "source must be a non-empty string";
490
505
  if (event.at !== undefined && !validDate(event.at))
491
506
  return "at must be an RFC 3339 date-time";
492
- for (const field of ["run_id", "task_id", "workspace_id", "context_id", "finding_id", "parent_id", "child_id"]) {
507
+ for (const field of ["run_id", "task_id", "workspace_id", "context_id", "finding_id", "parent_id", "child_id", "execution_id", "task_from_execution_id", "workflow_fact_id"]) {
493
508
  if (event[field] !== undefined && (typeof event[field] !== "string" || !ID_RE.test(event[field])))
494
509
  return `${field} is not a valid bounded identifier`;
495
510
  }
511
+ if (event.parent_execution_id !== undefined && event.parent_execution_id !== null &&
512
+ (typeof event.parent_execution_id !== "string" || !ID_RE.test(event.parent_execution_id)))
513
+ return "parent_execution_id is not a valid bounded identifier or null";
514
+ if (event.deadline_at !== undefined && !validDate(event.deadline_at))
515
+ return "deadline_at must be an RFC 3339 date-time";
496
516
  for (const field of ["phase", "tool", "capability"]) {
497
517
  if (event[field] !== undefined && (typeof event[field] !== "string" || !event[field]))
498
518
  return `${field} must be a non-empty string`;
@@ -0,0 +1,33 @@
1
+ import type { Verdict } from "./score.js";
2
+ /** Domain-neutral vote shape shared by ordinary adjudication and qualification panels. */
3
+ export interface PanelVote {
4
+ ordinal: number;
5
+ verdict: Verdict;
6
+ suspect: boolean;
7
+ }
8
+ export interface VotePanelCollapse {
9
+ state: "confirmed" | "tie_broken" | "unresolved";
10
+ verdict?: "PASS" | "FAIL";
11
+ clean_votes: number;
12
+ pass_votes: number;
13
+ fail_votes: number;
14
+ /** True only when the first two answers are both clean and oppose one another. */
15
+ split: boolean;
16
+ /** Minority clean votes / all clean votes; zero when there is no minority. */
17
+ minority_rate: number;
18
+ }
19
+ export declare function isCleanPanelVote(vote: PanelVote): vote is PanelVote & {
20
+ verdict: "PASS" | "FAIL";
21
+ };
22
+ /**
23
+ * One collapse rule for both skill-harness adjudication and qualification.
24
+ * Invalid, ambiguous, and suspect answers remain evidence but are never votes.
25
+ */
26
+ export declare function collapseVotePanel(votes: readonly PanelVote[]): VotePanelCollapse;
27
+ export declare function runVotePanel<T extends PanelVote>(options: {
28
+ cast: (ordinal: 1 | 2 | 3) => Promise<T>;
29
+ }): Promise<{
30
+ votes: T[];
31
+ collapse: VotePanelCollapse;
32
+ calls_made: number;
33
+ }>;
@@ -0,0 +1,41 @@
1
+ export function isCleanPanelVote(vote) {
2
+ return !vote.suspect && (vote.verdict === "PASS" || vote.verdict === "FAIL");
3
+ }
4
+ /**
5
+ * One collapse rule for both skill-harness adjudication and qualification.
6
+ * Invalid, ambiguous, and suspect answers remain evidence but are never votes.
7
+ */
8
+ export function collapseVotePanel(votes) {
9
+ const clean = votes.filter(isCleanPanelVote);
10
+ const passVotes = clean.filter((vote) => vote.verdict === "PASS").length;
11
+ const failVotes = clean.length - passVotes;
12
+ const firstTwo = votes.filter((vote) => vote.ordinal === 1 || vote.ordinal === 2);
13
+ const split = firstTwo.length === 2 && firstTwo.every(isCleanPanelVote) && firstTwo[0].verdict !== firstTwo[1].verdict;
14
+ const minority = Math.min(passVotes, failVotes);
15
+ const base = {
16
+ clean_votes: clean.length,
17
+ pass_votes: passVotes,
18
+ fail_votes: failVotes,
19
+ split,
20
+ minority_rate: clean.length === 0 ? 0 : minority / clean.length,
21
+ };
22
+ if (clean.length < 2)
23
+ return { ...base, state: "unresolved" };
24
+ if (passVotes === 0 || failVotes === 0)
25
+ return { ...base, state: "confirmed", verdict: clean[0].verdict };
26
+ if (passVotes > failVotes)
27
+ return { ...base, state: "tie_broken", verdict: "PASS" };
28
+ if (failVotes > passVotes)
29
+ return { ...base, state: "tie_broken", verdict: "FAIL" };
30
+ return { ...base, state: "unresolved" };
31
+ }
32
+ export async function runVotePanel(options) {
33
+ const votes = [await options.cast(1), await options.cast(2)];
34
+ let collapse = collapseVotePanel(votes);
35
+ if (collapse.split) {
36
+ votes.push(await options.cast(3));
37
+ collapse = collapseVotePanel(votes);
38
+ }
39
+ return { votes, collapse, calls_made: votes.length };
40
+ }
41
+ //# sourceMappingURL=vote-panel.js.map
package/dist/workspace.js CHANGED
@@ -143,7 +143,10 @@ function gitBaseline(cwd) {
143
143
  * claim in the prompt, and needs no network.
144
144
  */
145
145
  function addLocalRemote(cwd) {
146
- const bare = mkdtempSync(join(tmpdir(), "sc-remote-")) + ".git";
146
+ // Use the directory mkdtemp created. Appending `.git` orphaned the original
147
+ // empty directory on every remote-backed scenario because cleanup removed only
148
+ // the suffixed repository.
149
+ const bare = mkdtempSync(join(tmpdir(), "sc-remote-"));
147
150
  execFileSync("git", ["init", "-q", "--bare", "-b", "main", bare], { timeout: GIT_TIMEOUT_MS });
148
151
  execFileSync("git", ["remote", "add", "origin", bare], { cwd, timeout: GIT_TIMEOUT_MS });
149
152
  execFileSync("git", ["push", "-q", "-u", "origin", "main"], { cwd, timeout: GIT_TIMEOUT_MS });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",
@@ -39,9 +39,9 @@
39
39
  "node": ">=20"
40
40
  },
41
41
  "scripts": {
42
- "prepack": "rm -rf ./schemas && cp -R ../../schemas ./schemas && cp ../../LICENSE ./LICENSE"
42
+ "prepack": "node ../../scripts/release-pack.mjs --guard"
43
43
  },
44
44
  "dependencies": {
45
- "js-yaml": "^4.1.0"
45
+ "js-yaml": "^4.3.1"
46
46
  }
47
47
  }
@@ -0,0 +1,32 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://skill-harness.dev/schemas/qualification-cell-result-v1.schema.json",
4
+ "title": "skill-harness qualification cell result v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "cell_id", "scenario_id", "subject_arm", "critical", "pass_threshold", "panels", "critical_failure", "acceptance", "collection", "disagreement"],
8
+ "properties": {
9
+ "schema_version": { "const": "qualification-cell-result-v1" },
10
+ "cell_id": { "$ref": "#/$defs/id" }, "scenario_id": { "$ref": "#/$defs/id" }, "subject_arm": { "$ref": "#/$defs/id" },
11
+ "critical": { "type": "boolean" }, "pass_threshold": { "type": "integer", "minimum": 1 },
12
+ "panels": { "type": "array", "minItems": 1, "items": { "$ref": "qualification-judge-panel-result-v1.schema.json" } },
13
+ "verdict": { "enum": ["PASS", "FAIL"] }, "critical_failure": { "type": "boolean" },
14
+ "acceptance": { "enum": ["pass", "fail", "inconclusive"] }, "collection": { "const": "continue" },
15
+ "disagreement": {
16
+ "type": "object", "additionalProperties": false,
17
+ "required": ["judge_calls", "clean_votes", "split_artifacts", "artifacts_with_two_clean_initial_votes", "unresolved_artifacts", "judge_split_rate"],
18
+ "properties": {
19
+ "judge_calls": { "type": "integer", "minimum": 0 }, "clean_votes": { "type": "integer", "minimum": 0 },
20
+ "split_artifacts": { "type": "integer", "minimum": 0 }, "artifacts_with_two_clean_initial_votes": { "type": "integer", "minimum": 0 },
21
+ "unresolved_artifacts": { "type": "integer", "minimum": 0 }, "judge_split_rate": { "type": "number", "minimum": 0, "maximum": 1 }
22
+ }
23
+ }
24
+ },
25
+ "allOf": [
26
+ { "if": { "properties": { "acceptance": { "const": "inconclusive" } }, "required": ["acceptance"] }, "then": { "not": { "properties": { "verdict": {} }, "required": ["verdict"] }, "properties": { "critical_failure": { "const": false } } }, "else": { "required": ["verdict"] } },
27
+ { "if": { "properties": { "acceptance": { "const": "pass" } }, "required": ["acceptance"] }, "then": { "properties": { "verdict": { "const": "PASS" }, "critical_failure": { "const": false } } } },
28
+ { "if": { "properties": { "acceptance": { "const": "fail" } }, "required": ["acceptance"] }, "then": { "properties": { "verdict": { "const": "FAIL" } }, "required": ["verdict"] } },
29
+ { "if": { "properties": { "critical_failure": { "const": true } }, "required": ["critical_failure"] }, "then": { "properties": { "critical": { "const": true }, "acceptance": { "const": "fail" }, "verdict": { "const": "FAIL" } }, "required": ["verdict"] } }
30
+ ],
31
+ "$defs": { "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" } }
32
+ }