@skill-harness/core 0.6.0 → 0.7.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.
@@ -0,0 +1,106 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import yaml from "js-yaml";
5
+ import { parseSpec } from "./spec.js";
6
+ /**
7
+ * The single choke point for appending a scenario to an existing
8
+ * `specification.yaml`.
9
+ *
10
+ * Two callers need this — `add-test` and capture promotion — and a second
11
+ * implementation is how they would drift into disagreeing about what a valid
12
+ * write is. Everything here is deliberately append-shaped: a spec is
13
+ * hand-authored and full of comments, and a round trip through
14
+ * `yaml.load`/`yaml.dump` would silently reformat it and drop every comment the
15
+ * author wrote. So the existing bytes are never re-serialized — the new block is
16
+ * concatenated onto them and the *result* is validated before anything is
17
+ * written.
18
+ */
19
+ /** Thrown when the spec on disk moved between the caller reading it and writing. */
20
+ export class ConcurrentSpecModification extends Error {
21
+ constructor(specPath) {
22
+ super(`${specPath} changed on disk since it was read — refusing to append. ` +
23
+ `Re-read the spec and retry; appending now would validate against a file that no longer exists.`);
24
+ this.name = "ConcurrentSpecModification";
25
+ }
26
+ }
27
+ /** Thrown when the scenario being appended collides with one already in the spec. */
28
+ export class DuplicateScenarioId extends Error {
29
+ constructor(id, specPath) {
30
+ super(`scenario id \`${id}\` already exists in ${specPath}`);
31
+ this.name = "DuplicateScenarioId";
32
+ }
33
+ }
34
+ /** SHA-256 of spec text. Callers hold one across a read→confirm→write cycle. */
35
+ export function specSha256(text) {
36
+ return createHash("sha256").update(text, "utf8").digest("hex");
37
+ }
38
+ /**
39
+ * Render one scenario as a YAML block that can be concatenated onto a spec.
40
+ *
41
+ * Dumps `{ scenarios: [scenario] }` and strips the top-level key, leaving the
42
+ * correctly-indented list item. Going through `yaml.dump` rather than string
43
+ * templating is what makes arbitrary user text — quotes, colons, newlines,
44
+ * leading dashes — safe to embed.
45
+ */
46
+ export function renderScenarioBlock(scenario) {
47
+ const dumped = yaml.dump({ scenarios: [scenario] }, { lineWidth: -1, noRefs: true });
48
+ return "\n" + dumped.replace(/^scenarios:\n/, "");
49
+ }
50
+ /**
51
+ * Validate and atomically append a scenario.
52
+ *
53
+ * Order matters and is load-bearing: read → detect concurrent modification →
54
+ * reject duplicate id → build → **validate the merged text** → write. The
55
+ * validation is on the merged result, not the block alone, because a block that
56
+ * parses in isolation can still break the file it lands in.
57
+ *
58
+ * The write is temp-file-plus-rename rather than `appendFileSync`. An append
59
+ * interrupted partway through leaves a syntactically broken spec on disk; a
60
+ * rename either happened or did not.
61
+ */
62
+ export function appendScenario(opts) {
63
+ const { specPath, scenario, baseSha256 } = opts;
64
+ const current = readFileSync(specPath, "utf8");
65
+ if (baseSha256 !== undefined && specSha256(current) !== baseSha256) {
66
+ throw new ConcurrentSpecModification(specPath);
67
+ }
68
+ const id = scenario.id;
69
+ if (typeof id !== "string" || id.trim() === "") {
70
+ throw new Error("scenario needs a non-empty string `id`");
71
+ }
72
+ const existing = parseSpec(current, specPath);
73
+ if (existing.scenarios.some((s) => s.id === id)) {
74
+ throw new DuplicateScenarioId(id, specPath);
75
+ }
76
+ const block = renderScenarioBlock(scenario);
77
+ const merged = current + block;
78
+ parseSpec(merged, specPath); // throws if the append broke the spec
79
+ atomicWrite(specPath, merged);
80
+ return { id, sha256: specSha256(merged), block };
81
+ }
82
+ /**
83
+ * Write via a sibling temp file and rename.
84
+ *
85
+ * Sibling, not `/tmp`: `rename(2)` is only atomic within a filesystem, and a
86
+ * cross-device rename would silently degrade to copy-then-delete — exactly the
87
+ * torn write this exists to prevent.
88
+ */
89
+ function atomicWrite(path, text) {
90
+ const tmp = join(dirname(path), `.${Date.now()}-${process.pid}.specwrite.tmp`);
91
+ try {
92
+ writeFileSync(tmp, text, "utf8");
93
+ renameSync(tmp, path);
94
+ }
95
+ catch (err) {
96
+ try {
97
+ unlinkSync(tmp);
98
+ }
99
+ catch {
100
+ // Best effort: the original file is untouched either way, and masking the
101
+ // real failure with a cleanup error would hide why the write failed.
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+ //# sourceMappingURL=spec-write.js.map
package/dist/spec.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { WorkspaceKind } from "./workspace.js";
2
+ import { type TraceAssert } from "./trace-gates.js";
2
3
  export type ScenarioMode = "inline" | "seeded";
3
4
  export interface SeededAssert {
4
5
  vitest?: boolean;
@@ -28,9 +29,37 @@ export interface Scenario {
28
29
  checklist: string[];
29
30
  fixture?: string;
30
31
  assert?: SeededAssert;
32
+ /**
33
+ * Objective assertions over the execution trace.
34
+ *
35
+ * Deliberately NOT part of `SeededAssert`: the other gates read a staged git
36
+ * diff and are meaningless without a fixture, while a trace exists for any run.
37
+ * Declaring it opts the scenario into structured (`--mode json`) execution.
38
+ */
39
+ traceAssert?: TraceAssert;
31
40
  workspace: WorkspaceKind;
32
41
  remote: boolean;
33
42
  systemPromptFile?: string;
43
+ /**
44
+ * `env.extensions`: pi extension files to load, resolved relative to the spec dir.
45
+ *
46
+ * Loading is CLOSED, not additive — the adapter passes `--no-extensions` plus one
47
+ * `--extension` per entry, so exactly these load and nothing discovered does.
48
+ * (Measured on pi 0.83.0: that flag pair isolates even under `-a` project-local
49
+ * trust.) Without it, whatever the developer happened to have installed would
50
+ * silently become part of the test.
51
+ */
52
+ extensions?: string[];
53
+ /**
54
+ * `covers`: instruction sections this scenario is declared to exercise, e.g.
55
+ * `SKILL.md#core-principle`.
56
+ *
57
+ * METADATA. It stales nothing — see `sources.ts`, where it is deliberately in
58
+ * no digest. A `covers` edit changes which tests `--affected` selects, not what
59
+ * any past run measured, so charging a re-run for it would be the exact
60
+ * "pay tokens to fix a label" trap the facet split exists to remove.
61
+ */
62
+ covers?: string[];
34
63
  reps?: number;
35
64
  passThreshold?: number;
36
65
  }
package/dist/spec.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import yaml from "js-yaml";
3
+ import { parseTraceAssert } from "./trace-gates.js";
3
4
  /** Thrown on any validation failure. Message always carries the spec file path. */
4
5
  export class SpecError extends Error {
5
6
  constructor(message, file) {
@@ -54,6 +55,34 @@ function resolveWorkspace(env, mode, fixture, id, file) {
54
55
  }
55
56
  throw new SpecError(`scenario \`${id}\` env.workspace must be none | empty-git | fixture:<path>`, file);
56
57
  }
58
+ /**
59
+ * Resolve `env.extensions` into a list of paths.
60
+ *
61
+ * Incompatible with `system_prompt_file` by construction: that flag REPLACES the
62
+ * system prompt to test a subagent definition in isolation, while an
63
+ * orchestration scenario tests the PARENT that delegates to one. Allowing both
64
+ * would silently test neither — the parent's instructions would be gone.
65
+ */
66
+ function resolveExtensions(env, hasSystemPrompt, id, file) {
67
+ const raw = env && typeof env === "object" ? env.extensions : undefined;
68
+ if (raw === undefined)
69
+ return undefined;
70
+ if (!Array.isArray(raw) || raw.length === 0) {
71
+ throw new SpecError(`scenario \`${id}\` env.extensions must be a non-empty list of paths`, file);
72
+ }
73
+ const paths = raw.map((p, i) => {
74
+ if (typeof p !== "string" || p.trim() === "") {
75
+ throw new SpecError(`scenario \`${id}\` env.extensions[${i}] must be a non-empty path`, file);
76
+ }
77
+ return p.trim();
78
+ });
79
+ if (hasSystemPrompt) {
80
+ throw new SpecError(`scenario \`${id}\` sets both env.extensions and system_prompt_file — ` +
81
+ `system_prompt_file replaces the system prompt to test a subagent in isolation, ` +
82
+ `while env.extensions tests the parent that delegates to one. Pick one.`, file);
83
+ }
84
+ return paths;
85
+ }
57
86
  /**
58
87
  * Resolve `env.remote`. A remote needs a repo to attach to, so it is only meaningful
59
88
  * with empty-git or a fixture — asking for one on a bare cwd is an authoring mistake,
@@ -144,6 +173,12 @@ export function parseSpec(text, file) {
144
173
  workspace: "none",
145
174
  remote: false,
146
175
  };
176
+ // `assert.trace` is legal for inline AND seeded scenarios — it reads the
177
+ // execution trace, which every run produces, not a staged diff.
178
+ const rawAssert = s.assert;
179
+ if (rawAssert?.trace !== undefined) {
180
+ scenario.traceAssert = parseTraceAssert(rawAssert.trace, `${file}: scenario \`${id}\``);
181
+ }
147
182
  if (mode === "seeded") {
148
183
  if (typeof s.fixture !== "string" || s.fixture.length === 0) {
149
184
  throw new SpecError(`seeded scenario \`${id}\` requires a \`fixture\` path`, file);
@@ -208,6 +243,26 @@ export function parseSpec(text, file) {
208
243
  }
209
244
  scenario.systemPromptFile = s.system_prompt_file.trim();
210
245
  }
246
+ if (s.covers !== undefined) {
247
+ if (!isStringArray(s.covers) || s.covers.length === 0) {
248
+ throw new SpecError(`scenario \`${id}\` \`covers\` must be a non-empty list of strings`, file);
249
+ }
250
+ const bad = s.covers.find((c) => c.trim() === "");
251
+ if (bad !== undefined)
252
+ throw new SpecError(`scenario \`${id}\` \`covers\` has an empty entry`, file);
253
+ scenario.covers = s.covers.map((c) => c.trim());
254
+ }
255
+ // `unchanged_paths` is checked against the workspace's git state, so a scenario
256
+ // with no repo has nothing to observe. Refused here — free and offline — rather
257
+ // than at run time, because the alternative shipped for a while: the assertion
258
+ // silently passed against an empty change list and reported a green safety gate.
259
+ if (scenario.traceAssert?.unchanged_paths?.length && scenario.workspace === "none") {
260
+ throw new SpecError(`scenario \`${id}\` declares \`assert.trace.unchanged_paths\` but has no workspace to observe — ` +
261
+ `set \`env.workspace: empty-git\` or \`fixture:<path>\`, or drop the assertion. ` +
262
+ `A path policy with nothing to compare against would pass unconditionally.`, file);
263
+ }
264
+ // After system_prompt_file, so the incompatibility check sees the resolved value.
265
+ scenario.extensions = resolveExtensions(s.env, scenario.systemPromptFile !== undefined, id, file);
211
266
  if (s.reps !== undefined) {
212
267
  if (typeof s.reps !== "number" || !Number.isInteger(s.reps) || s.reps < 1) {
213
268
  throw new SpecError(`scenario \`${id}\` \`reps\` must be a positive integer`, file);
@@ -0,0 +1,133 @@
1
+ import type { ExecutionTraceV1 } from "./capture-trace-types.js";
2
+ /**
3
+ * The objective gate layer: assertions evaluated against a saved execution
4
+ * trace, before any judge is asked anything.
5
+ *
6
+ * The DSL is deliberately tiny and entirely declarative — no expressions, no
7
+ * callbacks, no executable predicates. A spec is data that arrives from a
8
+ * repository; giving it a code path would make "add a test" and "run arbitrary
9
+ * code in CI" the same act. Everything here is a comparison between a value the
10
+ * trace recorded and a literal the spec wrote down.
11
+ *
12
+ * What these assertions can and cannot prove is a hard boundary, restated here
13
+ * because it is easy to over-claim: a trace proves **a registered tool was
14
+ * called with given arguments**. It proves nothing about what that tool then did
15
+ * to the machine. A `bash` command string is not a filesystem audit.
16
+ */
17
+ export interface ArgPredicate {
18
+ equals?: unknown;
19
+ contains?: string;
20
+ starts_with?: string;
21
+ ends_with?: string;
22
+ matches?: string;
23
+ exists?: boolean;
24
+ /** For array-valued arguments: at least one element satisfies the inner predicate. */
25
+ any?: ArgPredicate;
26
+ }
27
+ export declare const PREDICATE_KEYS: readonly ["equals", "contains", "starts_with", "ends_with", "matches", "exists", "any"];
28
+ export interface CountConstraint {
29
+ min?: number;
30
+ max?: number;
31
+ }
32
+ export interface RequireCall {
33
+ tool: string;
34
+ count?: CountConstraint;
35
+ args?: Record<string, ArgPredicate>;
36
+ }
37
+ export interface ForbidCall {
38
+ tool: string;
39
+ /** When present, only calls whose arguments match are forbidden. */
40
+ args?: Record<string, ArgPredicate>;
41
+ }
42
+ /**
43
+ * Convenience syntax for the orchestration case: "the parent delegated to
44
+ * `plan`, and the handoff carried X but not Y".
45
+ *
46
+ * Sugar over `require_calls`, not a second mechanism — it normalizes the known
47
+ * subagent argument shapes and then evaluates through the same path. There is
48
+ * deliberately no universal subagent extension assumed: an unknown extension can
49
+ * still be asserted on with plain `require_calls`, which is why this stays
50
+ * optional sugar rather than the only way in.
51
+ */
52
+ export interface RequireSubagent {
53
+ /** The registered tool name — declared by the spec, since pi has no standard one. */
54
+ tool: string;
55
+ /** Which subagent the parent should have selected. */
56
+ agent: string;
57
+ count?: CountConstraint;
58
+ /** Substrings the handoff MUST carry. */
59
+ task_contains?: string[];
60
+ /** Substrings the handoff must NOT carry — the leak check. */
61
+ task_excludes?: string[];
62
+ }
63
+ export interface TraceAssert {
64
+ require_calls?: RequireCall[];
65
+ require_subagents?: RequireSubagent[];
66
+ forbid_calls?: ForbidCall[];
67
+ unchanged_paths?: string[];
68
+ }
69
+ /**
70
+ * Subagent invocations extracted from one tool call.
71
+ *
72
+ * A single call can carry several: `{tasks: [...]}` fans out and `{chain: [...]}`
73
+ * sequences. Normalizing to a flat list means a `count` constraint means the same
74
+ * thing — how many subagent invocations happened — whichever shape the extension
75
+ * uses to express them.
76
+ */
77
+ export interface SubagentInvocation {
78
+ agent: string;
79
+ task: string;
80
+ }
81
+ /**
82
+ * Recognize the known subagent argument shapes.
83
+ *
84
+ * Three are supported because three exist in the wild; anything else yields an
85
+ * empty list, and the scenario should use plain `require_calls` instead. It
86
+ * deliberately does NOT guess: inventing an `agent` from an unrecognized shape
87
+ * would produce a confident assertion about a field nobody wrote.
88
+ */
89
+ export declare function normalizeSubagentCall(args: Record<string, unknown>): SubagentInvocation[];
90
+ /**
91
+ * ERROR is a third outcome, not a shade of FAIL: it means the assertion could
92
+ * not be evaluated because the evidence is absent. The two call for different
93
+ * fixes — a FAIL means change the skill, an ERROR means the harness could not
94
+ * look — and only one of them is a finding about the model.
95
+ */
96
+ export type AssertionStatus = "PASS" | "FAIL" | "ERROR";
97
+ export interface AssertionResult {
98
+ kind: "require_call" | "require_subagent" | "forbid_call" | "unchanged_path";
99
+ status: AssertionStatus;
100
+ detail: string;
101
+ }
102
+ export interface TraceGateResult {
103
+ status: AssertionStatus;
104
+ assertions: AssertionResult[];
105
+ }
106
+ /**
107
+ * Evaluate every assertion. All of them run even after the first failure — a
108
+ * scorecard that reports one problem per run makes the author re-run to find the
109
+ * second, and re-running is the expensive thing this whole layer exists to avoid.
110
+ *
111
+ * (One deliberate exception, marked inline in the `require_subagents` loop: the
112
+ * three sub-questions there are reported separately, and a later one is skipped
113
+ * when an earlier one already established there is nothing to ask it about.)
114
+ */
115
+ export declare function evaluateTraceGates(assert: TraceAssert, trace: ExecutionTraceV1): TraceGateResult;
116
+ export declare function testPredicate(value: unknown, p: ArgPredicate): boolean;
117
+ /**
118
+ * Minimal glob over workspace-relative paths: `**` any depth, `*` one segment.
119
+ *
120
+ * Paths are normalized to forward slashes and stripped of a leading `./` first,
121
+ * so `./src/a.ts` and `src/a.ts` are the same path — otherwise an assertion
122
+ * would pass or fail on how the runner happened to spell it.
123
+ */
124
+ export declare function matchesGlob(pattern: string, path: string): boolean;
125
+ /**
126
+ * Validate an `assert.trace` block from a spec.
127
+ *
128
+ * Strict on purpose: an unknown key is an error, not something ignored. A
129
+ * silently-ignored `forbid_call` (singular, say) would read in review as a gate
130
+ * that is protecting something while asserting nothing at all — the worst
131
+ * possible failure for a safety check.
132
+ */
133
+ export declare function parseTraceAssert(raw: unknown, ctx: string): TraceAssert;