@skill-harness/core 0.7.0 → 0.9.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 (44) hide show
  1. package/dist/adapters/types.d.ts +8 -0
  2. package/dist/adjudication.d.ts +1 -0
  3. package/dist/adjudication.js +26 -4
  4. package/dist/capture-trace-types.d.ts +19 -0
  5. package/dist/comparison.d.ts +96 -0
  6. package/dist/comparison.js +183 -0
  7. package/dist/execution-trace.js +93 -3
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +4 -0
  10. package/dist/lint.d.ts +7 -0
  11. package/dist/lint.js +14 -2
  12. package/dist/regate.d.ts +1 -1
  13. package/dist/regate.js +94 -21
  14. package/dist/regrade.d.ts +4 -0
  15. package/dist/regrade.js +29 -5
  16. package/dist/report.d.ts +7 -0
  17. package/dist/report.js +6 -0
  18. package/dist/reps.d.ts +8 -1
  19. package/dist/reps.js +47 -13
  20. package/dist/rescore.js +2 -2
  21. package/dist/restamp.d.ts +68 -0
  22. package/dist/restamp.js +194 -0
  23. package/dist/results.d.ts +28 -0
  24. package/dist/results.js +56 -4
  25. package/dist/run.js +96 -35
  26. package/dist/sandbox.d.ts +32 -0
  27. package/dist/sandbox.js +12 -0
  28. package/dist/score.d.ts +3 -1
  29. package/dist/score.js +20 -5
  30. package/dist/seeded.d.ts +7 -1
  31. package/dist/seeded.js +29 -8
  32. package/dist/sources.d.ts +50 -0
  33. package/dist/sources.js +186 -7
  34. package/dist/spec.d.ts +15 -0
  35. package/dist/spec.js +53 -1
  36. package/dist/stability.js +26 -4
  37. package/dist/trace-gates.d.ts +2 -1
  38. package/dist/trace-gates.js +4 -1
  39. package/dist/trajectory-gates.d.ts +137 -0
  40. package/dist/trajectory-gates.js +592 -0
  41. package/package.json +5 -3
  42. package/schemas/results-v2.schema.json +133 -0
  43. package/schemas/specification-v1.schema.json +217 -0
  44. package/schemas/trajectory-event-v1.schema.json +61 -0
package/dist/spec.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import yaml from "js-yaml";
3
3
  import { parseTraceAssert } from "./trace-gates.js";
4
+ import { parseTrajectoryAssert } from "./trajectory-gates.js";
4
5
  /** Thrown on any validation failure. Message always carries the spec file path. */
5
6
  export class SpecError extends Error {
6
7
  constructor(message, file) {
@@ -88,6 +89,46 @@ function resolveExtensions(env, hasSystemPrompt, id, file) {
88
89
  * with empty-git or a fixture — asking for one on a bare cwd is an authoring mistake,
89
90
  * not something to silently ignore.
90
91
  */
92
+ function resolveEventSources(env, id, file) {
93
+ const raw = env && typeof env === "object" ? env.event_sources : undefined;
94
+ if (raw === undefined)
95
+ return undefined;
96
+ if (!Array.isArray(raw) || raw.length === 0) {
97
+ throw new SpecError(`scenario \`${id}\` env.event_sources must be a non-empty list`, file);
98
+ }
99
+ const allowedAdapters = new Set([
100
+ "normalized-v1", "principal-assurance-v1", "pi-daddy-v1",
101
+ ]);
102
+ return raw.map((entry, index) => {
103
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
104
+ throw new SpecError(`scenario \`${id}\` env.event_sources[${index}] must be a mapping`, file);
105
+ }
106
+ const source = entry;
107
+ for (const key of Object.keys(source)) {
108
+ if (!["adapter", "path", "required"].includes(key)) {
109
+ throw new SpecError(`scenario \`${id}\` env.event_sources[${index}] has unknown key \`${key}\``, file);
110
+ }
111
+ }
112
+ if (!allowedAdapters.has(source.adapter)) {
113
+ throw new SpecError(`scenario \`${id}\` env.event_sources[${index}].adapter is unsupported`, file);
114
+ }
115
+ if (typeof source.path !== "string" || !source.path.trim()) {
116
+ throw new SpecError(`scenario \`${id}\` env.event_sources[${index}].path must be non-empty`, file);
117
+ }
118
+ const path = source.path.trim();
119
+ if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.includes("\\") || path.split("/").some((part) => part === ".." || part === "." || part === "")) {
120
+ throw new SpecError(`scenario \`${id}\` env.event_sources paths must be workspace-relative without traversal (got \`${path}\`)`, file);
121
+ }
122
+ if (source.required !== undefined && typeof source.required !== "boolean") {
123
+ throw new SpecError(`scenario \`${id}\` env.event_sources[${index}].required must be true or false`, file);
124
+ }
125
+ return {
126
+ adapter: source.adapter,
127
+ path,
128
+ required: source.required !== false,
129
+ };
130
+ });
131
+ }
91
132
  function resolveRemote(env, workspace, id, file) {
92
133
  const raw = env && typeof env === "object" ? env.remote : undefined;
93
134
  if (raw === undefined)
@@ -114,6 +155,9 @@ export function parseSpec(text, file) {
114
155
  throw new SpecError("spec must be a YAML mapping", file);
115
156
  }
116
157
  const o = doc;
158
+ if (o.schema !== undefined && o.schema !== 1) {
159
+ throw new SpecError(`unsupported \`schema\` ${JSON.stringify(o.schema)} (expected 1)`, file);
160
+ }
117
161
  if (typeof o.skill !== "string" || o.skill.length === 0) {
118
162
  throw new SpecError("missing or invalid `skill` (string)", file);
119
163
  }
@@ -179,6 +223,9 @@ export function parseSpec(text, file) {
179
223
  if (rawAssert?.trace !== undefined) {
180
224
  scenario.traceAssert = parseTraceAssert(rawAssert.trace, `${file}: scenario \`${id}\``);
181
225
  }
226
+ if (rawAssert?.trajectory !== undefined) {
227
+ scenario.trajectoryAssert = parseTrajectoryAssert(rawAssert.trajectory, `${file}: scenario \`${id}\``);
228
+ }
182
229
  if (mode === "seeded") {
183
230
  if (typeof s.fixture !== "string" || s.fixture.length === 0) {
184
231
  throw new SpecError(`seeded scenario \`${id}\` requires a \`fixture\` path`, file);
@@ -231,6 +278,10 @@ export function parseSpec(text, file) {
231
278
  }
232
279
  scenario.workspace = resolveWorkspace(s.env, mode, scenario.fixture, id, file);
233
280
  scenario.remote = resolveRemote(s.env, scenario.workspace, id, file);
281
+ scenario.eventSources = resolveEventSources(s.env, id, file);
282
+ if (scenario.eventSources && !scenario.trajectoryAssert) {
283
+ throw new SpecError(`scenario \`${id}\` declares env.event_sources without assert.trajectory`, file);
284
+ }
234
285
  if (s.system_prompt_file !== undefined) {
235
286
  if (typeof s.system_prompt_file !== "string" || !s.system_prompt_file.trim()) {
236
287
  throw new SpecError(`scenario \`${id}\` \`system_prompt_file\` must be a non-empty string`, file);
@@ -277,7 +328,8 @@ export function parseSpec(text, file) {
277
328
  }
278
329
  return scenario;
279
330
  });
280
- return { skill: o.skill, judge_persona: o.judge_persona, ship_bar, critical, scenarios };
331
+ const effectiveCritical = [...new Set([...critical, ...scenarios.filter((scenario) => scenario.critical).map((scenario) => scenario.id)])];
332
+ return { schema: 1, skill: o.skill, judge_persona: o.judge_persona, ship_bar, critical: effectiveCritical, scenarios };
281
333
  }
282
334
  /** Load + validate a specification.yaml from disk. */
283
335
  export function loadSpec(file) {
package/dist/stability.js CHANGED
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import { loadSpec } from "./spec.js";
3
3
  import { effectiveVerdicts } from "./results.js";
4
4
  import { collectScoredRuns } from "./trends.js";
5
- import { describeSourceKey, scenarioSourceKeys, PERSONA_KEY, SKILL_KEY } from "./sources.js";
5
+ import { describeSourceKey, isSupersededKey, scenarioSourceKeys, PERSONA_KEY, SKILL_KEY, SKILL_PROMPT_KEY } from "./sources.js";
6
6
  const DEFAULT_WINDOW = 5;
7
7
  /** A verdict that carries evidence about the task rather than about the harness or judge. */
8
8
  function conclusive(v) {
@@ -23,6 +23,25 @@ function pointFor(r, s, verdict) {
23
23
  unanimous: reps > 1 && s.flakiness === 0,
24
24
  };
25
25
  }
26
+ /**
27
+ * Did the skill text the MODEL RECEIVES differ between two runs?
28
+ *
29
+ * Prefers `skill:prompt` when both runs recorded it, and falls back to the raw-bytes
30
+ * `SKILL.md` key for runs that predate it. A frontmatter-only edit moves the raw bytes and
31
+ * not the prompt, and labelling a flip "SKILL.md changed across that step" on that basis
32
+ * would point a reader at an edit no model could have seen — the note's whole job is to
33
+ * say which of two explanations the record supports, so it must not invent a third.
34
+ */
35
+ function skillTextChanged(prev, cur) {
36
+ for (const key of [SKILL_PROMPT_KEY, SKILL_KEY]) {
37
+ const a = prev?.[key];
38
+ const b = cur?.[key];
39
+ if (a === undefined || b === undefined)
40
+ continue;
41
+ return a !== b;
42
+ }
43
+ return false;
44
+ }
26
45
  /** reps + threshold, normalised the way lift.ts normalises it (a lone rep has no threshold). */
27
46
  function shapeOf(s) {
28
47
  const reps = s.reps ?? 1;
@@ -43,6 +62,11 @@ function compareSources(a, b, keys) {
43
62
  let shared = 0;
44
63
  const changed = [];
45
64
  for (const key of keys) {
65
+ // Same rule as lint's: when both runs recorded the model-visible digest of a prompt
66
+ // document, THAT is the comparison. Leaving the raw-bytes key in would call two runs
67
+ // "different questions" over an `allowed-tools:` line neither model could read.
68
+ if (isSupersededKey(key, a) && isSupersededKey(key, b))
69
+ continue;
46
70
  const va = a[key];
47
71
  const vb = b[key];
48
72
  if (va === undefined || vb === undefined)
@@ -77,9 +101,7 @@ function stabilityForScenario(group, scenario, window) {
77
101
  const cur = raw[i];
78
102
  const from = points[i - 1];
79
103
  const to = points[i];
80
- const skillChanged = prev.r.source_hashes?.[SKILL_KEY] !== undefined &&
81
- cur.r.source_hashes?.[SKILL_KEY] !== undefined &&
82
- prev.r.source_hashes[SKILL_KEY] !== cur.r.source_hashes[SKILL_KEY];
104
+ const skillChanged = skillTextChanged(prev.r.source_hashes, cur.r.source_hashes);
83
105
  const base = { from, to, flipped: false, skillChanged, changedSources: [], unanimousFlip: false };
84
106
  if (!prev.ok || !cur.ok) {
85
107
  pairs.push({ ...base, status: "inconclusive" });
@@ -95,7 +95,7 @@ export declare function normalizeSubagentCall(args: Record<string, unknown>): Su
95
95
  */
96
96
  export type AssertionStatus = "PASS" | "FAIL" | "ERROR";
97
97
  export interface AssertionResult {
98
- kind: "require_call" | "require_subagent" | "forbid_call" | "unchanged_path";
98
+ kind: "require_call" | "require_subagent" | "forbid_call" | "unchanged_path" | "trace_evidence";
99
99
  status: AssertionStatus;
100
100
  detail: string;
101
101
  }
@@ -131,3 +131,4 @@ export declare function matchesGlob(pattern: string, path: string): boolean;
131
131
  * possible failure for a safety check.
132
132
  */
133
133
  export declare function parseTraceAssert(raw: unknown, ctx: string): TraceAssert;
134
+ export declare function parsePredicate(raw: unknown, ctx: string): ArgPredicate;
@@ -39,6 +39,9 @@ export function normalizeSubagentCall(args) {
39
39
  */
40
40
  export function evaluateTraceGates(assert, trace) {
41
41
  const assertions = [];
42
+ for (const error of trace.capture_errors ?? []) {
43
+ assertions.push({ kind: "trace_evidence", status: "ERROR", detail: error });
44
+ }
42
45
  for (const req of assert.require_calls ?? []) {
43
46
  const matched = trace.tool_calls.filter((c) => c.name === req.tool && argsMatch(c, req.args));
44
47
  const min = req.count?.min ?? 1;
@@ -469,7 +472,7 @@ function parseArgs(raw, ctx) {
469
472
  }
470
473
  return out;
471
474
  }
472
- function parsePredicate(raw, ctx) {
475
+ export function parsePredicate(raw, ctx) {
473
476
  // `agent: plan` is shorthand for `agent: { equals: plan }` — the common case
474
477
  // should not require the author to know the operator vocabulary.
475
478
  if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
@@ -0,0 +1,137 @@
1
+ import { type ArgPredicate, type CountConstraint } from "./trace-gates.js";
2
+ /** Adapter-neutral event contract used by objective trajectory assertions. */
3
+ export declare const TRAJECTORY_EVENT_VERSION: "1.0";
4
+ export declare const TRAJECTORY_ASSERT_VERSION: "1.0";
5
+ export interface TrajectoryApproval {
6
+ id?: string;
7
+ capability?: string;
8
+ subject?: string;
9
+ source?: string;
10
+ scope?: string;
11
+ approved_at?: string;
12
+ expires_at?: string;
13
+ used_at?: string;
14
+ }
15
+ export interface TrajectoryDigests {
16
+ plan?: string;
17
+ task?: string;
18
+ definition?: string;
19
+ head?: string;
20
+ tree?: string;
21
+ [key: string]: string | undefined;
22
+ }
23
+ /**
24
+ * One normalized workflow event. Native field names remain available in
25
+ * `attributes`; common correlation fields are promoted so the evaluator never
26
+ * needs to know whether the source was pi, pi-daddy, or principal assurance.
27
+ */
28
+ export interface TrajectoryEventV1 {
29
+ event_version: typeof TRAJECTORY_EVENT_VERSION;
30
+ seq: number;
31
+ type: string;
32
+ source: string;
33
+ at?: string;
34
+ run_id?: string;
35
+ task_id?: string;
36
+ workspace_id?: string;
37
+ context_id?: string;
38
+ finding_id?: string;
39
+ parent_id?: string;
40
+ child_id?: string;
41
+ phase?: string;
42
+ tool?: string;
43
+ capability?: string;
44
+ requested_capabilities?: string[];
45
+ effective_capabilities?: string[];
46
+ refusal_code?: string;
47
+ exit_code?: number;
48
+ digests?: TrajectoryDigests;
49
+ approval?: TrajectoryApproval;
50
+ requirements?: string[];
51
+ attributes?: Record<string, unknown>;
52
+ }
53
+ export interface EventSelector {
54
+ event: string;
55
+ where?: Record<string, ArgPredicate>;
56
+ select?: "first" | "last" | "all";
57
+ }
58
+ export interface RequiredEvent extends EventSelector {
59
+ count?: CountConstraint;
60
+ }
61
+ export interface CorrelationAssert {
62
+ left: EventSelector;
63
+ right: EventSelector;
64
+ same?: string[];
65
+ different?: string[];
66
+ order?: "before" | "after";
67
+ }
68
+ export interface FreshnessAssert {
69
+ subject: EventSelector;
70
+ after: EventSelector[];
71
+ same?: string[];
72
+ }
73
+ export interface UniqueAssert {
74
+ events: EventSelector;
75
+ fields: string[];
76
+ }
77
+ export interface ForbidAfterAssert {
78
+ anchor: EventSelector;
79
+ forbidden: EventSelector[];
80
+ same?: string[];
81
+ anchor_optional?: boolean;
82
+ }
83
+ export interface ApprovalAssert {
84
+ grant: EventSelector;
85
+ use: EventSelector;
86
+ same?: string[];
87
+ scopes?: string[];
88
+ sources?: string[];
89
+ unexpired?: boolean;
90
+ max_uses?: number;
91
+ }
92
+ export interface CoverageAssert {
93
+ requirements: string[];
94
+ events?: EventSelector;
95
+ }
96
+ export interface TrajectoryAssert {
97
+ version: typeof TRAJECTORY_ASSERT_VERSION;
98
+ require?: RequiredEvent[];
99
+ forbid?: EventSelector[];
100
+ ordered?: EventSelector[][];
101
+ correlate?: CorrelationAssert[];
102
+ freshness?: FreshnessAssert[];
103
+ unique?: UniqueAssert[];
104
+ forbid_after?: ForbidAfterAssert[];
105
+ approvals?: ApprovalAssert[];
106
+ coverage?: CoverageAssert[];
107
+ }
108
+ export type TrajectoryAssertionStatus = "PASS" | "FAIL" | "ERROR";
109
+ export interface TrajectoryAssertionResult {
110
+ kind: "require_event" | "forbid_event" | "ordered_events" | "correlation" | "freshness" | "unique" | "forbid_after" | "approval" | "coverage" | "evidence";
111
+ status: TrajectoryAssertionStatus;
112
+ detail: string;
113
+ }
114
+ export interface TrajectoryGateResult {
115
+ status: TrajectoryAssertionStatus;
116
+ event_version: typeof TRAJECTORY_EVENT_VERSION;
117
+ events_sha256: string;
118
+ assertions: TrajectoryAssertionResult[];
119
+ }
120
+ export declare function trajectoryEventsSha256(events: TrajectoryEventV1[]): string;
121
+ export declare function serializeTrajectoryEvents(events: TrajectoryEventV1[]): string;
122
+ export declare function deserializeTrajectoryEvents(text: string): TrajectoryEventV1[] | null;
123
+ /** Evaluate a closed, declarative assertion set over saved normalized events. */
124
+ export declare function evaluateTrajectoryGates(assert: TrajectoryAssert, input: TrajectoryEventV1[]): TrajectoryGateResult;
125
+ export declare function parseTrajectoryAssert(raw: unknown, ctx: string): TrajectoryAssert;
126
+ export interface MutationSelfTestCase {
127
+ id: string;
128
+ detected: boolean;
129
+ status: TrajectoryAssertionStatus;
130
+ detail: string;
131
+ }
132
+ export interface MutationSelfTestReport {
133
+ baseline: "PASS";
134
+ cases: MutationSelfTestCase[];
135
+ }
136
+ /** Fast, deterministic proof that every trajectory assertion class can turn red. */
137
+ export declare function runTrajectoryMutationSelfTest(): MutationSelfTestReport;