@skill-harness/core 0.5.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.
- package/dist/adapters/types.d.ts +37 -0
- package/dist/adjudication.d.ts +210 -0
- package/dist/adjudication.js +392 -0
- package/dist/affected.d.ts +88 -0
- package/dist/affected.js +222 -0
- package/dist/capture-trace-types.d.ts +228 -0
- package/dist/capture-trace-types.js +23 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +344 -0
- package/dist/execution-trace.d.ts +61 -0
- package/dist/execution-trace.js +299 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/instruction-coverage.d.ts +106 -0
- package/dist/instruction-coverage.js +253 -0
- package/dist/journal.d.ts +17 -0
- package/dist/lint.d.ts +16 -1
- package/dist/lint.js +52 -0
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +48 -0
- package/dist/report.js +39 -1
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.d.ts +9 -1
- package/dist/run.js +129 -9
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.d.ts +26 -0
- package/dist/sources.js +82 -3
- package/dist/spec-write.d.ts +62 -0
- package/dist/spec-write.js +106 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +55 -0
- package/dist/stability.d.ts +144 -0
- package/dist/stability.js +232 -0
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/trends.d.ts +28 -0
- package/dist/trends.js +76 -61
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- package/package.json +1 -1
package/dist/seeded.js
CHANGED
|
@@ -160,13 +160,37 @@ export function capDiff(diff, maxBytes = DIFF_MAX_BYTES) {
|
|
|
160
160
|
*/
|
|
161
161
|
export async function runSeeded(scenario, opts) {
|
|
162
162
|
const repo = opts.cwd;
|
|
163
|
-
const
|
|
163
|
+
const req = {
|
|
164
164
|
skillDir: opts.skillDir,
|
|
165
165
|
model: opts.model,
|
|
166
166
|
mode: opts.mode,
|
|
167
167
|
turns: scenario.turns,
|
|
168
168
|
cwd: repo,
|
|
169
|
-
|
|
169
|
+
// Resolved against the spec dir, exactly like fixtures and post-tests.
|
|
170
|
+
extensions: scenario.extensions?.map((e) => resolve(opts.specDir, e)),
|
|
171
|
+
};
|
|
172
|
+
// A trace-gated seeded scenario runs through the structured path so the tool
|
|
173
|
+
// calls are recorded; everything downstream (gates, diff, transcript) is
|
|
174
|
+
// identical, because the rebuilt transcript is what print mode would have
|
|
175
|
+
// emitted anyway.
|
|
176
|
+
let traces = [];
|
|
177
|
+
let harnessOut;
|
|
178
|
+
if (opts.trace) {
|
|
179
|
+
if (!opts.adapter.runStructured) {
|
|
180
|
+
throw new Error(`scenario \`${opts.trace.scenarioId}\` declares \`assert.trace\`, but the \`${opts.adapter.name}\` adapter` +
|
|
181
|
+
` cannot produce execution traces — the gate would have no evidence to read.`);
|
|
182
|
+
}
|
|
183
|
+
const structured = await opts.adapter.runStructured({
|
|
184
|
+
...req,
|
|
185
|
+
scenarioId: opts.trace.scenarioId,
|
|
186
|
+
rep: opts.trace.rep,
|
|
187
|
+
});
|
|
188
|
+
harnessOut = structured.transcript;
|
|
189
|
+
traces = structured.traces;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
harnessOut = await opts.adapter.run(req);
|
|
193
|
+
}
|
|
170
194
|
const parts = [harnessOut, "", "=== SEEDED GATES ==="];
|
|
171
195
|
let gateFailure = null;
|
|
172
196
|
const runVitest = opts.runVitest ??
|
|
@@ -189,7 +213,7 @@ export async function runSeeded(scenario, opts) {
|
|
|
189
213
|
(gitFailure.stderr.trim() ? `: ${gitFailure.stderr.trim().split("\n")[0]}` : "");
|
|
190
214
|
parts.push(` staged diff: ERROR (${msg})`);
|
|
191
215
|
gateFailure = msg;
|
|
192
|
-
return finish(parts, gateFailure, diff);
|
|
216
|
+
return finish(parts, gateFailure, diff, traces);
|
|
193
217
|
}
|
|
194
218
|
// BOTH needle gates read the changed lines only, never context. A unified diff
|
|
195
219
|
// carries three lines of context per hunk, so an untouched symbol near the edit
|
|
@@ -257,7 +281,7 @@ export async function runSeeded(scenario, opts) {
|
|
|
257
281
|
parts.push(` post_test: ERROR (${msg})`);
|
|
258
282
|
if (!gateFailure)
|
|
259
283
|
gateFailure = msg;
|
|
260
|
-
return finish(parts, gateFailure, diff);
|
|
284
|
+
return finish(parts, gateFailure, diff, traces);
|
|
261
285
|
}
|
|
262
286
|
const v = await runVitest([POST_TEST_BASE], repo);
|
|
263
287
|
const out = `${v.stdout}\n${v.stderr}`;
|
|
@@ -306,7 +330,7 @@ export async function runSeeded(scenario, opts) {
|
|
|
306
330
|
gateFailure = problem;
|
|
307
331
|
}
|
|
308
332
|
}
|
|
309
|
-
return finish(parts, gateFailure, diff);
|
|
333
|
+
return finish(parts, gateFailure, diff, traces);
|
|
310
334
|
}
|
|
311
335
|
function git(cwd, args) {
|
|
312
336
|
return exec("git", args, { cwd, timeoutMs: 30_000 });
|
|
@@ -340,13 +364,13 @@ function bothStreams(v) {
|
|
|
340
364
|
return o || e;
|
|
341
365
|
}
|
|
342
366
|
/** Append the staged diff and return the outcome. Every exit path goes through here, so the judge always sees the same sections in the same order. */
|
|
343
|
-
function finish(parts, gateFailure, diff) {
|
|
367
|
+
function finish(parts, gateFailure, diff, traces = []) {
|
|
344
368
|
// The code itself, last — the gates above only prove that keywords appeared.
|
|
345
369
|
// Without this section a seeded checklist item about what the code *does* is
|
|
346
370
|
// graded from the model's own description of its work.
|
|
347
371
|
parts.push("", "=== STAGED DIFF ===");
|
|
348
372
|
parts.push(diff.trim() === "" ? " (empty — the model left no staged changes)" : capDiff(diff));
|
|
349
|
-
return { transcript: parts.join("\n"), gateFailure, diff };
|
|
373
|
+
return { transcript: parts.join("\n"), gateFailure, diff, traces };
|
|
350
374
|
}
|
|
351
375
|
/**
|
|
352
376
|
* Parse vitest's `Tests N passed | M skipped (T)` summary line.
|
package/dist/sources.d.ts
CHANGED
|
@@ -172,5 +172,31 @@ export declare function describeSourceKey(key: string): string;
|
|
|
172
172
|
* in place. Naming the actual remedy is what converts that into a free command.
|
|
173
173
|
*/
|
|
174
174
|
export declare function remedyForKey(key: string): string;
|
|
175
|
+
/** The skill-text key. Skill-wide: it belongs to every scenario at once. */
|
|
176
|
+
export declare const SKILL_KEY = "SKILL.md";
|
|
177
|
+
/**
|
|
178
|
+
* Every recorded key whose drift could change THIS scenario's verdict — excluding the
|
|
179
|
+
* two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
|
|
180
|
+
* separately because they move every scenario at once.
|
|
181
|
+
*
|
|
182
|
+
* Written for run-over-run comparison (see stability.ts): "did these two runs ask this
|
|
183
|
+
* scenario the same question, judged by the same rubric?" is answerable from the
|
|
184
|
+
* recorded hashes, and only if you know which keys belong to the scenario. Derived
|
|
185
|
+
* from the spec rather than from the key strings, because the path-shaped keys
|
|
186
|
+
* (`system_prompt_file`, `post_test`) carry no scenario id at all.
|
|
187
|
+
*
|
|
188
|
+
* `policy:<id>` is deliberately NOT here. Its `reps`/`pass_threshold` half is already
|
|
189
|
+
* compared as an *aggregation* shape (1 draw vs a majority of 3 is the comparison a
|
|
190
|
+
* hash cannot express), and its `critical` half changes whether a verdict can block a
|
|
191
|
+
* ship, never what the verdict is. Including it would report a critical-set edit as
|
|
192
|
+
* "these runs measured different things", which is false.
|
|
193
|
+
*
|
|
194
|
+
* Both key generations are returned: 0.4.0+ runs carry the split facet keys, older
|
|
195
|
+
* ones the combined `scenario:<id>`. A caller comparing two runs must not treat a
|
|
196
|
+
* combined digest and a split one as comparable — they hash different byte layouts —
|
|
197
|
+
* so it compares only keys BOTH runs recorded, and treats "no shared key" as
|
|
198
|
+
* unverifiable rather than unchanged.
|
|
199
|
+
*/
|
|
200
|
+
export declare function scenarioSourceKeys(s: Scenario): string[];
|
|
175
201
|
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
176
202
|
export declare function scenarioIdForKey(key: string, scenarios: Scenario[]): string | undefined;
|
package/dist/sources.js
CHANGED
|
@@ -167,24 +167,50 @@ function walk(dir, prefix = "") {
|
|
|
167
167
|
* transcripts are actually invalid.
|
|
168
168
|
*/
|
|
169
169
|
function facets(s) {
|
|
170
|
-
const { id, title, critical, mode, turns, checklist, fixture, assert, workspace, remote, systemPromptFile, reps, passThreshold, ...restScenario } = s;
|
|
170
|
+
const { id, title, critical, mode, turns, checklist, fixture, assert, traceAssert, workspace, remote, systemPromptFile, extensions, reps, passThreshold, covers: _coversIsMetadata, ...restScenario } = s;
|
|
171
171
|
const _scenarioExhaustive = restScenario;
|
|
172
172
|
void _scenarioExhaustive;
|
|
173
|
+
// `covers` is destructured into a discard on purpose, and this comment is the
|
|
174
|
+
// decision the guard demanded: it belongs to NO digest. It records which
|
|
175
|
+
// instruction sections a scenario is declared to exercise, which changes what
|
|
176
|
+
// `--affected` selects next time — not what any past run measured. Bucketing it
|
|
177
|
+
// anywhere would charge a re-run (or at best a re-score) for editing a label,
|
|
178
|
+
// which is the exact trap the facet split was built to remove.
|
|
179
|
+
void _coversIsMetadata;
|
|
173
180
|
const { vitest, diff_contains, diff_excludes, post_test, ...restAssert } = assert ?? {};
|
|
174
181
|
const _assertExhaustive = restAssert;
|
|
175
182
|
void _assertExhaustive;
|
|
176
|
-
|
|
183
|
+
// `traceAssert` is a GATE, not stimulus: it is evaluated against a trace the run
|
|
184
|
+
// already saved, so `regate` (free) can re-answer it without re-running the model.
|
|
185
|
+
// Note the asymmetry with `env.extensions` in Phase 3, which IS stimulus — one
|
|
186
|
+
// changes what gets executed, the other only what we conclude from it.
|
|
187
|
+
const hasGates = diff_contains !== undefined || diff_excludes !== undefined || traceAssert !== undefined;
|
|
177
188
|
return {
|
|
178
189
|
// `vitest` and the `post_test` PATH are stimulus, not gates: both change what the
|
|
179
190
|
// run executes in the workspace, and neither can be re-evaluated from a saved
|
|
180
191
|
// diff. (`post_test`'s CONTENTS get their own file-path key, hashed separately.)
|
|
192
|
+
// `extensions` is STIMULUS, not a gate — note the asymmetry with `traceAssert`
|
|
193
|
+
// below. Changing which extensions load changes what the model can DO, so the
|
|
194
|
+
// old transcripts describe a different agent and only a re-run can answer.
|
|
195
|
+
// Changing an assertion only changes what we conclude from evidence already on
|
|
196
|
+
// disk, which `regate` can redo for free.
|
|
197
|
+
// APPENDED CONDITIONALLY, never as a fixed slot. This tuple is positional and
|
|
198
|
+
// its hash is stored in every published results.yaml, so adding an
|
|
199
|
+
// unconditional element re-hashes every scenario that never used the field —
|
|
200
|
+
// measured: 62 real lint findings became 261 across the reference corpus, all of
|
|
201
|
+
// them demanding paid re-runs for scenarios nobody had edited.
|
|
181
202
|
stimulus: JSON.stringify([
|
|
182
203
|
id, mode, turns, workspace, remote, systemPromptFile ?? null,
|
|
183
204
|
fixture ?? null, vitest ?? null, post_test ?? null,
|
|
205
|
+
...(extensions ? [extensions] : []),
|
|
184
206
|
]),
|
|
185
207
|
rubric: JSON.stringify([id, title, checklist]),
|
|
186
208
|
policy: JSON.stringify([id, critical, reps ?? null, passThreshold ?? null]),
|
|
187
|
-
|
|
209
|
+
// Same rule as `stimulus` above: conditional, so a needle-gated scenario that
|
|
210
|
+
// declares no trace assertions keeps the digest it was published with.
|
|
211
|
+
gates: hasGates
|
|
212
|
+
? JSON.stringify([id, diff_contains ?? null, diff_excludes ?? null, ...(traceAssert ? [traceAssert] : [])])
|
|
213
|
+
: null,
|
|
188
214
|
};
|
|
189
215
|
}
|
|
190
216
|
function sha(canonical) {
|
|
@@ -281,6 +307,15 @@ export function sourceHashes(ctx) {
|
|
|
281
307
|
if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
|
|
282
308
|
hashes[s.systemPromptFile] = fileSha256(resolve(ctx.specDir, s.systemPromptFile)) ?? UNREADABLE;
|
|
283
309
|
}
|
|
310
|
+
// Extension CONTENTS, not just the paths the stimulus digest already covers.
|
|
311
|
+
// An orchestration scenario's subagent tool lives in these files: editing one
|
|
312
|
+
// changes what the model could do without changing a single character of the
|
|
313
|
+
// spec, which is precisely the drift the staleness gate exists to catch.
|
|
314
|
+
for (const ext of s.extensions ?? []) {
|
|
315
|
+
if (ext in hashes)
|
|
316
|
+
continue;
|
|
317
|
+
hashes[ext] = fileSha256(resolve(ctx.specDir, ext)) ?? UNREADABLE;
|
|
318
|
+
}
|
|
284
319
|
// The post-test IS the gate on a post_test scenario, and it lives outside the
|
|
285
320
|
// fixture tree by convention (`fixture: fixtures/A1`, `post_test: post/A1.test.ts`),
|
|
286
321
|
// so neither the fixture digest nor the scenario digest — which holds only the
|
|
@@ -390,6 +425,50 @@ export function remedyForKey(key) {
|
|
|
390
425
|
}
|
|
391
426
|
return "re-run"; // stimulus:, SKILL.md, fixture:, agent files, post_test contents
|
|
392
427
|
}
|
|
428
|
+
/** The skill-text key. Skill-wide: it belongs to every scenario at once. */
|
|
429
|
+
export const SKILL_KEY = "SKILL.md";
|
|
430
|
+
/**
|
|
431
|
+
* Every recorded key whose drift could change THIS scenario's verdict — excluding the
|
|
432
|
+
* two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
|
|
433
|
+
* separately because they move every scenario at once.
|
|
434
|
+
*
|
|
435
|
+
* Written for run-over-run comparison (see stability.ts): "did these two runs ask this
|
|
436
|
+
* scenario the same question, judged by the same rubric?" is answerable from the
|
|
437
|
+
* recorded hashes, and only if you know which keys belong to the scenario. Derived
|
|
438
|
+
* from the spec rather than from the key strings, because the path-shaped keys
|
|
439
|
+
* (`system_prompt_file`, `post_test`) carry no scenario id at all.
|
|
440
|
+
*
|
|
441
|
+
* `policy:<id>` is deliberately NOT here. Its `reps`/`pass_threshold` half is already
|
|
442
|
+
* compared as an *aggregation* shape (1 draw vs a majority of 3 is the comparison a
|
|
443
|
+
* hash cannot express), and its `critical` half changes whether a verdict can block a
|
|
444
|
+
* ship, never what the verdict is. Including it would report a critical-set edit as
|
|
445
|
+
* "these runs measured different things", which is false.
|
|
446
|
+
*
|
|
447
|
+
* Both key generations are returned: 0.4.0+ runs carry the split facet keys, older
|
|
448
|
+
* ones the combined `scenario:<id>`. A caller comparing two runs must not treat a
|
|
449
|
+
* combined digest and a split one as comparable — they hash different byte layouts —
|
|
450
|
+
* so it compares only keys BOTH runs recorded, and treats "no shared key" as
|
|
451
|
+
* unverifiable rather than unchanged.
|
|
452
|
+
*/
|
|
453
|
+
export function scenarioSourceKeys(s) {
|
|
454
|
+
const keys = [
|
|
455
|
+
STIMULUS_PREFIX + s.id,
|
|
456
|
+
RUBRIC_PREFIX + s.id,
|
|
457
|
+
SCENARIO_PREFIX + s.id, // legacy combined (pre-0.4.0 runs)
|
|
458
|
+
];
|
|
459
|
+
if (gatesDigest(s) !== null)
|
|
460
|
+
keys.push(GATES_PREFIX + s.id);
|
|
461
|
+
if (s.systemPromptFile)
|
|
462
|
+
keys.push(s.systemPromptFile); // the agent file IS the stimulus
|
|
463
|
+
for (const ext of s.extensions ?? [])
|
|
464
|
+
keys.push(ext); // an edited extension is new stimulus
|
|
465
|
+
if (s.assert?.post_test)
|
|
466
|
+
keys.push(s.assert.post_test); // its contents are the gate
|
|
467
|
+
const fx = effectiveFixture(s);
|
|
468
|
+
if (fx)
|
|
469
|
+
keys.push(FIXTURE_PREFIX + fx);
|
|
470
|
+
return keys;
|
|
471
|
+
}
|
|
393
472
|
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
394
473
|
export function scenarioIdForKey(key, scenarios) {
|
|
395
474
|
if (key === PERSONA_KEY)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single choke point for appending a scenario to an existing
|
|
3
|
+
* `specification.yaml`.
|
|
4
|
+
*
|
|
5
|
+
* Two callers need this — `add-test` and capture promotion — and a second
|
|
6
|
+
* implementation is how they would drift into disagreeing about what a valid
|
|
7
|
+
* write is. Everything here is deliberately append-shaped: a spec is
|
|
8
|
+
* hand-authored and full of comments, and a round trip through
|
|
9
|
+
* `yaml.load`/`yaml.dump` would silently reformat it and drop every comment the
|
|
10
|
+
* author wrote. So the existing bytes are never re-serialized — the new block is
|
|
11
|
+
* concatenated onto them and the *result* is validated before anything is
|
|
12
|
+
* written.
|
|
13
|
+
*/
|
|
14
|
+
/** Thrown when the spec on disk moved between the caller reading it and writing. */
|
|
15
|
+
export declare class ConcurrentSpecModification extends Error {
|
|
16
|
+
constructor(specPath: string);
|
|
17
|
+
}
|
|
18
|
+
/** Thrown when the scenario being appended collides with one already in the spec. */
|
|
19
|
+
export declare class DuplicateScenarioId extends Error {
|
|
20
|
+
constructor(id: string, specPath: string);
|
|
21
|
+
}
|
|
22
|
+
/** SHA-256 of spec text. Callers hold one across a read→confirm→write cycle. */
|
|
23
|
+
export declare function specSha256(text: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Render one scenario as a YAML block that can be concatenated onto a spec.
|
|
26
|
+
*
|
|
27
|
+
* Dumps `{ scenarios: [scenario] }` and strips the top-level key, leaving the
|
|
28
|
+
* correctly-indented list item. Going through `yaml.dump` rather than string
|
|
29
|
+
* templating is what makes arbitrary user text — quotes, colons, newlines,
|
|
30
|
+
* leading dashes — safe to embed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function renderScenarioBlock(scenario: Record<string, unknown>): string;
|
|
33
|
+
export interface AppendScenarioOptions {
|
|
34
|
+
specPath: string;
|
|
35
|
+
/** Plain object in spec field order; serialized by `renderScenarioBlock`. */
|
|
36
|
+
scenario: Record<string, unknown>;
|
|
37
|
+
/**
|
|
38
|
+
* SHA-256 the caller last saw. When supplied and the file no longer matches,
|
|
39
|
+
* the append is refused rather than layered onto someone else's edit.
|
|
40
|
+
*/
|
|
41
|
+
baseSha256?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface AppendScenarioResult {
|
|
44
|
+
id: string;
|
|
45
|
+
/** SHA-256 of the spec AFTER the append — the caller's new baseline. */
|
|
46
|
+
sha256: string;
|
|
47
|
+
/** The block that was appended, for preview/echo. */
|
|
48
|
+
block: string;
|
|
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 declare function appendScenario(opts: AppendScenarioOptions): AppendScenarioResult;
|
|
@@ -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);
|