@skill-harness/core 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.
Files changed (81) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/types.d.ts +30 -0
  3. package/dist/adjudication.d.ts +9 -1
  4. package/dist/adjudication.js +41 -33
  5. package/dist/arms.d.ts +51 -0
  6. package/dist/arms.js +255 -0
  7. package/dist/capture-trace-types.d.ts +2 -0
  8. package/dist/command-cost.d.ts +9 -0
  9. package/dist/command-cost.js +23 -0
  10. package/dist/comparison.d.ts +1 -0
  11. package/dist/comparison.js +9 -3
  12. package/dist/execution-trace.js +9 -1
  13. package/dist/grade.d.ts +2 -0
  14. package/dist/grade.js +2 -1
  15. package/dist/index.d.ts +14 -0
  16. package/dist/index.js +14 -0
  17. package/dist/journal.d.ts +2 -1
  18. package/dist/judge-agreement.d.ts +19 -0
  19. package/dist/judge-agreement.js +31 -0
  20. package/dist/judge-policy.js +5 -3
  21. package/dist/lift.js +1 -1
  22. package/dist/lint.js +1 -1
  23. package/dist/prompt-normalization.d.ts +6 -0
  24. package/dist/prompt-normalization.js +14 -0
  25. package/dist/provider-failure.d.ts +56 -0
  26. package/dist/provider-failure.js +98 -0
  27. package/dist/qualification-capture.d.ts +44 -0
  28. package/dist/qualification-capture.js +163 -0
  29. package/dist/qualification-config.d.ts +233 -0
  30. package/dist/qualification-config.js +694 -0
  31. package/dist/qualification-lock.d.ts +15 -0
  32. package/dist/qualification-lock.js +87 -0
  33. package/dist/qualification-oauth-directory.d.ts +79 -0
  34. package/dist/qualification-oauth-directory.js +444 -0
  35. package/dist/qualification-panel-store.d.ts +14 -0
  36. package/dist/qualification-panel-store.js +188 -0
  37. package/dist/qualification-panels.d.ts +99 -0
  38. package/dist/qualification-panels.js +91 -0
  39. package/dist/qualification-process.d.ts +13 -0
  40. package/dist/qualification-process.js +188 -0
  41. package/dist/qualification-runner.d.ts +203 -0
  42. package/dist/qualification-runner.js +2037 -0
  43. package/dist/qualification-store.d.ts +187 -0
  44. package/dist/qualification-store.js +833 -0
  45. package/dist/qualification-validation.d.ts +12 -0
  46. package/dist/qualification-validation.js +8 -0
  47. package/dist/regate.js +49 -10
  48. package/dist/regrade.d.ts +3 -1
  49. package/dist/regrade.js +62 -6
  50. package/dist/reps.d.ts +4 -2
  51. package/dist/reps.js +13 -4
  52. package/dist/rescore.js +9 -1
  53. package/dist/results.d.ts +99 -7
  54. package/dist/results.js +228 -10
  55. package/dist/run.d.ts +14 -0
  56. package/dist/run.js +260 -57
  57. package/dist/score.d.ts +4 -3
  58. package/dist/score.js +13 -4
  59. package/dist/screen.d.ts +31 -0
  60. package/dist/screen.js +105 -0
  61. package/dist/seeded.d.ts +12 -1
  62. package/dist/seeded.js +9 -2
  63. package/dist/sources.d.ts +7 -1
  64. package/dist/sources.js +13 -5
  65. package/dist/spec.d.ts +1 -1
  66. package/dist/spec.js +1 -1
  67. package/dist/stability.js +1 -1
  68. package/dist/trajectory-gates.d.ts +14 -2
  69. package/dist/trajectory-gates.js +26 -6
  70. package/dist/vote-panel.d.ts +33 -0
  71. package/dist/vote-panel.js +41 -0
  72. package/dist/workspace.js +34 -3
  73. package/package.json +3 -3
  74. package/schemas/qualification-cell-result-v1.schema.json +32 -0
  75. package/schemas/qualification-config-v1.schema.json +244 -0
  76. package/schemas/qualification-invocation-request-v1.schema.json +59 -0
  77. package/schemas/qualification-judge-panel-result-v1.schema.json +42 -0
  78. package/schemas/results-v2.schema.json +19 -21
  79. package/schemas/results-v3.schema.json +654 -0
  80. package/schemas/specification-v1.schema.json +1 -1
  81. 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;
@@ -27,6 +27,17 @@ interface SeededOpts {
27
27
  scenarioId: string;
28
28
  rep: number;
29
29
  };
30
+ /**
31
+ * Absolute paths of an arm's pi extensions, merged with `scenario.extensions`
32
+ * when the `RunReq` is built below — the same shape the non-seeded path in
33
+ * `run.ts` produces, so a trace-gated seeded scenario measures the same
34
+ * delivery as an ungated one (the reason `runStructured` shares `skillFlags`
35
+ * with `run()` instead of inventing a second convention applies here too).
36
+ */
37
+ armExtensions?: string[];
38
+ /** The arm's env for the subject process; `<run-dir>` already substituted by the caller. */
39
+ armEnv?: Record<string, string>;
40
+ onPromptObservation?: (observation: PromptProvenance) => void;
30
41
  }
31
42
  /**
32
43
  * Result of one vitest invocation — deliberately `ExecResult`, not a narrower
package/dist/seeded.js CHANGED
@@ -166,9 +166,16 @@ export async function runSeeded(scenario, opts) {
166
166
  mode: opts.mode,
167
167
  turns: scenario.turns,
168
168
  cwd: repo,
169
- // Resolved against the spec dir, exactly like fixtures and post-tests.
170
- extensions: scenario.extensions?.map((e) => resolve(opts.specDir, e)),
169
+ // Resolved against the spec dir, exactly like fixtures and post-tests —
170
+ // the arm's extensions (already absolute) join alongside, same as the
171
+ // non-seeded path in run.ts.
172
+ extensions: [
173
+ ...(scenario.extensions?.map((e) => resolve(opts.specDir, e)) ?? []),
174
+ ...(opts.armExtensions ?? []),
175
+ ],
171
176
  eventSources: scenario.eventSources,
177
+ ...(opts.armEnv ? { armEnv: opts.armEnv } : {}),
178
+ ...(opts.onPromptObservation ? { onPromptObservation: opts.onPromptObservation } : {}),
172
179
  };
173
180
  // A trace-gated seeded scenario runs through the structured path so the tool
174
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
@@ -90,8 +90,22 @@ function assertKnownMarkers(src) {
90
90
  * ways — a `diff_contains` needle matching a test filename can pass for free, and a
91
91
  * `diff_excludes` needle can false-fail on a path string — and it pads every diff the
92
92
  * judge reads.
93
+ *
94
+ * `.pi/skills/` is the same class from a different writer: `seedArmDefinitions` copies
95
+ * an arm's definitions there for pi-daddy to spawn, and that happens in the same
96
+ * workspace `runSeeded` later runs `git add -A` + `git diff --cached` against.
97
+ * Without this exclusion those seeded `.md` files land in the diff as
98
+ * harness-injected `+` lines — a `diff_contains` needle can match text the model
99
+ * never wrote (a false objective PASS), the judge is shown the seeded definitions
100
+ * as if they were the model's edit, and `.pi/skills/*` reads as a model change for
101
+ * `unchanged_paths`.
102
+ *
103
+ * `.pi/skills/` and NOT `.pi/`: only that one directory is harness-written. `.pi/`
104
+ * at large is content the model can legitimately be asked to author, and excluding
105
+ * all of it would make that work invisible to every gate — including for the
106
+ * control arm, which seeds nothing.
93
107
  */
94
- const TOOL_ARTIFACTS = ["node_modules/", "coverage/", ".vitest/"];
108
+ const TOOL_ARTIFACTS = ["node_modules/", "coverage/", ".vitest/", ".pi/skills/"];
95
109
  /**
96
110
  * Exclude tool artifacts via `.git/info/exclude`, deliberately not a `.gitignore`.
97
111
  *
@@ -129,7 +143,10 @@ function gitBaseline(cwd) {
129
143
  * claim in the prompt, and needs no network.
130
144
  */
131
145
  function addLocalRemote(cwd) {
132
- 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-"));
133
150
  execFileSync("git", ["init", "-q", "--bare", "-b", "main", bare], { timeout: GIT_TIMEOUT_MS });
134
151
  execFileSync("git", ["remote", "add", "origin", bare], { cwd, timeout: GIT_TIMEOUT_MS });
135
152
  execFileSync("git", ["push", "-q", "-u", "origin", "main"], { cwd, timeout: GIT_TIMEOUT_MS });
@@ -190,8 +207,20 @@ export function createWorkspace(kind, opts) {
190
207
  }
191
208
  return { cwd, cleanup };
192
209
  }
193
- /** Never the model's work, and never worth hashing. */
210
+ /** Never the model's work, and never worth hashing — matched on the entry NAME, at any depth. */
194
211
  const SNAPSHOT_SKIP = new Set([".git", "node_modules", "coverage", ".vitest"]);
212
+ /**
213
+ * Skipped by RELATIVE PATH, not by name: `.pi/skills/` is where an arm's seeded
214
+ * definitions land, and only that directory is the harness's writing.
215
+ *
216
+ * Scoped to `skills/` rather than all of `.pi/` on purpose. `.pi/` is also a
217
+ * perfectly ordinary thing for the model to be asked to write — a skill that
218
+ * authors pi agent definitions does exactly that, and the corpus keeps a `.pi/`
219
+ * at its own root — so skipping the whole directory would hide that work from
220
+ * `unchanged_paths` (a vacuous pass) as well as from `diff_contains` (a false
221
+ * FAIL), for the control arm too, which seeds nothing at all.
222
+ */
223
+ const SNAPSHOT_SKIP_RELS = new Set([".pi/skills"]);
195
224
  /** Snapshot a workspace, or null when there is no workspace to look at. */
196
225
  export function snapshotPaths(cwd, kind) {
197
226
  if (kind === "none" || !cwd || !existsSync(cwd))
@@ -209,6 +238,8 @@ export function snapshotPaths(cwd, kind) {
209
238
  if (SNAPSHOT_SKIP.has(e.name))
210
239
  continue;
211
240
  const rel = prefix ? `${prefix}/${e.name}` : e.name;
241
+ if (SNAPSHOT_SKIP_RELS.has(rel))
242
+ continue;
212
243
  const abs = join(dir, e.name);
213
244
  if (e.isDirectory()) {
214
245
  walk(abs, rel);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.10.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
+ }