@skill-harness/core 0.7.0 → 0.8.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/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export * from "./seeded.js";
14
14
  export * from "./report.js";
15
15
  export * from "./trends.js";
16
16
  export * from "./lint.js";
17
+ export * from "./restamp.js";
17
18
  export * from "./sources.js";
18
19
  export * from "./lift.js";
19
20
  export * from "./adapters/types.js";
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export * from "./seeded.js";
14
14
  export * from "./report.js";
15
15
  export * from "./trends.js";
16
16
  export * from "./lint.js";
17
+ export * from "./restamp.js";
17
18
  export * from "./sources.js";
18
19
  export * from "./lift.js";
19
20
  export * from "./adapters/types.js";
package/dist/lint.d.ts CHANGED
@@ -27,3 +27,10 @@ export declare function failsGate(f: LintFinding): boolean;
27
27
  * report every problem across every skill.
28
28
  */
29
29
  export declare function lintSkill(skillDir: string): LintFinding[];
30
+ /**
31
+ * All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
32
+ * Empty if none. Never throws: unreadable/dangling entries (e.g. a broken symlink, or a
33
+ * TOCTOU removal between readdir and statSync) are skipped rather than propagated, so a
34
+ * single bad entry can't abort lintSkill's "never throws" contract.
35
+ */
36
+ export declare function enumerateRunDirs(resultsRoot: string): string[];
package/dist/lint.js CHANGED
@@ -4,7 +4,7 @@ import yaml from "js-yaml";
4
4
  import { loadSpec, SpecError } from "./spec.js";
5
5
  import { computeCoverage } from "./instruction-coverage.js";
6
6
  import { readResults, finalizeResults, findTranscriptFiles, resultsPath, scoreContextFor } from "./results.js";
7
- import { currentHashFor, describeSourceKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
7
+ import { currentHashFor, describeSourceKey, isSupersededKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
8
8
  import { downgradeWarning } from "./downgrade.js";
9
9
  import { collectScoredRuns } from "./trends.js";
10
10
  import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
@@ -247,6 +247,12 @@ export function lintSkill(skillDir) {
247
247
  const newest = full.runDir;
248
248
  const ctx = { skillDir, specDir, scenarios: spec.scenarios, judgePersona: spec.judge_persona };
249
249
  for (const [key, recorded] of Object.entries(hashes)) {
250
+ // A raw-bytes key whose model-visible digest this run also recorded is not the
251
+ // honest comparison: it reports a `description`-reword or an `allowed-tools:`
252
+ // line — text no graded run could observe — as a reason to re-run. The pair is
253
+ // recorded together and exactly one of them is compared.
254
+ if (isSupersededKey(key, hashes))
255
+ continue;
250
256
  const what = describeSourceKey(key);
251
257
  const scenario = scenarioIdForKey(key, spec.scenarios);
252
258
  // The run itself failed to hash this source, so it was never verified and
@@ -349,7 +355,7 @@ function runDirsNewestFirst(tagDir) {
349
355
  * TOCTOU removal between readdir and statSync) are skipped rather than propagated, so a
350
356
  * single bad entry can't abort lintSkill's "never throws" contract.
351
357
  */
352
- function enumerateRunDirs(resultsRoot) {
358
+ export function enumerateRunDirs(resultsRoot) {
353
359
  if (!existsSync(resultsRoot))
354
360
  return [];
355
361
  const out = [];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Upgrade already-committed runs to the model-visible digests, without re-running anything.
3
+ *
4
+ * ## Why a command and not a rule inside lint
5
+ *
6
+ * A stored hash is one-way. A run recorded before 0.8.0 carries only sha256 of SKILL.md's
7
+ * RAW BYTES, so once that file has been edited there is nothing in the record — and
8
+ * nothing saved beside it; run dirs hold transcripts, not a copy of the skill — that can
9
+ * say whether the edit touched the body or only the frontmatter. Lint therefore cannot
10
+ * retroactively forgive a frontmatter-only edit on a legacy record, and pretending
11
+ * otherwise would mean dropping the check that catches real body edits across the whole
12
+ * published corpus.
13
+ *
14
+ * What CAN be established, and is the entire proof this command rests on: when the
15
+ * recorded raw-bytes hash still equals the file's current raw-bytes hash, that run
16
+ * measured the exact bytes on disk right now. The model-visible digest of those same
17
+ * bytes is then a true statement about what that run measured, and recording it is
18
+ * bookkeeping rather than a claim. Any record that fails the check is left untouched —
19
+ * it stays honestly stale, and `run` is the only thing that can clear it.
20
+ *
21
+ * So the migration is: upgrade while the board is green, and every later
22
+ * frontmatter-only edit is free. Skipping it costs nothing either — a legacy record with
23
+ * an unedited skill keeps matching on the raw-bytes key exactly as before.
24
+ *
25
+ * ## `--from <ref>`, for when the edit already landed
26
+ *
27
+ * Nobody discovers a gate over-fires until it has over-fired, so the green worktree the
28
+ * paragraph above assumes is usually already gone. The pre-edit bytes are still in git,
29
+ * and they carry the same proof in two steps: the recorded hash matches `<ref>`'s bytes
30
+ * (so the run measured THOSE), and `<ref>`'s model-visible digest equals today's (so the
31
+ * text the model receives has not moved since). Only then is the digest recorded.
32
+ *
33
+ * It is the same proof, not a weaker one — with no `--from`, "the ref" is just the
34
+ * working tree, which is why both go through one code path.
35
+ *
36
+ * Free and offline: no model, no judge, no network. It rewrites only `source_hashes`.
37
+ */
38
+ /**
39
+ * Three exclusive buckets that SUM to `runs`, plus `partial` as a subset of `upgraded`.
40
+ *
41
+ * Summing is the point. These counters are the only thing an operator can judge a
42
+ * migration by, and "18 upgraded, 20 left alone" out of 140 leaves 102 records
43
+ * unaccounted for — which reads like the command quietly skipped them rather than like
44
+ * the 102 having nothing to upgrade.
45
+ */
46
+ export interface RestampReport {
47
+ /** Run records examined. `upgraded + unprovable + unchanged`. */
48
+ runs: number;
49
+ /** Records that gained at least one model-visible digest, and were rewritten. */
50
+ upgraded: number;
51
+ /**
52
+ * Records that gained NOTHING because a document they measured has already moved —
53
+ * honestly stale, and `run` is the only remedy.
54
+ */
55
+ unprovable: number;
56
+ /** Records with nothing to do: no `source_hashes`, unreadable, or already upgraded. */
57
+ unchanged: number;
58
+ /** Of `upgraded`, those still carrying a document that could not be proven. */
59
+ partial: number;
60
+ /** `<runDir>: <key>` for each digest added, for the CLI to print. */
61
+ added: string[];
62
+ }
63
+ export interface RestampOptions {
64
+ /** Git ref holding the bytes the runs measured, for a tree that has already been edited. */
65
+ from?: string;
66
+ }
67
+ /** Upgrade every provable record under one skill's tests/results. */
68
+ export declare function restampSkill(skillDir: string, opts?: RestampOptions): RestampReport;
@@ -0,0 +1,194 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import yaml from "js-yaml";
6
+ import { loadSpec } from "./spec.js";
7
+ import { resultsPath } from "./results.js";
8
+ import { enumerateRunDirs } from "./lint.js";
9
+ import { promptDocDigest, SKILL_KEY, SKILL_PROMPT_KEY, PROMPT_PREFIX, UNREADABLE, } from "./sources.js";
10
+ /**
11
+ * The `source_hashes` map of a raw-loaded results doc, or null when the doc has none.
12
+ * Read from the RAW yaml rather than through `readResults` on purpose: this rewrites the
13
+ * file, and `readResults` migrates schema 1 → 2 in memory, so writing its output back
14
+ * would silently upgrade the schema of records nobody asked to touch.
15
+ */
16
+ function rawHashes(doc) {
17
+ if (!doc || typeof doc !== "object")
18
+ return null;
19
+ const h = doc.source_hashes;
20
+ if (!h || typeof h !== "object" || Array.isArray(h))
21
+ return null;
22
+ return h;
23
+ }
24
+ /**
25
+ * `git show <ref>:<path>` as BYTES, or null when the ref, the repo, or the path isn't there.
26
+ *
27
+ * `execFileSync` with a buffer encoding rather than the shared `exec` helper: that one
28
+ * accumulates stdout as decoded strings, so a multi-byte character landing on a chunk
29
+ * boundary would come back with a replacement character and hash to something the run
30
+ * never recorded. This comparison is byte-exact by definition, so it reads bytes.
31
+ */
32
+ function bytesAtRef(abs, ref) {
33
+ const cwd = dirname(abs);
34
+ try {
35
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
36
+ const rel = relative(top, abs).split("\\").join("/");
37
+ return execFileSync("git", ["show", `${ref}:${rel}`], { cwd, maxBuffer: 64 * 1024 * 1024 });
38
+ }
39
+ catch {
40
+ return null; // no git, no repo, no such ref, or the file did not exist there
41
+ }
42
+ }
43
+ /**
44
+ * The bytes a run is claimed to have measured: `<ref>`'s copy, or the working tree's when
45
+ * no ref was given. Null when they cannot be read at all, which proves nothing and so
46
+ * upgrades nothing.
47
+ */
48
+ function measuredBytes(abs, from) {
49
+ if (from !== undefined)
50
+ return bytesAtRef(abs, from);
51
+ try {
52
+ return readFileSync(abs);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Fail loudly when `--from` names something git cannot resolve.
60
+ *
61
+ * Without this the whole migration degrades into "0 upgraded, everything left alone",
62
+ * which is indistinguishable from a corpus that genuinely cannot be proven — measured on
63
+ * a clone whose only local branch was the feature branch, where `--from main` silently
64
+ * did nothing and read as the tool being broken. A ref the operator got wrong is their
65
+ * mistake to see, not one to absorb.
66
+ */
67
+ function assertRefResolves(dir, ref) {
68
+ try {
69
+ execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: dir, stdio: "ignore" });
70
+ }
71
+ catch {
72
+ throw new Error(`--from \`${ref}\` does not resolve to a commit in the repo holding ${dir} — ` +
73
+ `check the ref exists locally (a fresh clone often has no local \`main\`; try \`origin/main\`)`);
74
+ }
75
+ }
76
+ /**
77
+ * Write via a temp file in the same directory, then rename.
78
+ *
79
+ * `rename(2)` is atomic within a filesystem, so a reader sees the old file or the new one
80
+ * and never a half-written one. It matters more here than in the writers that produce a
81
+ * run: this loops over an entire published corpus rewriting records that already hold
82
+ * measurements nobody can reproduce without spending model tokens, so an interrupt or a
83
+ * full disk partway through would truncate exactly the artifacts the command exists to
84
+ * preserve. The temp file is `.tmp`-suffixed beside the target rather than in the system
85
+ * temp dir, because a cross-device rename is not atomic — and is not even permitted.
86
+ */
87
+ function writeAtomically(path, text) {
88
+ const tmp = `${path}.restamp.tmp`;
89
+ writeFileSync(tmp, text, "utf8");
90
+ try {
91
+ renameSync(tmp, path);
92
+ }
93
+ catch (e) {
94
+ try {
95
+ rmSync(tmp, { force: true });
96
+ }
97
+ catch { /* the rename failure is what matters */ }
98
+ throw e;
99
+ }
100
+ }
101
+ /** Upgrade every provable record under one skill's tests/results. */
102
+ export function restampSkill(skillDir, opts = {}) {
103
+ const report = { runs: 0, upgraded: 0, unprovable: 0, unchanged: 0, partial: 0, added: [] };
104
+ const specPath = join(skillDir, "tests", "specification.yaml");
105
+ const spec = loadSpec(specPath);
106
+ const specDir = dirname(specPath);
107
+ // Only `system_prompt_file` paths, resolved from the CURRENT spec. The other
108
+ // path-shaped keys (`post_test`, `env.extensions`) name code, not prompt documents:
109
+ // every byte of those reaches the run, so they keep hashing raw and are left alone.
110
+ const promptDocs = new Map([[SKILL_KEY, resolve(skillDir, "SKILL.md")]]);
111
+ for (const s of spec.scenarios) {
112
+ if (s.systemPromptFile)
113
+ promptDocs.set(s.systemPromptFile, resolve(specDir, s.systemPromptFile));
114
+ }
115
+ if (opts.from !== undefined)
116
+ assertRefResolves(skillDir, opts.from);
117
+ // Resolved once per document, not once per run: with `--from`, each resolution is two
118
+ // git invocations, and a corpus of 140 runs would otherwise pay for them 140 times over.
119
+ const provable = new Map();
120
+ for (const [legacyKey, abs] of promptDocs) {
121
+ const measured = measuredBytes(abs, opts.from);
122
+ let currentPrompt = null;
123
+ try {
124
+ currentPrompt = promptDocDigest(readFileSync(abs, "utf8"));
125
+ }
126
+ catch {
127
+ currentPrompt = null;
128
+ }
129
+ provable.set(legacyKey, {
130
+ measuredRaw: measured === null ? null : createHash("sha256").update(measured).digest("hex"),
131
+ measuredPrompt: measured === null ? null : promptDocDigest(measured.toString("utf8")),
132
+ currentPrompt,
133
+ });
134
+ }
135
+ for (const runDir of enumerateRunDirs(join(skillDir, "tests", "results"))) {
136
+ report.runs++;
137
+ let doc;
138
+ try {
139
+ doc = yaml.load(readFileSync(resultsPath(runDir), "utf8"));
140
+ }
141
+ catch {
142
+ // Unreadable/malformed → lint already reports it; never rewrite what we can't read.
143
+ // Still bucketed: every record counted in `runs` has to be accounted for somewhere.
144
+ report.unchanged++;
145
+ continue;
146
+ }
147
+ const hashes = rawHashes(doc);
148
+ if (!hashes) {
149
+ report.unchanged++; // predates source_hashes → nothing to upgrade from
150
+ continue;
151
+ }
152
+ let blocked = false;
153
+ const additions = [];
154
+ for (const legacyKey of promptDocs.keys()) {
155
+ const recorded = hashes[legacyKey];
156
+ if (recorded === undefined || recorded === UNREADABLE)
157
+ continue; // never measured → nothing proven
158
+ const upgradedKey = legacyKey === SKILL_KEY ? SKILL_PROMPT_KEY : PROMPT_PREFIX + legacyKey;
159
+ if (hashes[upgradedKey] !== undefined)
160
+ continue; // already upgraded
161
+ const p = provable.get(legacyKey);
162
+ if (p.currentPrompt === null)
163
+ continue; // the file is gone; lint reports that itself
164
+ // Two steps, and both are required. First: this run measured the reference bytes —
165
+ // otherwise the reference says nothing about THIS run. Second: those bytes and
166
+ // today's carry the same model-visible text — otherwise what the model reads has
167
+ // genuinely moved and only a re-run can speak for it.
168
+ if (p.measuredRaw === null || recorded !== p.measuredRaw || p.measuredPrompt !== p.currentPrompt) {
169
+ blocked = true;
170
+ continue;
171
+ }
172
+ additions.push([upgradedKey, p.currentPrompt]);
173
+ }
174
+ if (additions.length === 0) {
175
+ // Exclusive: a record that gained nothing is `unprovable` only if something
176
+ // actually blocked it, and `unchanged` otherwise.
177
+ if (blocked)
178
+ report.unprovable++;
179
+ else
180
+ report.unchanged++;
181
+ continue;
182
+ }
183
+ for (const [k, v] of additions) {
184
+ hashes[k] = v;
185
+ report.added.push(`${runDir}: ${k}`);
186
+ }
187
+ writeAtomically(resultsPath(runDir), yaml.dump(doc, { lineWidth: 100 }));
188
+ report.upgraded++;
189
+ if (blocked)
190
+ report.partial++;
191
+ }
192
+ return report;
193
+ }
194
+ //# sourceMappingURL=restamp.js.map
package/dist/sources.d.ts CHANGED
@@ -174,6 +174,56 @@ export declare function describeSourceKey(key: string): string;
174
174
  export declare function remedyForKey(key: string): string;
175
175
  /** The skill-text key. Skill-wide: it belongs to every scenario at once. */
176
176
  export declare const SKILL_KEY = "SKILL.md";
177
+ /**
178
+ * The model-visible digests of the two prompt documents a run delivers: the skill text
179
+ * and any `system_prompt_file`. They measure what the model RECEIVES, where the bare
180
+ * keys above (`SKILL.md`, `<agent path>`) measure the file's raw bytes.
181
+ *
182
+ * | key | contents |
183
+ * |---|---|
184
+ * | `skill:prompt` | SKILL.md's body + its model-visible frontmatter |
185
+ * | `prompt:<path>` | the same, for one `system_prompt_file` |
186
+ *
187
+ * Both are prefixed, so a skill-harness OLDER than this one reads them through
188
+ * `currentHashFor`'s unknown-prefix guard and reports "not comparable" rather than a
189
+ * confident wrong finding about a source that is fine.
190
+ */
191
+ export declare const SKILL_PROMPT_KEY = "skill:prompt";
192
+ export declare const PROMPT_PREFIX = "prompt:";
193
+ /**
194
+ * The digest of a prompt document: its body verbatim, plus every frontmatter field the
195
+ * model can actually receive.
196
+ *
197
+ * The gate exists to protect published claims, so "the current text" has to mean the text
198
+ * the model was given. Hashing the raw bytes — which is what shipped through 0.7.0 —
199
+ * charged a full re-run for edits no graded run could observe: measured on the reference
200
+ * corpus, adding one `allowed-tools:` key to seven SKILL.md files with byte-identical
201
+ * bodies took lint from 2 findings to 22, and 20 of those demanded paid re-waves.
202
+ *
203
+ * Versioned in the hashed tuple (`prompt-doc/1`) because this is a stored-hash format: if
204
+ * the rule for "model-visible" ever changes, the tag changes with it and old digests stay
205
+ * legible as what they were.
206
+ */
207
+ export declare function promptDocDigest(text: string): string;
208
+ /** `promptDocDigest` of a file, or null when it doesn't exist / isn't readable. */
209
+ export declare function promptDocDigestOfFile(path: string): string | null;
210
+ /**
211
+ * True when `key` is a raw-bytes key whose model-visible counterpart THIS RUN also
212
+ * recorded — in which case the counterpart is the honest comparison and the raw-bytes one
213
+ * must be skipped.
214
+ *
215
+ * Both keys keep being written, and this is why: an older skill-harness reading a newer
216
+ * results.yaml still finds the bare `SKILL.md` key and keeps checking it, and `restamp`
217
+ * needs the raw-bytes hash as the proof it upgrades from. Superseding at COMPARISON time
218
+ * rather than dropping the key at record time is what buys both.
219
+ *
220
+ * `UNREADABLE` on EITHER side supersedes nothing. If the counterpart is unreadable there
221
+ * is nothing better to compare against; and if the raw-bytes key itself is unreadable, the
222
+ * run never verified that document at all — a fact lint must keep reporting, since the
223
+ * one thing this module refuses to do is let an unhashed source drop out of checking for
224
+ * the life of a result.
225
+ */
226
+ export declare function isSupersededKey(key: string, recorded: Record<string, string>): boolean;
177
227
  /**
178
228
  * Every recorded key whose drift could change THIS scenario's verdict — excluding the
179
229
  * two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
package/dist/sources.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync, readdirSync } from "node:fs";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
+ import yaml from "js-yaml";
4
5
  /**
5
6
  * What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
6
7
  * lint can prove a published result still describes the current inputs.
@@ -292,7 +293,12 @@ export function sourceHashes(ctx) {
292
293
  const hashes = {};
293
294
  // UNREADABLE rather than omission on every branch below: a source we failed to
294
295
  // hash must stay visible to lint, not vanish from the record. See UNREADABLE.
295
- hashes["SKILL.md"] = fileSha256(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
296
+ // Two digests of one file, and both are load-bearing. `SKILL.md` is the raw bytes,
297
+ // kept so an older skill-harness reading this record still has the key it knows and so
298
+ // `restamp` has a proof to upgrade from; `skill:prompt` is the text the model actually
299
+ // receives, and is the one lint compares (see `isSupersededKey`).
300
+ hashes[SKILL_KEY] = fileSha256(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
301
+ hashes[SKILL_PROMPT_KEY] = promptDocDigestOfFile(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
296
302
  hashes[PERSONA_KEY] = personaDigest(ctx.judgePersona);
297
303
  for (const s of ctx.scenarios) {
298
304
  // Split, not combined: each facet's drift has a different cheapest remedy, and a
@@ -304,8 +310,13 @@ export function sourceHashes(ctx) {
304
310
  const gates = gatesDigest(s);
305
311
  if (gates !== null)
306
312
  hashes[GATES_PREFIX + s.id] = gates;
313
+ // An agent file is a prompt document too, delivered with `--append-system-prompt`,
314
+ // and its `tools:` header is read by the harness rather than the model — so it gets
315
+ // the same pair of digests, for the same reasons, as SKILL.md above.
307
316
  if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
308
- hashes[s.systemPromptFile] = fileSha256(resolve(ctx.specDir, s.systemPromptFile)) ?? UNREADABLE;
317
+ const abs = resolve(ctx.specDir, s.systemPromptFile);
318
+ hashes[s.systemPromptFile] = fileSha256(abs) ?? UNREADABLE;
319
+ hashes[PROMPT_PREFIX + s.systemPromptFile] = promptDocDigestOfFile(abs) ?? UNREADABLE;
309
320
  }
310
321
  // Extension CONTENTS, not just the paths the stimulus digest already covers.
311
322
  // An orchestration scenario's subagent tool lives in these files: editing one
@@ -344,8 +355,16 @@ export function sourceHashes(ctx) {
344
355
  * from drifting: a new key kind is defined once, for both sides.
345
356
  */
346
357
  export function currentHashFor(key, ctx) {
347
- if (key === "SKILL.md")
358
+ if (key === SKILL_KEY)
348
359
  return fileSha256(resolve(ctx.skillDir, "SKILL.md"));
360
+ if (key === SKILL_PROMPT_KEY)
361
+ return promptDocDigestOfFile(resolve(ctx.skillDir, "SKILL.md"));
362
+ // Before the unknown-prefix guard below, which would otherwise swallow these as keys
363
+ // from a newer skill-harness. A deleted agent file must still resolve to null ("gone"),
364
+ // not undefined ("not comparable").
365
+ if (key.startsWith(PROMPT_PREFIX)) {
366
+ return promptDocDigestOfFile(resolve(ctx.specDir, key.slice(PROMPT_PREFIX.length)));
367
+ }
349
368
  if (key === PERSONA_KEY)
350
369
  return personaDigest(ctx.judgePersona);
351
370
  // Facet keys, and the legacy combined key, all resolve per scenario id. A removed
@@ -383,6 +402,13 @@ export function currentHashFor(key, ctx) {
383
402
  export function describeSourceKey(key) {
384
403
  if (key === PERSONA_KEY)
385
404
  return "the judge persona";
405
+ // Deliberately the same label as the raw-bytes key: only one of the pair is ever
406
+ // compared (see `isSupersededKey`), and a reader of a lint finding cares which FILE
407
+ // moved, not which of two digests of it was the one consulted.
408
+ if (key === SKILL_PROMPT_KEY)
409
+ return SKILL_KEY;
410
+ if (key.startsWith(PROMPT_PREFIX))
411
+ return key.slice(PROMPT_PREFIX.length);
386
412
  if (key.startsWith(STIMULUS_PREFIX))
387
413
  return `the stimulus for \`${key.slice(STIMULUS_PREFIX.length)}\``;
388
414
  if (key.startsWith(RUBRIC_PREFIX))
@@ -427,6 +453,149 @@ export function remedyForKey(key) {
427
453
  }
428
454
  /** The skill-text key. Skill-wide: it belongs to every scenario at once. */
429
455
  export const SKILL_KEY = "SKILL.md";
456
+ /**
457
+ * The model-visible digests of the two prompt documents a run delivers: the skill text
458
+ * and any `system_prompt_file`. They measure what the model RECEIVES, where the bare
459
+ * keys above (`SKILL.md`, `<agent path>`) measure the file's raw bytes.
460
+ *
461
+ * | key | contents |
462
+ * |---|---|
463
+ * | `skill:prompt` | SKILL.md's body + its model-visible frontmatter |
464
+ * | `prompt:<path>` | the same, for one `system_prompt_file` |
465
+ *
466
+ * Both are prefixed, so a skill-harness OLDER than this one reads them through
467
+ * `currentHashFor`'s unknown-prefix guard and reports "not comparable" rather than a
468
+ * confident wrong finding about a source that is fine.
469
+ */
470
+ export const SKILL_PROMPT_KEY = "skill:prompt";
471
+ export const PROMPT_PREFIX = "prompt:";
472
+ /**
473
+ * Frontmatter keys the model never receives: capability declarations the HARNESS
474
+ * consumes to build a tool allowlist.
475
+ *
476
+ * Why a denylist and not an allowlist of "body + description". An allowlist makes the
477
+ * safe-looking choice the dangerous one: the day a harness starts putting some new
478
+ * frontmatter key in context, every published number silently stops being protected and
479
+ * nothing says so. A denylist fails the other way — a newly-invented inert key charges
480
+ * one unnecessary re-run until it is named here, which is a one-line patch and a visible
481
+ * complaint. This module already takes that side everywhere else (see `UNREADABLE`, and
482
+ * the unknown-prefix guard in `currentHashFor`): a blind spot is the worst outcome, and
483
+ * a false alarm is merely expensive.
484
+ *
485
+ * `tools` and `allowed-tools` are the two spellings of the same ceiling in the reference
486
+ * corpus. Both are read by pi/pi-daddy to build a `--tools` allowlist; neither is ever
487
+ * rendered into the model's context.
488
+ */
489
+ const CAPABILITY_KEYS = new Set(["allowed-tools", "tools"]);
490
+ /** Opening `---` on line 1 only — a `---` further down is a horizontal rule. */
491
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
492
+ /** A prompt document split into its frontmatter text (null when it has none) and its body. */
493
+ function splitPromptDoc(text) {
494
+ const m = FRONTMATTER_RE.exec(text);
495
+ // Unterminated frontmatter falls through to "all body", the same call
496
+ // instruction-coverage.ts makes: treat the whole file as content rather than lose it.
497
+ return m ? { frontmatter: m[1], body: text.slice(m[0].length) } : { frontmatter: null, body: text };
498
+ }
499
+ /**
500
+ * Parsed YAML in a stable shape: object keys sorted, string scalars trimmed.
501
+ *
502
+ * Trimmed because YAML block style is not stimulus — `description: >` clips a trailing
503
+ * newline that `description: <text>` does not, so an author refolding a paragraph the
504
+ * model receives identically would otherwise be charged a re-run.
505
+ */
506
+ function canonicalValue(v) {
507
+ if (typeof v === "string")
508
+ return v.trim();
509
+ if (Array.isArray(v))
510
+ return v.map(canonicalValue);
511
+ if (v && typeof v === "object") {
512
+ return Object.entries(v)
513
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
514
+ .map(([k, val]) => [k, canonicalValue(val)]);
515
+ }
516
+ return v;
517
+ }
518
+ /**
519
+ * The frontmatter a run can observe, canonically. Built from the PARSED YAML for the
520
+ * same reason the scenario digests are built from the parsed scenario: reindenting or
521
+ * reordering keys changes no stimulus and must not read as a change.
522
+ *
523
+ * Unparseable frontmatter is hashed VERBATIM rather than dropped. Dropping it would mean
524
+ * a file whose frontmatter is malformed quietly loses that half of its protection — and
525
+ * a malformed header is exactly when an author is most likely to be mid-edit.
526
+ */
527
+ function modelVisibleFrontmatter(fm) {
528
+ if (fm === null)
529
+ return null;
530
+ let parsed;
531
+ try {
532
+ parsed = yaml.load(fm);
533
+ }
534
+ catch {
535
+ return ["unparsed", fm];
536
+ }
537
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
538
+ return ["unparsed", fm];
539
+ return [
540
+ "parsed",
541
+ Object.entries(parsed)
542
+ .filter(([k]) => !CAPABILITY_KEYS.has(k))
543
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
544
+ .map(([k, v]) => [k, canonicalValue(v)]),
545
+ ];
546
+ }
547
+ /**
548
+ * The digest of a prompt document: its body verbatim, plus every frontmatter field the
549
+ * model can actually receive.
550
+ *
551
+ * The gate exists to protect published claims, so "the current text" has to mean the text
552
+ * the model was given. Hashing the raw bytes — which is what shipped through 0.7.0 —
553
+ * charged a full re-run for edits no graded run could observe: measured on the reference
554
+ * corpus, adding one `allowed-tools:` key to seven SKILL.md files with byte-identical
555
+ * bodies took lint from 2 findings to 22, and 20 of those demanded paid re-waves.
556
+ *
557
+ * Versioned in the hashed tuple (`prompt-doc/1`) because this is a stored-hash format: if
558
+ * the rule for "model-visible" ever changes, the tag changes with it and old digests stay
559
+ * legible as what they were.
560
+ */
561
+ export function promptDocDigest(text) {
562
+ const { frontmatter, body } = splitPromptDoc(text);
563
+ return sha(JSON.stringify(["prompt-doc/1", modelVisibleFrontmatter(frontmatter), body]));
564
+ }
565
+ /** `promptDocDigest` of a file, or null when it doesn't exist / isn't readable. */
566
+ export function promptDocDigestOfFile(path) {
567
+ try {
568
+ return promptDocDigest(readFileSync(path, "utf8"));
569
+ }
570
+ catch {
571
+ return null;
572
+ }
573
+ }
574
+ /**
575
+ * True when `key` is a raw-bytes key whose model-visible counterpart THIS RUN also
576
+ * recorded — in which case the counterpart is the honest comparison and the raw-bytes one
577
+ * must be skipped.
578
+ *
579
+ * Both keys keep being written, and this is why: an older skill-harness reading a newer
580
+ * results.yaml still finds the bare `SKILL.md` key and keeps checking it, and `restamp`
581
+ * needs the raw-bytes hash as the proof it upgrades from. Superseding at COMPARISON time
582
+ * rather than dropping the key at record time is what buys both.
583
+ *
584
+ * `UNREADABLE` on EITHER side supersedes nothing. If the counterpart is unreadable there
585
+ * is nothing better to compare against; and if the raw-bytes key itself is unreadable, the
586
+ * run never verified that document at all — a fact lint must keep reporting, since the
587
+ * one thing this module refuses to do is let an unhashed source drop out of checking for
588
+ * the life of a result.
589
+ */
590
+ export function isSupersededKey(key, recorded) {
591
+ if (key === SKILL_PROMPT_KEY || key.startsWith(PROMPT_PREFIX))
592
+ return false;
593
+ if (recorded[key] === UNREADABLE)
594
+ return false;
595
+ const upgraded = key === SKILL_KEY ? SKILL_PROMPT_KEY : PROMPT_PREFIX + key;
596
+ const v = recorded[upgraded];
597
+ return v !== undefined && v !== UNREADABLE;
598
+ }
430
599
  /**
431
600
  * Every recorded key whose drift could change THIS scenario's verdict — excluding the
432
601
  * two skill-wide ones (`SKILL.md`, `rubric:__persona`), which callers handle
@@ -458,8 +627,10 @@ export function scenarioSourceKeys(s) {
458
627
  ];
459
628
  if (gatesDigest(s) !== null)
460
629
  keys.push(GATES_PREFIX + s.id);
461
- if (s.systemPromptFile)
630
+ if (s.systemPromptFile) {
462
631
  keys.push(s.systemPromptFile); // the agent file IS the stimulus
632
+ keys.push(PROMPT_PREFIX + s.systemPromptFile); // ...as the model receives it
633
+ }
463
634
  for (const ext of s.extensions ?? [])
464
635
  keys.push(ext); // an edited extension is new stimulus
465
636
  if (s.assert?.post_test)
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" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",