@skill-harness/core 0.28.0 → 0.30.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/README.md +16 -2
- package/dist/adapters/types.d.ts +2 -0
- package/dist/authoring-preflight.d.ts +14 -0
- package/dist/authoring-preflight.js +58 -0
- package/dist/authoring.d.ts +96 -0
- package/dist/authoring.js +442 -0
- package/dist/calibration.d.ts +81 -0
- package/dist/calibration.js +212 -0
- package/dist/command-cost.d.ts +1 -1
- package/dist/command-cost.js +2 -0
- package/dist/diagnose.d.ts +15 -0
- package/dist/diagnose.js +61 -0
- package/dist/execution-trace.d.ts +2 -1
- package/dist/execution-trace.js +4 -3
- package/dist/grade.d.ts +8 -0
- package/dist/grade.js +34 -8
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/judge-evidence.d.ts +40 -0
- package/dist/judge-evidence.js +120 -0
- package/dist/judge-references.d.ts +8 -0
- package/dist/judge-references.js +34 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +22 -0
- package/dist/regate.js +20 -3
- package/dist/regrade.d.ts +8 -1
- package/dist/regrade.js +61 -7
- package/dist/results.d.ts +7 -0
- package/dist/results.js +6 -0
- package/dist/run.js +20 -9
- package/dist/seeded.js +1 -0
- package/dist/sources.d.ts +4 -1
- package/dist/sources.js +31 -6
- package/dist/spec.d.ts +8 -0
- package/dist/spec.js +27 -0
- package/dist/trace-gates.d.ts +2 -0
- package/dist/trace-gates.js +13 -3
- package/package.json +1 -1
- package/schemas/results-v2.schema.json +10 -0
- package/schemas/results-v3.schema.json +10 -0
- package/schemas/specification-v1.schema.json +21 -0
package/README.md
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# @skill-harness/core
|
|
2
2
|
|
|
3
3
|
The engine behind [skill-harness](https://github.com/mojomanyana/skill-harness):
|
|
4
|
-
|
|
4
|
+
skill-authoring drafts and previews, spec parsing, eval preflight, selected judge
|
|
5
|
+
references, calibration, run orchestration, grading, scoring, and result diagnosis.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
Most users should install `skill-harness` in Pi, select a model, and start with
|
|
8
|
+
`/skill-harness create <idea>`. Authoring requires Pi and its selected model;
|
|
9
|
+
generated skills require only Pi. A pi-daddy wrapper is optional. Evaluation
|
|
10
|
+
files can live with the skill or in a separate repository.
|
|
11
|
+
|
|
12
|
+
The CLI exposes `preflight`, offline `calibrate` validation, explicit
|
|
13
|
+
`calibrate --run --judge ...`, and `diagnose`. Core helpers preserve selected
|
|
14
|
+
references and reviewed calibration expectations without treating them as a
|
|
15
|
+
model-quality score. See
|
|
16
|
+
[EVAL-AUTHORING.md](https://github.com/mojomanyana/skill-harness/blob/main/docs/EVAL-AUTHORING.md)
|
|
17
|
+
and [JUDGE-EVIDENCE.md](https://github.com/mojomanyana/skill-harness/blob/main/docs/JUDGE-EVIDENCE.md).
|
|
18
|
+
|
|
19
|
+
Fresh Pi execution is qualified for exact versions **1.0.4 and 1.1.0**.
|
|
20
|
+
Other versions require separate transport qualification.
|
|
7
21
|
|
|
8
22
|
ESM, Node ≥ 20.
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface RunReq {
|
|
|
55
55
|
* extension the developer happens to have installed cannot join the test.
|
|
56
56
|
*/
|
|
57
57
|
extensions?: string[];
|
|
58
|
+
/** Explicit Pi tool filter; an empty list disables tools. Not OS isolation. */
|
|
59
|
+
tools?: string[];
|
|
58
60
|
/**
|
|
59
61
|
* Extra env for the subject process, from the arm. Merged over `process.env` by
|
|
60
62
|
* the adapter — an arm must be able to add `PI_GRANTS_*` without the harness
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Spec } from "./spec.js";
|
|
2
|
+
export interface AuthoringFinding {
|
|
3
|
+
scenario: string;
|
|
4
|
+
code: "INPUT_DISCOVERY" | "READ_COUNT" | "PATH_PORTABILITY" | "COMMAND_SPELLING" | "TEST_SYNTAX" | "CITATION_FORMAT" | "UNAVAILABLE_FORBIDDEN_TOOL" | "UNAVAILABLE_REQUIRED_TOOL";
|
|
5
|
+
severity?: "error";
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
/** Review hints only. A valid exact-format contract may intentionally trigger one. */
|
|
9
|
+
export declare function inspectEvalAuthoring(spec: Spec, profile?: {
|
|
10
|
+
tools?: readonly string[];
|
|
11
|
+
fixtureFiles?: (scenario: Spec["scenarios"][number]) => readonly string[];
|
|
12
|
+
}): AuthoringFinding[];
|
|
13
|
+
/** Names only, never file contents. Do not follow symlinks or inspect private host directories. */
|
|
14
|
+
export declare function fixtureInventory(root: string): string[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { MARKERS } from "./workspace.js";
|
|
4
|
+
/** Review hints only. A valid exact-format contract may intentionally trigger one. */
|
|
5
|
+
export function inspectEvalAuthoring(spec, profile = {}) {
|
|
6
|
+
const findings = [];
|
|
7
|
+
for (const s of spec.scenarios) {
|
|
8
|
+
const tools = profile.tools ?? s.tools;
|
|
9
|
+
const add = (code, message) => findings.push({ scenario: s.id, code, message });
|
|
10
|
+
for (const call of s.traceAssert?.require_calls ?? []) {
|
|
11
|
+
const pattern = call.args?.path?.matches;
|
|
12
|
+
if (tools && !tools.includes(call.tool)) {
|
|
13
|
+
findings.push({ scenario: s.id, code: "UNAVAILABLE_REQUIRED_TOOL", severity: "error", message: `Required tool ${call.tool} is excluded by the explicit Pi tool filter.` });
|
|
14
|
+
}
|
|
15
|
+
if (tools?.length === 1 && tools[0] === "read" && call.tool === "read" && pattern) {
|
|
16
|
+
const matches = (profile.fixtureFiles?.(s) ?? []).filter(file => new RegExp(pattern).test(file));
|
|
17
|
+
if (matches.length && !matches.some(file => s.turns.join("\n").includes(file))) {
|
|
18
|
+
add("INPUT_DISCOVERY", "A required source is not named in the prompt and the supplied profile exposes only read. Provide an inventory or discovery capability.");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (call.tool === "read" && (call.count?.min ?? 1) > 1) {
|
|
22
|
+
add("READ_COUNT", "Repeated reads do not prove content coverage. Calibrate a single complete read and repeated first chunks.");
|
|
23
|
+
}
|
|
24
|
+
if (pattern?.includes("(^|/)"))
|
|
25
|
+
add("PATH_PORTABILITY", "The path predicate rejects Windows separators. Check whether that restriction is intentional.");
|
|
26
|
+
if (call.tool === "bash" && call.args?.command?.matches === String.raw `\bnpm test\b`) {
|
|
27
|
+
add("COMMAND_SPELLING", "The command predicate rejects npm run test, which invokes the same package script.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (s.assert?.diff_contains?.includes("expect("))
|
|
31
|
+
add("TEST_SYNTAX", "An assertion spelling does not establish that a regression test detects the defect.");
|
|
32
|
+
if (s.checklist.some(item => /\bCite\s+\S+\s+line\s+\d/i.test(item))) {
|
|
33
|
+
add("CITATION_FORMAT", "A mandatory line citation can reject an accurate attributed answer. Justify the format requirement or accept equivalent evidence.");
|
|
34
|
+
}
|
|
35
|
+
if (tools && s.traceAssert?.forbid_calls?.some(call => !tools.includes(call.tool))) {
|
|
36
|
+
add("UNAVAILABLE_FORBIDDEN_TOOL", "A forbidden tool is absent from the supplied profile. Non-use does not demonstrate refusal of an available capability.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return findings;
|
|
40
|
+
}
|
|
41
|
+
/** Names only, never file contents. Do not follow symlinks or inspect private host directories. */
|
|
42
|
+
export function fixtureInventory(root) {
|
|
43
|
+
const files = [];
|
|
44
|
+
const visit = (directory, prefix) => {
|
|
45
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
46
|
+
if (entry.name === ".git" || entry.isSymbolicLink())
|
|
47
|
+
continue;
|
|
48
|
+
const name = prefix + entry.name;
|
|
49
|
+
if (entry.isDirectory())
|
|
50
|
+
visit(join(directory, entry.name), name + "/");
|
|
51
|
+
else if (entry.isFile())
|
|
52
|
+
files.push(MARKERS.some(marker => name.startsWith(marker + "/")) ? name.slice(name.indexOf("/") + 1) : name);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
visit(root, "");
|
|
56
|
+
return [...new Set(files)].sort();
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=authoring-preflight.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface AuthoringAnswers {
|
|
2
|
+
purpose: string;
|
|
3
|
+
outcomes: string;
|
|
4
|
+
boundaries: string;
|
|
5
|
+
tools: string[];
|
|
6
|
+
placement: "same-repo" | "separate-repo";
|
|
7
|
+
delivery: "skill" | "skill-and-agent";
|
|
8
|
+
evalRoot?: string;
|
|
9
|
+
/** Additional operator-agreed context/delegation capabilities for the optional wrapper. */
|
|
10
|
+
agentCapabilities?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface AuthoringQuestion {
|
|
13
|
+
key: keyof AuthoringAnswers;
|
|
14
|
+
prompt: string;
|
|
15
|
+
options?: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface AuthoringFile {
|
|
18
|
+
area: "skill" | "evals" | "agent";
|
|
19
|
+
path: string;
|
|
20
|
+
content: string;
|
|
21
|
+
}
|
|
22
|
+
export interface AuthoringDraft {
|
|
23
|
+
files: AuthoringFile[];
|
|
24
|
+
requirements: {
|
|
25
|
+
id: string;
|
|
26
|
+
description: string;
|
|
27
|
+
scenarios: string[];
|
|
28
|
+
}[];
|
|
29
|
+
/** An author's assertion about proposed control labels, never calibration or ship approval. */
|
|
30
|
+
labelStatus: "proposed" | "reviewed";
|
|
31
|
+
}
|
|
32
|
+
export interface AuthoringChange {
|
|
33
|
+
area: AuthoringFile["area"];
|
|
34
|
+
path: string;
|
|
35
|
+
destination: string;
|
|
36
|
+
beforeSha256: string | null;
|
|
37
|
+
afterSha256: string;
|
|
38
|
+
kind: "create" | "update" | "unchanged";
|
|
39
|
+
before: string | null;
|
|
40
|
+
after: string;
|
|
41
|
+
}
|
|
42
|
+
export interface AuthoringState {
|
|
43
|
+
schema: 1;
|
|
44
|
+
name: string;
|
|
45
|
+
idea: string;
|
|
46
|
+
root: string;
|
|
47
|
+
skillDir: string;
|
|
48
|
+
skillsRoot: string;
|
|
49
|
+
agentRoot: string;
|
|
50
|
+
answers: Partial<AuthoringAnswers>;
|
|
51
|
+
imported: {
|
|
52
|
+
path: string;
|
|
53
|
+
sha256: string;
|
|
54
|
+
} | null;
|
|
55
|
+
revision: number;
|
|
56
|
+
draft?: {
|
|
57
|
+
data: AuthoringDraft;
|
|
58
|
+
digest: string;
|
|
59
|
+
changes: AuthoringChange[];
|
|
60
|
+
};
|
|
61
|
+
appliedDigest?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface AuthoringPreview {
|
|
64
|
+
state: AuthoringState;
|
|
65
|
+
nextQuestion: AuthoringQuestion | null;
|
|
66
|
+
draftDigest?: string;
|
|
67
|
+
changes: AuthoringChange[];
|
|
68
|
+
issues: string[];
|
|
69
|
+
warnings: string[];
|
|
70
|
+
readyToApply: boolean;
|
|
71
|
+
}
|
|
72
|
+
/** Create a local brief, without running a model, scanning examples or editing a skill. */
|
|
73
|
+
export declare function startAuthoring(options: {
|
|
74
|
+
root: string;
|
|
75
|
+
name: string;
|
|
76
|
+
idea: string;
|
|
77
|
+
answers?: Partial<AuthoringAnswers>;
|
|
78
|
+
skillRoot?: string;
|
|
79
|
+
evalRoot?: string;
|
|
80
|
+
agentRoot?: string;
|
|
81
|
+
skillDir?: string;
|
|
82
|
+
}): AuthoringState;
|
|
83
|
+
export declare function readAuthoring(root: string, name: string): AuthoringState;
|
|
84
|
+
/** Known answers can be supplied together; the UI asks only the next unresolved question. */
|
|
85
|
+
export declare function answerAuthoring(root: string, name: string, answers: Partial<AuthoringAnswers>): AuthoringState;
|
|
86
|
+
/** Stage model-authored content and capture exact existing bytes. This does not apply or certify it. */
|
|
87
|
+
export declare function stageAuthoringDraft(root: string, name: string, draft: AuthoringDraft): AuthoringPreview;
|
|
88
|
+
export declare function inspectAuthoring(root: string, name: string): AuthoringPreview;
|
|
89
|
+
/** Explicit, digest-bound apply. No grants, installation, provider calls, labels or training authorization are inferred. */
|
|
90
|
+
export declare function applyAuthoring(root: string, name: string, options: {
|
|
91
|
+
digest: string;
|
|
92
|
+
}): {
|
|
93
|
+
state: AuthoringState;
|
|
94
|
+
files: string[];
|
|
95
|
+
status: "applied" | "already-applied";
|
|
96
|
+
};
|
|
@@ -0,0 +1,442 @@
|
|
|
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, { fixtureFiles: scenario => {
|
|
304
|
+
const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
|
|
305
|
+
if (!fixture)
|
|
306
|
+
return [];
|
|
307
|
+
safeRelative(fixture);
|
|
308
|
+
const prefix = fixture.replace(/\/$/, "") + "/";
|
|
309
|
+
const staged = draft.files.filter(f => f.area === "evals" && f.path.startsWith(prefix)).map(f => f.path.slice(prefix.length));
|
|
310
|
+
const actual = resolve(locations(state).evals, fixture);
|
|
311
|
+
noLinks(actual);
|
|
312
|
+
return [...new Set([...staged, ...(existsSync(actual) && lstatSync(actual).isDirectory() ? fixtureInventory(actual) : [])])];
|
|
313
|
+
} })) {
|
|
314
|
+
(finding.severity === "error" ? issues : warnings).push(`${finding.scenario}: ${finding.message}`);
|
|
315
|
+
}
|
|
316
|
+
if (state.answers.placement === "separate-repo" && existsSync(join(state.skillDir, "tests", "specification.yaml"))) {
|
|
317
|
+
issues.push("The imported skill already has an in-repo specification; explicitly relocate it before selecting separate evals (specifications are not merged)");
|
|
318
|
+
}
|
|
319
|
+
if (state.answers.delivery === "skill-and-agent") {
|
|
320
|
+
const agents = draft.files.filter(f => f.area === "agent");
|
|
321
|
+
if (!agents.length)
|
|
322
|
+
issues.push("Selected skill-and-agent delivery needs a separate agent definition");
|
|
323
|
+
const agentNames = new Set();
|
|
324
|
+
for (const file of agents) {
|
|
325
|
+
const identity = basename(file.path, ".md");
|
|
326
|
+
if (agentNames.has(identity))
|
|
327
|
+
throw new Error(`Duplicate Pi agent resource name: ${identity}`);
|
|
328
|
+
agentNames.add(identity);
|
|
329
|
+
checkAgent(state, file);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
issues.push(error.message);
|
|
335
|
+
}
|
|
336
|
+
return { issues, warnings };
|
|
337
|
+
}
|
|
338
|
+
/** Stage model-authored content and capture exact existing bytes. This does not apply or certify it. */
|
|
339
|
+
export function stageAuthoringDraft(root, name, draft) {
|
|
340
|
+
const state = readAuthoring(root, name);
|
|
341
|
+
if (nextQuestion(state))
|
|
342
|
+
throw new Error("Complete the authoring brief before staging a draft");
|
|
343
|
+
if (!Array.isArray(draft.files) || !draft.files.length || draft.files.length > 200)
|
|
344
|
+
throw new Error("Draft needs between 1 and 200 selected files");
|
|
345
|
+
if (draft.labelStatus !== "proposed" && draft.labelStatus !== "reviewed")
|
|
346
|
+
throw new Error("Expected-label status must be proposed or author reviewed");
|
|
347
|
+
if (draft.files.some(f => f.area === "evals" && f.path === "authoring.json"))
|
|
348
|
+
throw new Error("authoring.json is generated from the reviewed brief");
|
|
349
|
+
const files = structuredClone(draft.files);
|
|
350
|
+
// An imported skill is part of the reviewed candidate even when only its evals change.
|
|
351
|
+
const skill = files.find(f => f.area === "skill" && f.path === "SKILL.md")?.content ?? readOptional(join(state.skillDir, "SKILL.md"));
|
|
352
|
+
if (skill !== null && !files.some(f => f.area === "skill" && f.path === "SKILL.md"))
|
|
353
|
+
files.push({ area: "skill", path: "SKILL.md", content: skill });
|
|
354
|
+
const loc = locations(state);
|
|
355
|
+
files.push({ area: "evals", path: "authoring.json", content: JSON.stringify({
|
|
356
|
+
schema: 1, skill: state.name,
|
|
357
|
+
skillsRoot: relative(loc.evalRoot, state.skillsRoot).split(sep).join("/") || ".",
|
|
358
|
+
skillSha256: skill === null ? null : hash(skill),
|
|
359
|
+
requirements: draft.requirements, labelStatus: draft.labelStatus,
|
|
360
|
+
modelQualityEvaluated: false, calibrated: false,
|
|
361
|
+
agent: state.answers.delivery === "skill-and-agent" ? { installed: false, grantsChanged: false } : null,
|
|
362
|
+
}, null, 2) + "\n" });
|
|
363
|
+
const seen = new Set();
|
|
364
|
+
const changes = files.map(file => {
|
|
365
|
+
if (typeof file.content !== "string" || file.content.includes("\0") || Buffer.byteLength(file.content) > 1024 * 1024)
|
|
366
|
+
throw new Error("Each authored file must be UTF-8 text of at most 1 MiB");
|
|
367
|
+
const path = destination(state, file);
|
|
368
|
+
const key = path.toLowerCase();
|
|
369
|
+
if (seen.has(key))
|
|
370
|
+
throw new Error(`Duplicate output destination: ${path}`);
|
|
371
|
+
seen.add(key);
|
|
372
|
+
const before = readOptional(path);
|
|
373
|
+
return { area: file.area, path: file.path, destination: path, before, after: file.content,
|
|
374
|
+
beforeSha256: before === null ? null : hash(before), afterSha256: hash(file.content),
|
|
375
|
+
kind: before === null ? "create" : before === file.content ? "unchanged" : "update" };
|
|
376
|
+
});
|
|
377
|
+
const data = structuredClone(draft);
|
|
378
|
+
const digest = hash(JSON.stringify({ revision: state.revision, answers: state.answers, data, changes }));
|
|
379
|
+
state.draft = { data, digest, changes };
|
|
380
|
+
delete state.appliedDigest;
|
|
381
|
+
save(state);
|
|
382
|
+
return inspectAuthoring(root, name);
|
|
383
|
+
}
|
|
384
|
+
export function inspectAuthoring(root, name) {
|
|
385
|
+
const state = readAuthoring(root, name);
|
|
386
|
+
const next = nextQuestion(state);
|
|
387
|
+
const { issues, warnings } = state.draft && !next ? draftIssues(state, state.draft.data) : { issues: [], warnings: [] };
|
|
388
|
+
const changes = state.draft?.changes ?? [];
|
|
389
|
+
if (state.draft) {
|
|
390
|
+
if (state.draft.digest !== hash(JSON.stringify({ revision: state.revision, answers: state.answers, data: state.draft.data, changes })))
|
|
391
|
+
issues.push("Draft digest does not match retained preview");
|
|
392
|
+
for (const change of changes) {
|
|
393
|
+
const target = destination(state, { area: change.area, path: change.path, content: change.after });
|
|
394
|
+
if (target !== change.destination || hash(change.after) !== change.afterSha256)
|
|
395
|
+
issues.push(`Invalid draft identity: ${change.path}`);
|
|
396
|
+
const current = readOptional(target);
|
|
397
|
+
const digest = current === null ? null : hash(current);
|
|
398
|
+
if (digest !== change.beforeSha256 && digest !== change.afterSha256)
|
|
399
|
+
issues.push(`Destination changed since preview: ${target}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return { state, nextQuestion: next, draftDigest: state.draft?.digest, changes, issues, warnings, readyToApply: !next && !!state.draft && issues.length === 0 };
|
|
403
|
+
}
|
|
404
|
+
/** Explicit, digest-bound apply. No grants, installation, provider calls, labels or training authorization are inferred. */
|
|
405
|
+
export function applyAuthoring(root, name, options) {
|
|
406
|
+
const dir = stateDir(root, name);
|
|
407
|
+
noLinks(dir);
|
|
408
|
+
const lock = join(dir, "apply.lock");
|
|
409
|
+
writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
410
|
+
try {
|
|
411
|
+
const preview = inspectAuthoring(root, name);
|
|
412
|
+
if (!preview.readyToApply || options.digest !== preview.draftDigest)
|
|
413
|
+
throw new Error(`Draft cannot be applied: ${preview.issues.join("; ") || "missing or changed preview digest"}`);
|
|
414
|
+
const state = preview.state;
|
|
415
|
+
const files = preview.changes.map(c => c.destination);
|
|
416
|
+
if (state.appliedDigest === options.digest) {
|
|
417
|
+
if (preview.changes.some(c => readOptional(c.destination) !== c.after))
|
|
418
|
+
throw new Error("An applied file has changed; stage a new reviewed draft");
|
|
419
|
+
return { state, files, status: "already-applied" };
|
|
420
|
+
}
|
|
421
|
+
// Validate the whole set before writing any product file. Backups remain local even after success.
|
|
422
|
+
for (const [index, change] of preview.changes.entries()) {
|
|
423
|
+
if (change.before !== null)
|
|
424
|
+
writeAtomic(join(dir, "backups", options.digest, `${index}.txt`), change.before);
|
|
425
|
+
}
|
|
426
|
+
for (const change of preview.changes) {
|
|
427
|
+
const current = readOptional(change.destination);
|
|
428
|
+
if (current === change.after)
|
|
429
|
+
continue;
|
|
430
|
+
if ((current === null ? null : hash(current)) !== change.beforeSha256)
|
|
431
|
+
throw new Error(`Destination changed during apply: ${change.destination}; earlier written files and backups are preserved`);
|
|
432
|
+
writeAtomic(change.destination, change.after);
|
|
433
|
+
}
|
|
434
|
+
state.appliedDigest = options.digest;
|
|
435
|
+
save(state);
|
|
436
|
+
return { state, files, status: "applied" };
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
rmSync(lock);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
//# sourceMappingURL=authoring.js.map
|