@skill-harness/core 0.29.0 → 0.31.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 (59) hide show
  1. package/README.md +16 -2
  2. package/dist/adapters/types.d.ts +2 -0
  3. package/dist/authoring-preflight.d.ts +22 -0
  4. package/dist/authoring-preflight.js +111 -0
  5. package/dist/authoring.d.ts +96 -0
  6. package/dist/authoring.js +451 -0
  7. package/dist/calibration.d.ts +138 -0
  8. package/dist/calibration.js +352 -0
  9. package/dist/canary.d.ts +28 -26
  10. package/dist/canary.js +109 -92
  11. package/dist/command-cost.d.ts +1 -1
  12. package/dist/command-cost.js +2 -0
  13. package/dist/diagnose.d.ts +17 -0
  14. package/dist/diagnose.js +70 -0
  15. package/dist/execution-trace.d.ts +2 -1
  16. package/dist/execution-trace.js +4 -3
  17. package/dist/grade.d.ts +8 -0
  18. package/dist/grade.js +37 -8
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +8 -0
  21. package/dist/journal.d.ts +3 -0
  22. package/dist/judge-evidence.d.ts +40 -0
  23. package/dist/judge-evidence.js +120 -0
  24. package/dist/judge-references.d.ts +8 -0
  25. package/dist/judge-references.js +34 -0
  26. package/dist/lint.d.ts +1 -1
  27. package/dist/lint.js +22 -0
  28. package/dist/regate.js +21 -3
  29. package/dist/regrade.d.ts +10 -1
  30. package/dist/regrade.js +146 -75
  31. package/dist/rescore.js +1 -0
  32. package/dist/resource-monitor.d.ts +3 -0
  33. package/dist/resource-monitor.js +72 -0
  34. package/dist/results.d.ts +16 -4
  35. package/dist/results.js +10 -0
  36. package/dist/run.d.ts +2 -2
  37. package/dist/run.js +147 -116
  38. package/dist/saved-run.d.ts +15 -0
  39. package/dist/saved-run.js +114 -0
  40. package/dist/scheduler.d.ts +3 -5
  41. package/dist/scheduler.js +18 -6
  42. package/dist/seeded.js +1 -0
  43. package/dist/sources.d.ts +4 -1
  44. package/dist/sources.js +44 -40
  45. package/dist/spec.d.ts +8 -0
  46. package/dist/spec.js +27 -0
  47. package/dist/trace-gates.d.ts +2 -0
  48. package/dist/trace-gates.js +13 -3
  49. package/dist/util/exec.d.ts +5 -0
  50. package/dist/util/exec.js +16 -32
  51. package/dist/util/fixture-inventory.d.ts +19 -0
  52. package/dist/util/fixture-inventory.js +68 -0
  53. package/dist/util/process.d.ts +78 -0
  54. package/dist/util/process.js +394 -0
  55. package/dist/workspace.js +13 -29
  56. package/package.json +1 -1
  57. package/schemas/results-v2.schema.json +10 -0
  58. package/schemas/results-v3.schema.json +10 -0
  59. package/schemas/specification-v1.schema.json +21 -0
@@ -0,0 +1,451 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import yaml from "js-yaml";
5
+ import { parseSpec } from "./spec.js";
6
+ import { fixtureInventory, inspectEvalAuthoring } from "./authoring-preflight.js";
7
+ const QUESTIONS = [
8
+ { key: "purpose", prompt: "Who will use this skill, and what job should it help them do?" },
9
+ { key: "outcomes", prompt: "What should a successful result contain, and what would make it unacceptable?" },
10
+ { key: "boundaries", prompt: "Which external actions may it take, which require your approval, and what must it never do?" },
11
+ { key: "tools", prompt: "Which tools or integrations may it rely on in Pi? Use an empty list for a text-only skill." },
12
+ { key: "delivery", prompt: "Create a portable Pi skill only, or also a separate pi-daddy subagent definition?", options: ["skill", "skill-and-agent"] },
13
+ { key: "placement", prompt: "Keep evaluations with the skill, or in a separate evaluation repository?", options: ["same-repo", "separate-repo"] },
14
+ ];
15
+ const hash = (text) => createHash("sha256").update(text).digest("hex");
16
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
17
+ function safeName(name) {
18
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 80 || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(name)) {
19
+ throw new Error("Skill name must be a portable lowercase name with optional hyphens (at most 80 characters)");
20
+ }
21
+ return name;
22
+ }
23
+ function safeRelative(path) {
24
+ if (!text(path) || isAbsolute(path) || /[\\:\0<>"|?*]/.test(path) || path.split("/").some(p => !p || p === "." || p === ".." || /[. ]$/.test(p) || /^(\.git|\.skill-harness|con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(p))) {
25
+ throw new Error(`Unsafe authored path: ${JSON.stringify(path)}`);
26
+ }
27
+ return path;
28
+ }
29
+ /** Reject symlinks in both existing leaves and parent paths; outputs never follow a redirected root. */
30
+ function noLinks(path) {
31
+ for (let cursor = resolve(path);; cursor = dirname(cursor)) {
32
+ try {
33
+ if (lstatSync(cursor).isSymbolicLink())
34
+ throw new Error(`Symlink is not an authoring destination: ${cursor}`);
35
+ }
36
+ catch (error) {
37
+ if (error.code !== "ENOENT")
38
+ throw error;
39
+ }
40
+ if (dirname(cursor) === cursor)
41
+ break;
42
+ }
43
+ }
44
+ function readOptional(path) {
45
+ noLinks(path);
46
+ if (!existsSync(path))
47
+ return null;
48
+ if (!lstatSync(path).isFile())
49
+ throw new Error(`Expected an ordinary file: ${path}`);
50
+ const bytes = readFileSync(path);
51
+ const content = bytes.toString("utf8");
52
+ if (!Buffer.from(content).equals(bytes) || content.includes("\0"))
53
+ throw new Error(`Expected UTF-8 text: ${path}`);
54
+ return content;
55
+ }
56
+ function stateDir(root, name) {
57
+ return join(resolve(root), ".skill-harness", "authoring", safeName(name));
58
+ }
59
+ function writeAtomic(path, content) {
60
+ noLinks(path);
61
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
62
+ const temporary = join(dirname(path), `.authoring-${randomUUID()}.tmp`);
63
+ try {
64
+ const mode = existsSync(path) ? lstatSync(path).mode & 0o777 : 0o600;
65
+ writeFileSync(temporary, content, { flag: "wx", mode });
66
+ noLinks(path);
67
+ renameSync(temporary, path);
68
+ }
69
+ finally {
70
+ if (existsSync(temporary))
71
+ rmSync(temporary);
72
+ }
73
+ }
74
+ function save(state) {
75
+ writeAtomic(join(stateDir(state.root, state.name), "state.json"), JSON.stringify(state, null, 2) + "\n");
76
+ }
77
+ function protectLocalRecords(root) {
78
+ const ignore = join(resolve(root), ".skill-harness", "authoring", ".gitignore");
79
+ noLinks(ignore);
80
+ mkdirSync(dirname(ignore), { recursive: true, mode: 0o700 });
81
+ if (!existsSync(ignore))
82
+ writeFileSync(ignore, "*\n", { flag: "wx", mode: 0o600 });
83
+ }
84
+ function validateAnswers(answers) {
85
+ const keys = new Set([...QUESTIONS.map(q => q.key), "evalRoot", "agentCapabilities"]);
86
+ for (const [key, value] of Object.entries(answers)) {
87
+ if (!keys.has(key))
88
+ throw new Error(`Unknown authoring answer: ${key}`);
89
+ if (key === "tools" || key === "agentCapabilities") {
90
+ if (!Array.isArray(value) || value.some(v => !text(v)))
91
+ throw new Error("tools must be a list of nonempty tool names (possibly empty)");
92
+ if (key === "agentCapabilities")
93
+ for (const capability of value) {
94
+ if (!/^(?:context|agent|skill|workspace|ext):[A-Za-z0-9][A-Za-z0-9_./:-]*$/.test(capability))
95
+ throw new Error("Additional agent capabilities must be explicit context:, agent:, skill:, workspace: or ext: IDs without patterns");
96
+ }
97
+ }
98
+ else if (!text(value))
99
+ throw new Error(`${key} must be nonempty`);
100
+ if (key === "placement" && value !== "same-repo" && value !== "separate-repo")
101
+ throw new Error("Unknown evaluation placement");
102
+ if (key === "delivery" && value !== "skill" && value !== "skill-and-agent")
103
+ throw new Error("Unknown skill delivery");
104
+ }
105
+ return structuredClone(answers);
106
+ }
107
+ function nextQuestion(state) {
108
+ const next = QUESTIONS.find(q => state.answers[q.key] === undefined);
109
+ if (next)
110
+ return next;
111
+ if (state.answers.placement === "separate-repo" && !state.answers.evalRoot) {
112
+ return { key: "evalRoot", prompt: "What directory should hold the separate evaluation repository?" };
113
+ }
114
+ return null;
115
+ }
116
+ function locations(state) {
117
+ const evalRoot = state.answers.placement === "separate-repo"
118
+ ? resolve(state.root, state.answers.evalRoot) : state.skillsRoot;
119
+ return { skill: state.skillDir, evals: join(evalRoot, state.name, "tests"), agent: state.agentRoot, evalRoot };
120
+ }
121
+ function destination(state, file) {
122
+ if (!["skill", "evals", "agent"].includes(file.area))
123
+ throw new Error("Unknown draft file area");
124
+ safeRelative(file.path);
125
+ if ((file.area === "skill" && file.path.split("/")[0] === "tests") || (file.area === "evals" && /^results(?:\/|$)/.test(file.path))) {
126
+ throw new Error(`Reserved authored path: ${file.area}/${file.path}`);
127
+ }
128
+ if (file.area === "agent" && state.answers.delivery !== "skill-and-agent")
129
+ throw new Error("A subagent definition was not selected");
130
+ const path = resolve(locations(state)[file.area], file.path);
131
+ noLinks(path);
132
+ return path;
133
+ }
134
+ /** Create a local brief, without running a model, scanning examples or editing a skill. */
135
+ export function startAuthoring(options) {
136
+ const root = resolve(options.root);
137
+ noLinks(root);
138
+ if (!existsSync(root) || !lstatSync(root).isDirectory())
139
+ throw new Error("Authoring root must be an existing directory");
140
+ const name = safeName(options.name);
141
+ if (!text(options.idea))
142
+ throw new Error("An initial skill idea is required");
143
+ const dir = stateDir(root, name);
144
+ if (existsSync(join(dir, "state.json"))) {
145
+ const existing = readAuthoring(root, name);
146
+ if (existing.idea !== options.idea)
147
+ throw new Error("This name already has a different authoring brief; resume it or choose another name");
148
+ return existing;
149
+ }
150
+ const skillDir = options.skillDir ? resolve(root, options.skillDir) : resolve(root, options.skillRoot ?? "skills", name);
151
+ if (basename(skillDir) !== name)
152
+ throw new Error("Selected skill directory must match the skill name so Pi and eval discovery agree");
153
+ const existingSkill = readOptional(join(skillDir, "SKILL.md"));
154
+ if (options.skillDir && existingSkill === null)
155
+ throw new Error("Imported skillDir must contain SKILL.md");
156
+ const state = {
157
+ schema: 1, root, name, idea: options.idea, skillDir, skillsRoot: dirname(skillDir),
158
+ agentRoot: resolve(root, options.agentRoot ?? "agents"),
159
+ answers: validateAnswers({ ...options.answers, ...(options.evalRoot ? { evalRoot: options.evalRoot } : {}) }),
160
+ imported: existingSkill === null ? null : { path: join(skillDir, "SKILL.md"), sha256: hash(existingSkill) }, revision: 1,
161
+ };
162
+ noLinks(state.agentRoot);
163
+ protectLocalRecords(root);
164
+ save(state);
165
+ return state;
166
+ }
167
+ export function readAuthoring(root, name) {
168
+ const raw = readOptional(join(stateDir(root, name), "state.json"));
169
+ if (raw === null)
170
+ throw new Error(`No authoring brief for ${name}`);
171
+ const state = JSON.parse(raw);
172
+ if (state.schema !== 1 || state.name !== name || state.root !== resolve(root) || !text(state.idea)
173
+ || state.skillsRoot !== dirname(state.skillDir) || basename(state.skillDir) !== name)
174
+ throw new Error("Invalid authoring state identity");
175
+ validateAnswers(state.answers);
176
+ noLinks(state.skillDir);
177
+ noLinks(state.agentRoot);
178
+ return state;
179
+ }
180
+ /** Known answers can be supplied together; the UI asks only the next unresolved question. */
181
+ export function answerAuthoring(root, name, answers) {
182
+ const state = readAuthoring(root, name);
183
+ state.answers = validateAnswers({ ...state.answers, ...answers });
184
+ state.revision++;
185
+ delete state.draft;
186
+ delete state.appliedDigest;
187
+ save(state);
188
+ return state;
189
+ }
190
+ function frontmatter(content, filename) {
191
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content);
192
+ if (!match)
193
+ throw new Error(`${filename} needs YAML frontmatter`);
194
+ const parsed = yaml.load(match[1]);
195
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
196
+ throw new Error(`Invalid ${filename} frontmatter`);
197
+ const obj = parsed;
198
+ if (!text(obj.name) || !text(obj.description))
199
+ throw new Error(`${filename} needs a name and description`);
200
+ return obj;
201
+ }
202
+ /** pi-daddy 0.49.3 keys selected Markdown resources by path, with a plain declared ceiling. */
203
+ function checkAgent(state, file) {
204
+ if (!file.path.endsWith(".md"))
205
+ throw new Error("Agent definitions must be Markdown files");
206
+ const identity = basename(file.path, ".md");
207
+ safeName(identity);
208
+ if (identity === state.name)
209
+ throw new Error("Agent wrapper name must differ from the portable skill name; use <skill>-agent.md to avoid Pi resource collisions");
210
+ const fm = frontmatter(file.content, file.path);
211
+ if (fm.name !== identity)
212
+ throw new Error("Agent frontmatter name must match its Markdown filename so Pi and pi-daddy select the same identity");
213
+ const header = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(file.content)[1];
214
+ const declared = /^allowed-tools:[ \t]*(.*)$/m.exec(header)?.[1].trim();
215
+ if (declared === undefined)
216
+ throw new Error("Agent wrapper requires explicit allowed-tools; an absent ceiling is not spawnable in pi-daddy");
217
+ // Daddy's small parser does not unquote scalars; YAML quotes/folding would name the wrong tools.
218
+ if (declared && (typeof fm["allowed-tools"] !== "string" || fm["allowed-tools"] !== declared))
219
+ throw new Error("Agent allowed-tools must be a plain single-line comma-separated declaration, without YAML quotes or folding");
220
+ const normalize = (value) => /^(?:tool|ext|skill|agent|workspace|context):/.test(value) ? value : `tool:${value.toLowerCase()}`;
221
+ const agreed = new Set([...(state.answers.tools ?? []).map(normalize), ...(state.answers.agentCapabilities ?? [])]);
222
+ const capabilities = declared.split(/[\s,]+/).filter(Boolean);
223
+ for (const capability of capabilities) {
224
+ if (!/^(?:(?:tool|ext|skill|agent|workspace|context):)?[A-Za-z0-9][A-Za-z0-9_./:-]*$/.test(capability))
225
+ throw new Error("Agent allowed-tools needs explicit capability IDs without quotes, patterns or wildcards");
226
+ if (!agreed.has(normalize(capability)))
227
+ throw new Error(`Agent capability ${capability} is outside the agreed brief; ask before adding context or delegation authority`);
228
+ }
229
+ if (fm.tools !== undefined) {
230
+ if (typeof fm.tools !== "string")
231
+ throw new Error("Agent tools must use Pi's comma-separated frontmatter form");
232
+ const ceiling = new Set(capabilities.map(normalize));
233
+ for (const tool of fm.tools.split(/[\s,]+/).filter(Boolean))
234
+ if (!ceiling.has(normalize(tool)))
235
+ throw new Error(`Agent tools entry ${tool} is absent from allowed-tools`);
236
+ }
237
+ }
238
+ function draftIssues(state, draft) {
239
+ const issues = [];
240
+ const warnings = [];
241
+ const lookup = (area, path) => draft.files.find(f => f.area === area && f.path === path)?.content
242
+ ?? readOptional(resolve(locations(state)[area], safeRelative(path)));
243
+ try {
244
+ const skill = lookup("skill", "SKILL.md");
245
+ if (!skill)
246
+ throw new Error("Draft or imported skill must include SKILL.md");
247
+ if (frontmatter(skill, "SKILL.md").name !== state.name)
248
+ throw new Error("SKILL.md name must match the authoring brief");
249
+ const specification = lookup("evals", "specification.yaml");
250
+ if (!specification)
251
+ throw new Error("Draft needs an evaluation specification.yaml");
252
+ const spec = parseSpec(specification, "specification.yaml");
253
+ if (spec.skill !== state.name)
254
+ issues.push("Evaluation skill name must match the authoring brief");
255
+ const ids = new Set(spec.scenarios.map(s => s.id));
256
+ if (spec.ship_bar.total !== ids.size)
257
+ issues.push(`Ship bar total must equal the ${ids.size} authored scenarios`);
258
+ if (!Number.isSafeInteger(spec.ship_bar.min_pass) || spec.ship_bar.min_pass < 1 || spec.ship_bar.min_pass > ids.size)
259
+ issues.push("Ship bar min_pass must be an integer between 1 and the scenario count");
260
+ for (const id of spec.critical)
261
+ if (!ids.has(id))
262
+ issues.push(`Unknown critical scenario ${id}`);
263
+ const covered = new Set();
264
+ const requirements = new Set();
265
+ if (!Array.isArray(draft.requirements) || !draft.requirements.length)
266
+ throw new Error("Draft needs authored requirements mapped to scenarios");
267
+ for (const req of draft.requirements) {
268
+ if (!text(req.id) || !text(req.description) || requirements.has(req.id))
269
+ throw new Error("Requirement IDs and descriptions must be nonempty and IDs unique");
270
+ requirements.add(req.id);
271
+ if (!Array.isArray(req.scenarios) || !req.scenarios.length)
272
+ issues.push(`Requirement ${req.id} has no scenario`);
273
+ for (const id of req.scenarios ?? []) {
274
+ if (!ids.has(id))
275
+ issues.push(`Requirement ${req.id} names unknown scenario ${id}`);
276
+ covered.add(id);
277
+ }
278
+ }
279
+ for (const id of ids)
280
+ if (!covered.has(id))
281
+ issues.push(`Scenario ${id} has no requirement`);
282
+ for (const scenario of spec.scenarios) {
283
+ if (scenario.tools === undefined)
284
+ issues.push(`${scenario.id} needs explicit env.tools; use the agreed tool profile or a narrower subset (an empty list disables tools)`);
285
+ else
286
+ for (const tool of scenario.tools)
287
+ if (!state.answers.tools.includes(tool))
288
+ issues.push(`${scenario.id} tool ${tool} is outside the agreed tool profile; update the brief only with user authorization`);
289
+ const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
290
+ if (fixture) {
291
+ safeRelative(fixture);
292
+ const staged = draft.files.some(f => f.area === "evals" && f.path.startsWith(fixture.replace(/\/$/, "") + "/"));
293
+ const actual = resolve(locations(state).evals, fixture);
294
+ noLinks(actual);
295
+ if (!staged && (!existsSync(actual) || !lstatSync(actual).isDirectory()))
296
+ issues.push(`${scenario.id} fixture is missing: ${fixture}`);
297
+ }
298
+ for (const path of [scenario.systemPromptFile, ...(scenario.extensions ?? []), ...(scenario.judgeReferences ?? []), scenario.assert?.post_test].filter((v) => !!v)) {
299
+ if (lookup("evals", path) === null)
300
+ issues.push(`${scenario.id} support file is missing: ${path}`);
301
+ }
302
+ }
303
+ for (const finding of inspectEvalAuthoring(spec, {
304
+ referenceText: path => lookup("evals", path),
305
+ fixtureText: (scenario, path) => {
306
+ const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
307
+ if (!fixture)
308
+ return null;
309
+ const prefix = fixture.replace(/\/$/, "");
310
+ return lookup("evals", `${prefix}/_uncommitted/${path}`) ?? lookup("evals", `${prefix}/_staged/${path}`) ?? lookup("evals", `${prefix}/${path}`);
311
+ }, fixtureFiles: scenario => {
312
+ const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
313
+ if (!fixture)
314
+ return [];
315
+ safeRelative(fixture);
316
+ const prefix = fixture.replace(/\/$/, "") + "/";
317
+ const staged = draft.files.filter(f => f.area === "evals" && f.path.startsWith(prefix)).map(f => f.path.slice(prefix.length));
318
+ const actual = resolve(locations(state).evals, fixture);
319
+ noLinks(actual);
320
+ return [...new Set([...staged, ...(existsSync(actual) && lstatSync(actual).isDirectory() ? fixtureInventory(actual) : [])])];
321
+ }
322
+ })) {
323
+ (finding.severity === "error" ? issues : warnings).push(`${finding.scenario}: ${finding.message}`);
324
+ }
325
+ if (state.answers.placement === "separate-repo" && existsSync(join(state.skillDir, "tests", "specification.yaml"))) {
326
+ issues.push("The imported skill already has an in-repo specification; explicitly relocate it before selecting separate evals (specifications are not merged)");
327
+ }
328
+ if (state.answers.delivery === "skill-and-agent") {
329
+ const agents = draft.files.filter(f => f.area === "agent");
330
+ if (!agents.length)
331
+ issues.push("Selected skill-and-agent delivery needs a separate agent definition");
332
+ const agentNames = new Set();
333
+ for (const file of agents) {
334
+ const identity = basename(file.path, ".md");
335
+ if (agentNames.has(identity))
336
+ throw new Error(`Duplicate Pi agent resource name: ${identity}`);
337
+ agentNames.add(identity);
338
+ checkAgent(state, file);
339
+ }
340
+ }
341
+ }
342
+ catch (error) {
343
+ issues.push(error.message);
344
+ }
345
+ return { issues, warnings };
346
+ }
347
+ /** Stage model-authored content and capture exact existing bytes. This does not apply or certify it. */
348
+ export function stageAuthoringDraft(root, name, draft) {
349
+ const state = readAuthoring(root, name);
350
+ if (nextQuestion(state))
351
+ throw new Error("Complete the authoring brief before staging a draft");
352
+ if (!Array.isArray(draft.files) || !draft.files.length || draft.files.length > 200)
353
+ throw new Error("Draft needs between 1 and 200 selected files");
354
+ if (draft.labelStatus !== "proposed" && draft.labelStatus !== "reviewed")
355
+ throw new Error("Expected-label status must be proposed or author reviewed");
356
+ if (draft.files.some(f => f.area === "evals" && f.path === "authoring.json"))
357
+ throw new Error("authoring.json is generated from the reviewed brief");
358
+ const files = structuredClone(draft.files);
359
+ // An imported skill is part of the reviewed candidate even when only its evals change.
360
+ const skill = files.find(f => f.area === "skill" && f.path === "SKILL.md")?.content ?? readOptional(join(state.skillDir, "SKILL.md"));
361
+ if (skill !== null && !files.some(f => f.area === "skill" && f.path === "SKILL.md"))
362
+ files.push({ area: "skill", path: "SKILL.md", content: skill });
363
+ const loc = locations(state);
364
+ files.push({ area: "evals", path: "authoring.json", content: JSON.stringify({
365
+ schema: 1, skill: state.name,
366
+ skillsRoot: relative(loc.evalRoot, state.skillsRoot).split(sep).join("/") || ".",
367
+ skillSha256: skill === null ? null : hash(skill),
368
+ requirements: draft.requirements, labelStatus: draft.labelStatus,
369
+ modelQualityEvaluated: false, calibrated: false,
370
+ agent: state.answers.delivery === "skill-and-agent" ? { installed: false, grantsChanged: false } : null,
371
+ }, null, 2) + "\n" });
372
+ const seen = new Set();
373
+ const changes = files.map(file => {
374
+ if (typeof file.content !== "string" || file.content.includes("\0") || Buffer.byteLength(file.content) > 1024 * 1024)
375
+ throw new Error("Each authored file must be UTF-8 text of at most 1 MiB");
376
+ const path = destination(state, file);
377
+ const key = path.toLowerCase();
378
+ if (seen.has(key))
379
+ throw new Error(`Duplicate output destination: ${path}`);
380
+ seen.add(key);
381
+ const before = readOptional(path);
382
+ return { area: file.area, path: file.path, destination: path, before, after: file.content,
383
+ beforeSha256: before === null ? null : hash(before), afterSha256: hash(file.content),
384
+ kind: before === null ? "create" : before === file.content ? "unchanged" : "update" };
385
+ });
386
+ const data = structuredClone(draft);
387
+ const digest = hash(JSON.stringify({ revision: state.revision, answers: state.answers, data, changes }));
388
+ state.draft = { data, digest, changes };
389
+ delete state.appliedDigest;
390
+ save(state);
391
+ return inspectAuthoring(root, name);
392
+ }
393
+ export function inspectAuthoring(root, name) {
394
+ const state = readAuthoring(root, name);
395
+ const next = nextQuestion(state);
396
+ const { issues, warnings } = state.draft && !next ? draftIssues(state, state.draft.data) : { issues: [], warnings: [] };
397
+ const changes = state.draft?.changes ?? [];
398
+ if (state.draft) {
399
+ if (state.draft.digest !== hash(JSON.stringify({ revision: state.revision, answers: state.answers, data: state.draft.data, changes })))
400
+ issues.push("Draft digest does not match retained preview");
401
+ for (const change of changes) {
402
+ const target = destination(state, { area: change.area, path: change.path, content: change.after });
403
+ if (target !== change.destination || hash(change.after) !== change.afterSha256)
404
+ issues.push(`Invalid draft identity: ${change.path}`);
405
+ const current = readOptional(target);
406
+ const digest = current === null ? null : hash(current);
407
+ if (digest !== change.beforeSha256 && digest !== change.afterSha256)
408
+ issues.push(`Destination changed since preview: ${target}`);
409
+ }
410
+ }
411
+ return { state, nextQuestion: next, draftDigest: state.draft?.digest, changes, issues, warnings, readyToApply: !next && !!state.draft && issues.length === 0 };
412
+ }
413
+ /** Explicit, digest-bound apply. No grants, installation, provider calls, labels or training authorization are inferred. */
414
+ export function applyAuthoring(root, name, options) {
415
+ const dir = stateDir(root, name);
416
+ noLinks(dir);
417
+ const lock = join(dir, "apply.lock");
418
+ writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 });
419
+ try {
420
+ const preview = inspectAuthoring(root, name);
421
+ if (!preview.readyToApply || options.digest !== preview.draftDigest)
422
+ throw new Error(`Draft cannot be applied: ${preview.issues.join("; ") || "missing or changed preview digest"}`);
423
+ const state = preview.state;
424
+ const files = preview.changes.map(c => c.destination);
425
+ if (state.appliedDigest === options.digest) {
426
+ if (preview.changes.some(c => readOptional(c.destination) !== c.after))
427
+ throw new Error("An applied file has changed; stage a new reviewed draft");
428
+ return { state, files, status: "already-applied" };
429
+ }
430
+ // Validate the whole set before writing any product file. Backups remain local even after success.
431
+ for (const [index, change] of preview.changes.entries()) {
432
+ if (change.before !== null)
433
+ writeAtomic(join(dir, "backups", options.digest, `${index}.txt`), change.before);
434
+ }
435
+ for (const change of preview.changes) {
436
+ const current = readOptional(change.destination);
437
+ if (current === change.after)
438
+ continue;
439
+ if ((current === null ? null : hash(current)) !== change.beforeSha256)
440
+ throw new Error(`Destination changed during apply: ${change.destination}; earlier written files and backups are preserved`);
441
+ writeAtomic(change.destination, change.after);
442
+ }
443
+ state.appliedDigest = options.digest;
444
+ save(state);
445
+ return { state, files, status: "applied" };
446
+ }
447
+ finally {
448
+ rmSync(lock);
449
+ }
450
+ }
451
+ //# sourceMappingURL=authoring.js.map
@@ -0,0 +1,138 @@
1
+ import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
2
+ import { type Spec } from "./spec.js";
3
+ import { type TraceIdentity } from "./judge-evidence.js";
4
+ export interface CalibrationControl {
5
+ id: string;
6
+ scenarioId: string;
7
+ expected: "PASS" | "FAIL";
8
+ kind: "authored" | "saved" | "equivalent";
9
+ /** Complete production-shaped transcript, including any seeded trailers. Never just its extracted answer. */
10
+ transcript: string;
11
+ rationale: string;
12
+ equivalentTo?: string;
13
+ review: {
14
+ status: "proposed" | "accepted";
15
+ by?: string;
16
+ };
17
+ /** Where the example/expectation came from; a generated expectation remains a proposal until reviewed. */
18
+ provenance?: string;
19
+ /** Required for saved controls: selected original transcript below the spec directory. Preparation hashes it. */
20
+ sourceTranscript?: string;
21
+ trace?: {
22
+ path: string;
23
+ sha256: string;
24
+ fileSha256: string;
25
+ identity: Omit<TraceIdentity, "scenarioId" | "turns" | "responses">;
26
+ };
27
+ }
28
+ interface BoundControl extends CalibrationControl {
29
+ transcriptSha256: string;
30
+ rubricSha256: string;
31
+ sourceTranscriptSha256?: string;
32
+ }
33
+ export interface LoadedCalibration {
34
+ manifestPath: string;
35
+ manifestSha256: string;
36
+ specDir: string;
37
+ spec: Spec;
38
+ controls: Array<BoundControl & {
39
+ prompt: string;
40
+ executionEvidence: "verified" | "unknown";
41
+ }>;
42
+ /** Calibration judges supplied answers; even a verified trace is not a gate evaluation. */
43
+ calibrationScope: "answer-judge";
44
+ executionGates: "not-evaluated";
45
+ ready: boolean;
46
+ issues: string[];
47
+ }
48
+ /** Deterministic authoring helper. Hashes are produced by code, never requested from a model. */
49
+ export declare function prepareCalibration(options: {
50
+ specPath: string;
51
+ manifestPath?: string;
52
+ controls: CalibrationControl[];
53
+ }): LoadedCalibration;
54
+ /** Offline validation only. No Pi, provider access, or result rewrite. */
55
+ export declare function loadCalibration(manifestPath: string): LoadedCalibration;
56
+ export interface CalibrationReuseSource {
57
+ directory: string;
58
+ manifestSha256: string;
59
+ artifacts: {
60
+ path: string;
61
+ sha256: string;
62
+ }[];
63
+ }
64
+ export interface CalibrationSelection {
65
+ only?: string[];
66
+ reuse?: string[];
67
+ }
68
+ export interface CalibrationPlan {
69
+ schema: 1;
70
+ purpose: "judge-calibration-plan";
71
+ manifestSha256: string;
72
+ judge: ModelRef;
73
+ calibrationScope: "answer-judge";
74
+ executionGates: "not-evaluated";
75
+ availableCases: number;
76
+ cases: number;
77
+ partial: boolean;
78
+ ready: boolean;
79
+ issues: string[];
80
+ notes: string[];
81
+ judgeCallsPlanned: number;
82
+ reused: number;
83
+ controls: {
84
+ id: string;
85
+ action: "judge" | "reuse";
86
+ source?: CalibrationReuseSource;
87
+ }[];
88
+ }
89
+ /** Preview eligible historical judgments. Reuse is not a fresh stability measurement. */
90
+ export declare function planCalibration(loaded: LoadedCalibration, options: CalibrationSelection & {
91
+ judge: ModelRef;
92
+ }): CalibrationPlan;
93
+ export interface CalibrationSummary {
94
+ schema: 1;
95
+ purpose: "judge-calibration";
96
+ modelQualityScore: false;
97
+ manifestSha256: string;
98
+ judge: ModelRef;
99
+ cases: number;
100
+ completed: number;
101
+ matched: number;
102
+ judgeCalls: number;
103
+ status: "matched" | "mismatch" | "error";
104
+ calibrationScope: "answer-judge";
105
+ executionGates: "not-evaluated";
106
+ availableCases: number;
107
+ partial: boolean;
108
+ disagreements: {
109
+ id: string;
110
+ expected: "PASS" | "FAIL";
111
+ observed: "PASS" | "FAIL";
112
+ }[];
113
+ reused: {
114
+ id: string;
115
+ source: CalibrationReuseSource;
116
+ }[];
117
+ /** Legacy direction aliases for label disagreements, not adjudicated judge errors. */
118
+ falsePasses: string[];
119
+ falseFailures: string[];
120
+ errors: string[];
121
+ unrun: string[];
122
+ output: string;
123
+ }
124
+ /** Explicit Pi-backed judging; semantic mismatches continue, infrastructure errors stop the remaining calls. */
125
+ export declare function runCalibration(loaded: LoadedCalibration, options: {
126
+ adapter: HarnessAdapter;
127
+ judge: ModelRef;
128
+ output: string;
129
+ allowMeteredJudge?: boolean;
130
+ only?: string[];
131
+ reuse?: string[];
132
+ onProgress?: (progress: {
133
+ id: string;
134
+ index: number;
135
+ total: number;
136
+ }) => void;
137
+ }): Promise<CalibrationSummary>;
138
+ export {};