@penpair/core 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1645 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-check-entry.d.ts +17 -0
- package/dist/skill-check-entry.js +89 -0
- package/dist/skill-check-instructions.d.ts +7 -0
- package/dist/skill-check-instructions.js +39 -0
- package/dist/skill-check.d.ts +15 -0
- package/dist/skill-check.js +36 -0
- package/dist/skill-file.d.ts +2 -0
- package/dist/skill-file.js +31 -0
- package/dist/skill-instructions.d.ts +4 -0
- package/dist/skill-instructions.js +97 -22
- package/dist/skill-metadata.d.ts +8 -0
- package/dist/skill-metadata.js +52 -0
- package/dist/skill-routing.d.ts +2 -0
- package/dist/skill-routing.js +36 -0
- package/dist/skills.js +131 -41
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/design-judgment/agents/openai.yaml +6 -0
- package/skills/penpair-design/SKILL.md +14 -1
- package/skills/penpair-design/agents/openai.yaml +6 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +14 -1
- package/skills/penpair-handoff/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/references/comparison-companion.md +16 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +13 -1
- package/skills/penpair-recipes/agents/openai.yaml +6 -0
- package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
- package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
- package/skills/penpair-setup/SKILL.md +9 -3
- package/skills/penpair-setup/agents/openai.yaml +6 -0
- package/skills/penpair-verify/SKILL.md +49 -0
- package/skills/penpair-verify/agents/openai.yaml +6 -0
- package/skills/penpair-verify/references/browser-evidence.md +33 -0
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
package/dist/run-file.js
CHANGED
|
@@ -8,93 +8,192 @@ import { normalize } from "./registry.js";
|
|
|
8
8
|
import { runOnPage, runOperation } from "./operation.js";
|
|
9
9
|
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
10
10
|
async function sourceFile(root, entry) {
|
|
11
|
-
if (!/\.m?js$/.test(entry))
|
|
11
|
+
if (!/\.m?js$/.test(entry)) {
|
|
12
12
|
throw new Error("Plugin bodies must be .js or .mjs files; host imports and TypeScript transpilation are not performed");
|
|
13
|
+
}
|
|
13
14
|
const target = await resolveProjectInput(root, entry);
|
|
14
15
|
const handle = await open(target, "r");
|
|
15
16
|
let body;
|
|
16
17
|
try {
|
|
17
18
|
const info = await handle.stat();
|
|
18
|
-
if (!info.isFile() || info.size > maxSnippetBytes)
|
|
19
|
+
if (!info.isFile() || info.size > maxSnippetBytes) {
|
|
19
20
|
throw new Error("Script file exceeds the 90 KB input bound or is not a regular file");
|
|
21
|
+
}
|
|
20
22
|
body = (await handle.readFile("utf8")).replace(/^\uFEFF/, "");
|
|
21
23
|
}
|
|
22
24
|
finally {
|
|
23
25
|
await handle.close();
|
|
24
26
|
}
|
|
25
|
-
if (body.includes("\0"))
|
|
27
|
+
if (body.includes("\0")) {
|
|
26
28
|
throw new Error("Plugin script contains a NUL character");
|
|
29
|
+
}
|
|
27
30
|
const header = body.trimStart().split(/\r?\n/)[0]?.trim();
|
|
28
31
|
const declaration = header === "// penpair: read" ? "read" : header === "// penpair: write" ? "write" : "unknown";
|
|
29
32
|
// The idempotency assertion must be in the initial comment block, before executable source.
|
|
30
33
|
const comments = [];
|
|
31
34
|
for (const line of body.trimStart().split(/\r?\n/)) {
|
|
32
|
-
if (!line.trim().startsWith("//"))
|
|
35
|
+
if (!line.trim().startsWith("//")) {
|
|
33
36
|
break;
|
|
37
|
+
}
|
|
34
38
|
comments.push(line.trim());
|
|
35
39
|
}
|
|
36
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
file: path
|
|
42
|
+
.relative(await realpath(root), target)
|
|
43
|
+
.split(path.sep)
|
|
44
|
+
.join("/"),
|
|
45
|
+
sha256: digest(body),
|
|
46
|
+
body,
|
|
47
|
+
declaration,
|
|
48
|
+
idempotent: comments.includes("// penpair: idempotent"),
|
|
49
|
+
};
|
|
37
50
|
}
|
|
38
51
|
function checked(value) {
|
|
39
52
|
const error = compiles(value.code);
|
|
40
|
-
if (error)
|
|
53
|
+
if (error) {
|
|
41
54
|
throw new Error(`${value.name}: invalid plugin body: ${error}`);
|
|
55
|
+
}
|
|
42
56
|
return value;
|
|
43
57
|
}
|
|
44
58
|
/** Reads and compiles explicitly selected files without importing project modules on the host. */
|
|
45
59
|
export async function prepareFileScript(root, file, options = {}) {
|
|
46
60
|
const includes = options.include ?? [];
|
|
47
|
-
if (includes.length > 20 || new Set(includes).size !== includes.length)
|
|
61
|
+
if (includes.length > 20 || new Set(includes).size !== includes.length) {
|
|
48
62
|
throw new Error("Use at most 20 distinct ordered includes");
|
|
63
|
+
}
|
|
49
64
|
const params = options.params ?? {};
|
|
50
|
-
if (Object.keys(params).length > 100 ||
|
|
65
|
+
if (Object.keys(params).length > 100 ||
|
|
66
|
+
Object.keys(params).some((key) => !/^[A-Za-z][A-Za-z0-9]*$/.test(key))) {
|
|
51
67
|
throw new Error("Use at most 100 named parameters (letters followed by letters/digits)");
|
|
52
|
-
|
|
68
|
+
}
|
|
69
|
+
if (options.page && Object.hasOwn(params, "page")) {
|
|
53
70
|
throw new Error("--page owns CFG.page; do not also pass --param page");
|
|
54
|
-
|
|
71
|
+
}
|
|
72
|
+
if (Object.hasOwn(params, "inspection")) {
|
|
55
73
|
throw new Error("CFG.inspection is reserved for the independently captured write inspection");
|
|
74
|
+
}
|
|
56
75
|
const helpers = [];
|
|
57
76
|
for (const include of includes) {
|
|
58
77
|
const virtual = options.virtualIncludes?.[include];
|
|
59
|
-
const helper = virtual === undefined
|
|
60
|
-
|
|
78
|
+
const helper = virtual === undefined
|
|
79
|
+
? await sourceFile(root, include)
|
|
80
|
+
: {
|
|
81
|
+
file: include,
|
|
82
|
+
body: virtual,
|
|
83
|
+
sha256: digest(virtual),
|
|
84
|
+
declaration: virtual.startsWith("// penpair: read\n") ? "read" : "unknown",
|
|
85
|
+
idempotent: false,
|
|
86
|
+
};
|
|
87
|
+
if (helper.declaration !== "read") {
|
|
61
88
|
throw new Error(`Include ${include} must declare // penpair: read for its top-level inclusion effects`);
|
|
89
|
+
}
|
|
62
90
|
helpers.push(helper);
|
|
63
91
|
}
|
|
64
92
|
const main = await sourceFile(root, file);
|
|
65
|
-
const prelude = helpers.map(helper => `// Included helper\n${helper.body}\n`).join("\n");
|
|
66
|
-
const cfg = {
|
|
67
|
-
|
|
93
|
+
const prelude = helpers.map((helper) => `// Included helper\n${helper.body}\n`).join("\n");
|
|
94
|
+
const cfg = {
|
|
95
|
+
...params,
|
|
96
|
+
...(options.page
|
|
97
|
+
? {
|
|
98
|
+
page: options.page,
|
|
99
|
+
}
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
const build = (label, body, effect, inspected) => checked(snippet(label, `${requirePage}\n${prelude}\n${body}`, {
|
|
103
|
+
...cfg,
|
|
104
|
+
...(effect === "write"
|
|
105
|
+
? {
|
|
106
|
+
inspection: inspected ?? null,
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
109
|
+
}, effect));
|
|
68
110
|
if (main.declaration === "read") {
|
|
69
|
-
if (options.inspect || options.readback)
|
|
111
|
+
if (options.inspect || options.readback) {
|
|
70
112
|
throw new Error("Read scripts do not take write inspect/readback phases");
|
|
71
|
-
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
kind: "read",
|
|
116
|
+
source: [
|
|
117
|
+
...helpers,
|
|
118
|
+
main,
|
|
119
|
+
].map(({ file, sha256 }) => ({
|
|
120
|
+
file,
|
|
121
|
+
sha256,
|
|
122
|
+
})),
|
|
123
|
+
params: cfg,
|
|
124
|
+
page: options.page,
|
|
125
|
+
read: build("run-file-read", main.body, "read"),
|
|
126
|
+
};
|
|
72
127
|
}
|
|
73
|
-
if (main.declaration !== "write" || !main.idempotent || !options.inspect || !options.readback)
|
|
128
|
+
if (main.declaration !== "write" || !main.idempotent || !options.inspect || !options.readback) {
|
|
74
129
|
throw new Error("Write script execution requires // penpair: write, // penpair: idempotent, and explicit --inspect/--readback files. Declarations are trusted code contracts, not a sandbox.");
|
|
75
|
-
|
|
76
|
-
|
|
130
|
+
}
|
|
131
|
+
const inspect = await sourceFile(root, options.inspect);
|
|
132
|
+
const readback = await sourceFile(root, options.readback);
|
|
133
|
+
if (inspect.declaration !== "read" || readback.declaration !== "read") {
|
|
77
134
|
throw new Error("Inspection and readback files must declare // penpair: read");
|
|
135
|
+
}
|
|
78
136
|
// Reuse the registry's positive evidence verifier; never equate an acknowledgment with a verified write.
|
|
79
|
-
const normalized = normalize({
|
|
80
|
-
|
|
137
|
+
const normalized = normalize({
|
|
138
|
+
name: "run-file",
|
|
139
|
+
description: "Explicitly selected reviewed file operation",
|
|
140
|
+
kind: "write",
|
|
141
|
+
inspect: inspect.body,
|
|
142
|
+
apply: main.body,
|
|
143
|
+
readback: readback.body,
|
|
144
|
+
}, "project");
|
|
145
|
+
const operation = {
|
|
146
|
+
...normalized.operation,
|
|
147
|
+
page: () => options.page,
|
|
81
148
|
inspect: () => build("run-file-inspect", inspect.body, "read"),
|
|
82
149
|
apply: (_params, inspected) => build("run-file-apply", main.body, "write", inspected),
|
|
83
|
-
readback: () => build("run-file-readback", readback.body, "read"),
|
|
150
|
+
readback: () => build("run-file-readback", readback.body, "read"),
|
|
151
|
+
};
|
|
84
152
|
operation.inspect({});
|
|
85
153
|
operation.apply({});
|
|
86
154
|
operation.readback({});
|
|
87
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
kind: "write",
|
|
157
|
+
source: [
|
|
158
|
+
...helpers,
|
|
159
|
+
main,
|
|
160
|
+
inspect,
|
|
161
|
+
readback,
|
|
162
|
+
].map(({ file, sha256 }) => ({
|
|
163
|
+
file,
|
|
164
|
+
sha256,
|
|
165
|
+
})),
|
|
166
|
+
params: cfg,
|
|
167
|
+
page: options.page,
|
|
168
|
+
operation,
|
|
169
|
+
};
|
|
88
170
|
}
|
|
89
171
|
export async function runFileScript(client, prepared, options = {}) {
|
|
90
172
|
if (prepared.kind === "read") {
|
|
91
|
-
if (options.apply || options.journal)
|
|
173
|
+
if (options.apply || options.journal) {
|
|
92
174
|
throw new Error("Read files do not accept apply or write recovery options");
|
|
93
|
-
|
|
94
|
-
|
|
175
|
+
}
|
|
176
|
+
const result = await runOnPage(client, prepared.read, prepared.page, {
|
|
177
|
+
...options,
|
|
178
|
+
effect: "read",
|
|
179
|
+
retries: 0,
|
|
180
|
+
});
|
|
181
|
+
if (result.ok !== true) {
|
|
95
182
|
throw new Error("Read script returned no explicit ok acknowledgment");
|
|
96
|
-
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
phase: "read",
|
|
186
|
+
kind: "declared-read",
|
|
187
|
+
source: prepared.source,
|
|
188
|
+
data: result,
|
|
189
|
+
};
|
|
97
190
|
}
|
|
98
|
-
const report = await runOperation(client, prepared.operation, {
|
|
99
|
-
|
|
191
|
+
const report = await runOperation(client, prepared.operation, {
|
|
192
|
+
parameters: prepared.params,
|
|
193
|
+
sourceFiles: prepared.source,
|
|
194
|
+
}, options);
|
|
195
|
+
return {
|
|
196
|
+
...report,
|
|
197
|
+
source: prepared.source,
|
|
198
|
+
};
|
|
100
199
|
}
|
package/dist/shots.js
CHANGED
|
@@ -12,8 +12,9 @@ import { pngSize } from "./baselines.js";
|
|
|
12
12
|
import { observationHash } from "./journal.js";
|
|
13
13
|
/** Exact names across the selected page. A match never silently resolves an ambiguity. */
|
|
14
14
|
export async function findShapesByName(client, name, page, options = {}) {
|
|
15
|
-
if (!name || !page)
|
|
15
|
+
if (!name || !page) {
|
|
16
16
|
throw new Error("Name lookup requires an exact name and explicit page");
|
|
17
|
+
}
|
|
17
18
|
const result = await runOnPage(client, snippet("find-shape-name", `${requirePage}
|
|
18
19
|
const matches = [], stack = [...penpot.currentPage.root.children]; let visited = 0;
|
|
19
20
|
while (stack.length && visited < 10000) {
|
|
@@ -21,54 +22,127 @@ while (stack.length && visited < 10000) {
|
|
|
21
22
|
if (shape.name === CFG.name) matches.push({id:shape.id,name:shape.name,type:shape.type});
|
|
22
23
|
for (const child of shape.children || []) stack.push(child);
|
|
23
24
|
}
|
|
24
|
-
return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id,revision:penpot.currentFile.revn,matches,truncated:stack.length>0};`, {
|
|
25
|
-
|
|
25
|
+
return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id,revision:penpot.currentFile.revn,matches,truncated:stack.length>0};`, {
|
|
26
|
+
name,
|
|
27
|
+
page,
|
|
28
|
+
}, "read"), page, options);
|
|
29
|
+
if (result.truncated) {
|
|
26
30
|
throw new PenpairError("incomplete-output", "Shape name search exceeded 10000 nodes; narrow the page");
|
|
27
|
-
|
|
31
|
+
}
|
|
32
|
+
if (result.ok !== true ||
|
|
33
|
+
typeof result.truncated !== "boolean" ||
|
|
34
|
+
typeof result.fileId !== "string" ||
|
|
35
|
+
!result.fileId ||
|
|
36
|
+
typeof result.pageId !== "string" ||
|
|
37
|
+
!result.pageId ||
|
|
38
|
+
!Number.isInteger(result.revision) ||
|
|
39
|
+
!Array.isArray(result.matches) ||
|
|
40
|
+
result.matches.some((match) => !match ||
|
|
41
|
+
typeof match.id !== "string" ||
|
|
42
|
+
typeof match.name !== "string" ||
|
|
43
|
+
typeof match.type !== "string")) {
|
|
28
44
|
throw new Error("Shape search returned incomplete identity evidence");
|
|
45
|
+
}
|
|
29
46
|
return result;
|
|
30
47
|
}
|
|
31
48
|
export async function exportShapeByName(client, name, page, options = {}) {
|
|
32
49
|
const lookup = await findShapesByName(client, name, page, options);
|
|
33
|
-
if (lookup.matches.length !== 1)
|
|
50
|
+
if (lookup.matches.length !== 1) {
|
|
34
51
|
throw new Error(`Exact name ${JSON.stringify(name)} matched ${lookup.matches.length} shapes on ${JSON.stringify(page)}. Candidates: ${JSON.stringify(lookup.matches.slice(0, 20))}. Use an ID for an ambiguous name.`);
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
}
|
|
53
|
+
const result = await exportShapePng(client, lookup.matches[0].id, {
|
|
54
|
+
...options,
|
|
55
|
+
page,
|
|
56
|
+
});
|
|
57
|
+
if (result.capture.fileId !== lookup.fileId ||
|
|
58
|
+
result.capture.pageId !== lookup.pageId ||
|
|
59
|
+
result.capture.revision !== lookup.revision) {
|
|
37
60
|
throw new Error("Named export source changed after lookup; no output was published");
|
|
61
|
+
}
|
|
38
62
|
return result;
|
|
39
63
|
}
|
|
40
64
|
/** Ad-hoc captures use immutable generations, but never carry baseline approval records. */
|
|
41
65
|
export async function captureShots(client, root, params, options = {}) {
|
|
42
|
-
const listing = await runOnPage(client, frames({
|
|
43
|
-
|
|
66
|
+
const listing = await runOnPage(client, frames({
|
|
67
|
+
page: params.page,
|
|
68
|
+
}), params.page, options);
|
|
69
|
+
if (typeof listing.fileId !== "string" ||
|
|
70
|
+
typeof listing.pageId !== "string" ||
|
|
71
|
+
!Number.isInteger(listing.revn) ||
|
|
72
|
+
!Array.isArray(listing.frames)) {
|
|
44
73
|
throw new Error("Shots listing lacks identity/revision evidence");
|
|
74
|
+
}
|
|
45
75
|
const selected = listing.frames.filter((frame) => !params.prefix || frame.name.startsWith(params.prefix));
|
|
46
|
-
const report = {
|
|
47
|
-
|
|
48
|
-
|
|
76
|
+
const report = {
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
complete: false,
|
|
79
|
+
fileId: listing.fileId,
|
|
80
|
+
pageId: listing.pageId,
|
|
81
|
+
revision: Number(listing.revn),
|
|
82
|
+
captures: [],
|
|
83
|
+
wrote: [],
|
|
84
|
+
};
|
|
85
|
+
if (!selected.length || selected.length > 100) {
|
|
86
|
+
return {
|
|
87
|
+
...report,
|
|
88
|
+
reason: !selected.length
|
|
89
|
+
? "No matching top-level frames"
|
|
90
|
+
: "More than 100 frames; narrow the prefix",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
49
93
|
const folder = await resolveProjectOutput(root, params.out ?? "design/shots");
|
|
50
|
-
const runId = randomUUID()
|
|
94
|
+
const runId = randomUUID();
|
|
95
|
+
const generation = `captures/${runId}`;
|
|
51
96
|
const images = [];
|
|
52
97
|
for (const [index, frame] of selected.entries()) {
|
|
53
98
|
options.signal?.throwIfAborted();
|
|
54
99
|
options.onStep?.(`exporting ${JSON.stringify(frame.name)}`);
|
|
55
|
-
const result = await exportShapePng(client, frame.id, {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
100
|
+
const result = await exportShapePng(client, frame.id, {
|
|
101
|
+
...options,
|
|
102
|
+
page: params.page,
|
|
103
|
+
stable: true,
|
|
104
|
+
});
|
|
105
|
+
if (!result.stable) {
|
|
106
|
+
return {
|
|
107
|
+
...report,
|
|
108
|
+
reason: "A frame did not stabilize; no output was published",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (result.capture.fileId !== report.fileId ||
|
|
112
|
+
result.capture.pageId !== report.pageId ||
|
|
113
|
+
result.capture.revision !== report.revision) {
|
|
59
114
|
throw new Error("Shots source changed during capture; no output was published");
|
|
115
|
+
}
|
|
60
116
|
const size = pngSize(result.png);
|
|
61
|
-
if (!size || size.width <= 0 || size.height <= 0)
|
|
117
|
+
if (!size || size.width <= 0 || size.height <= 0) {
|
|
62
118
|
throw new Error("Shot export is missing valid PNG dimensions");
|
|
63
|
-
|
|
119
|
+
}
|
|
120
|
+
report.captures.push({
|
|
121
|
+
layout: result.layout,
|
|
122
|
+
...size,
|
|
123
|
+
name: frame.name,
|
|
124
|
+
file: `${generation}/shot-${String(index + 1).padStart(4, "0")}.png`,
|
|
125
|
+
sha256: createHash("sha256").update(result.png).digest("hex"),
|
|
126
|
+
capture: result.capture,
|
|
127
|
+
});
|
|
64
128
|
images.push(result.png);
|
|
65
129
|
}
|
|
66
|
-
const after = await runOnPage(client, frames({
|
|
67
|
-
|
|
68
|
-
|
|
130
|
+
const after = await runOnPage(client, frames({
|
|
131
|
+
page: params.page,
|
|
132
|
+
}), params.page, options);
|
|
133
|
+
const identity = (entry) => ({
|
|
134
|
+
fileId: entry.fileId,
|
|
135
|
+
pageId: entry.pageId,
|
|
136
|
+
revision: entry.revn,
|
|
137
|
+
frames: entry.frames,
|
|
138
|
+
});
|
|
139
|
+
if (observationHash(identity(listing)) !== observationHash(identity(after))) {
|
|
69
140
|
throw new Error("Shots source changed before publication; previous output was preserved");
|
|
141
|
+
}
|
|
70
142
|
options.signal?.throwIfAborted();
|
|
71
|
-
await mkdir(path.join(folder, "captures"), {
|
|
143
|
+
await mkdir(path.join(folder, "captures"), {
|
|
144
|
+
recursive: true,
|
|
145
|
+
});
|
|
72
146
|
const staged = path.join(folder, `.stage-${runId}`);
|
|
73
147
|
await mkdir(staged);
|
|
74
148
|
try {
|
|
@@ -80,11 +154,19 @@ export async function captureShots(client, root, params, options = {}) {
|
|
|
80
154
|
await rename(staged, path.join(folder, generation));
|
|
81
155
|
options.signal?.throwIfAborted();
|
|
82
156
|
report.complete = true;
|
|
83
|
-
report.wrote = [
|
|
84
|
-
|
|
157
|
+
report.wrote = [
|
|
158
|
+
...report.captures.map((entry) => entry.file),
|
|
159
|
+
"penpair-shots.json",
|
|
160
|
+
];
|
|
161
|
+
await atomicWriteFile(path.join(folder, "penpair-shots.json"), `${JSON.stringify(report, null, 2)}\n`, {
|
|
162
|
+
replace: true,
|
|
163
|
+
});
|
|
85
164
|
return report;
|
|
86
165
|
}
|
|
87
166
|
finally {
|
|
88
|
-
await rm(staged, {
|
|
167
|
+
await rm(staged, {
|
|
168
|
+
recursive: true,
|
|
169
|
+
force: true,
|
|
170
|
+
});
|
|
89
171
|
}
|
|
90
172
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SkillInstructionCheck } from "./skill-check-instructions.js";
|
|
2
|
+
export interface SkillDiagnostic {
|
|
3
|
+
name: string;
|
|
4
|
+
path: string;
|
|
5
|
+
implicitInvocation: boolean | null;
|
|
6
|
+
entrypoint: "current" | "modified" | "unavailable";
|
|
7
|
+
problems: string[];
|
|
8
|
+
warnings: string[];
|
|
9
|
+
}
|
|
10
|
+
interface EntryOptions {
|
|
11
|
+
root: string;
|
|
12
|
+
name: string;
|
|
13
|
+
bundledDirectory: string;
|
|
14
|
+
instructions: SkillInstructionCheck;
|
|
15
|
+
}
|
|
16
|
+
export declare function checkSkillEntry(options: EntryOptions): Promise<SkillDiagnostic>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseSkillMetadata, parseSkillYaml } from "./skill-metadata.js";
|
|
4
|
+
import { readSkillFile } from "./skill-file.js";
|
|
5
|
+
import { resolveSkillLink } from "./skill-check-instructions.js";
|
|
6
|
+
async function invocationAllowed(root, skillPath) {
|
|
7
|
+
const relative = path.posix.join(path.posix.dirname(skillPath), "agents/openai.yaml");
|
|
8
|
+
const present = await lstat(path.join(root, relative)).catch((error) => {
|
|
9
|
+
if (error.code !== "ENOENT") {
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
});
|
|
14
|
+
if (!present) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const metadata = parseSkillYaml(await readSkillFile(root, relative));
|
|
18
|
+
if (metadata.policy === undefined) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (!metadata.policy || typeof metadata.policy !== "object" || Array.isArray(metadata.policy)) {
|
|
22
|
+
throw new Error("Invalid invocation policy");
|
|
23
|
+
}
|
|
24
|
+
const allowed = metadata.policy.allow_implicit_invocation;
|
|
25
|
+
if (allowed !== undefined && typeof allowed !== "boolean") {
|
|
26
|
+
throw new Error("Invocation policy must use a boolean");
|
|
27
|
+
}
|
|
28
|
+
return allowed !== false;
|
|
29
|
+
}
|
|
30
|
+
export async function checkSkillEntry(options) {
|
|
31
|
+
const { root, name, bundledDirectory, instructions } = options;
|
|
32
|
+
const skillPath = path.posix.join(".agents/skills", name, "SKILL.md");
|
|
33
|
+
const report = {
|
|
34
|
+
name,
|
|
35
|
+
path: skillPath,
|
|
36
|
+
implicitInvocation: null,
|
|
37
|
+
entrypoint: "unavailable",
|
|
38
|
+
problems: [],
|
|
39
|
+
warnings: [],
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
const source = await readSkillFile(root, skillPath);
|
|
43
|
+
parseSkillMetadata(source, name);
|
|
44
|
+
const bundled = await readFile(path.join(bundledDirectory, name, "SKILL.md"), "utf8");
|
|
45
|
+
report.entrypoint = source === bundled ? "current" : "modified";
|
|
46
|
+
if (report.entrypoint === "modified") {
|
|
47
|
+
report.warnings.push("The discoverable entrypoint differs from the bundled skill; review intentional customization before refresh.");
|
|
48
|
+
}
|
|
49
|
+
const localLinks = [
|
|
50
|
+
...source.matchAll(/\[[^\]]+\]\(([^)]+)\)/g),
|
|
51
|
+
]
|
|
52
|
+
.map((match) => match[1])
|
|
53
|
+
.filter((target) => target && !target.startsWith("#") && !/^[a-z][a-z0-9+.-]*:/i.test(target));
|
|
54
|
+
if (localLinks.length > 100) {
|
|
55
|
+
throw new Error("Too many local resource links");
|
|
56
|
+
}
|
|
57
|
+
for (const target of localLinks) {
|
|
58
|
+
await resolveSkillLink(root, skillPath, target);
|
|
59
|
+
}
|
|
60
|
+
report.implicitInvocation = await invocationAllowed(root, skillPath);
|
|
61
|
+
if (!report.implicitInvocation) {
|
|
62
|
+
report.problems.push("Implicit invocation is disabled in openai.yaml; explicit invocation remains available.");
|
|
63
|
+
}
|
|
64
|
+
for (const folder of [
|
|
65
|
+
".skills",
|
|
66
|
+
".claude/skills",
|
|
67
|
+
]) {
|
|
68
|
+
const otherPath = path.posix.join(folder, name, "SKILL.md");
|
|
69
|
+
const other = await readSkillFile(root, otherPath).catch(() => undefined);
|
|
70
|
+
if (other !== undefined && other !== source) {
|
|
71
|
+
report.problems.push("Codex and another installed skill copy differ; review the conflicting copies before refreshing.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const routes = instructions.links[name] ?? [];
|
|
75
|
+
if (!routes.length) {
|
|
76
|
+
report.problems.push("No generated task route links this skill from the selected instruction file.");
|
|
77
|
+
}
|
|
78
|
+
const resolvedSkill = await realpath(path.join(root, skillPath));
|
|
79
|
+
for (const target of routes) {
|
|
80
|
+
if ((await resolveSkillLink(root, instructions.path, target)) !== resolvedSkill) {
|
|
81
|
+
report.problems.push("An instruction route points to a different skill copy.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
report.problems.push("Skill discovery, frontmatter, local resources or UI policy is missing, invalid or resolves outside the project; inspect the local files and preview a skill refresh.");
|
|
87
|
+
}
|
|
88
|
+
return report;
|
|
89
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface SkillInstructionCheck {
|
|
2
|
+
path: string;
|
|
3
|
+
problems: string[];
|
|
4
|
+
links: Record<string, string[]>;
|
|
5
|
+
}
|
|
6
|
+
export declare function resolveSkillLink(root: string, from: string, target: string): Promise<string>;
|
|
7
|
+
export declare function checkSkillInstructions(root: string, instructionFile: string): Promise<SkillInstructionCheck>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readSkillFile } from "./skill-file.js";
|
|
3
|
+
import { skillInstructionMarkers } from "./skill-instructions.js";
|
|
4
|
+
import { resolveProjectInput } from "./project-input.js";
|
|
5
|
+
export async function resolveSkillLink(root, from, target) {
|
|
6
|
+
const decoded = decodeURIComponent(target.split("#")[0]);
|
|
7
|
+
if (!decoded || path.isAbsolute(decoded) || /^[a-z][a-z0-9+.-]*:/i.test(decoded)) {
|
|
8
|
+
throw new Error("Skill resource link must resolve inside the project");
|
|
9
|
+
}
|
|
10
|
+
const relative = path.relative(root, path.resolve(root, path.dirname(from), decoded));
|
|
11
|
+
return resolveProjectInput(root, relative);
|
|
12
|
+
}
|
|
13
|
+
export async function checkSkillInstructions(root, instructionFile) {
|
|
14
|
+
const report = {
|
|
15
|
+
path: instructionFile,
|
|
16
|
+
problems: [],
|
|
17
|
+
links: {},
|
|
18
|
+
};
|
|
19
|
+
try {
|
|
20
|
+
const resolved = await resolveProjectInput(root, instructionFile);
|
|
21
|
+
report.path = path.relative(root, resolved).split(path.sep).join("/");
|
|
22
|
+
const source = await readSkillFile(root, report.path);
|
|
23
|
+
const { start, end } = skillInstructionMarkers;
|
|
24
|
+
if (source.split(start).length !== 2 ||
|
|
25
|
+
source.split(end).length !== 2 ||
|
|
26
|
+
source.indexOf(start) > source.indexOf(end)) {
|
|
27
|
+
throw new Error("Missing or malformed installer-owned skill routing block");
|
|
28
|
+
}
|
|
29
|
+
const block = source.slice(source.indexOf(start) + start.length, source.indexOf(end));
|
|
30
|
+
const routing = block.split("| Skill | Purpose |")[0];
|
|
31
|
+
for (const match of routing.matchAll(/\[([a-z0-9-]+)\]\(([^)]+)\)/g)) {
|
|
32
|
+
(report.links[match[1]] ??= []).push(match[2]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
report.problems.push("Project instructions are missing, unreadable or lack a valid owned routing block; preview skills install --agents with this destination.");
|
|
37
|
+
}
|
|
38
|
+
return report;
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type SkillDiagnostic } from "./skill-check-entry.js";
|
|
2
|
+
import { type SkillInstructionCheck } from "./skill-check-instructions.js";
|
|
3
|
+
export interface SkillCheckOptions {
|
|
4
|
+
root: string;
|
|
5
|
+
agents?: string;
|
|
6
|
+
only?: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface SkillCheckReport {
|
|
9
|
+
agent: "codex";
|
|
10
|
+
ready: boolean;
|
|
11
|
+
instructions: Pick<SkillInstructionCheck, "path" | "problems">;
|
|
12
|
+
skills: SkillDiagnostic[];
|
|
13
|
+
limitations: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function checkSkills(options: SkillCheckOptions): Promise<SkillCheckReport>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { bundledSkillsDirectory, listBundledSkills } from "./skills.js";
|
|
3
|
+
import { checkSkillEntry } from "./skill-check-entry.js";
|
|
4
|
+
import { checkSkillInstructions } from "./skill-check-instructions.js";
|
|
5
|
+
export async function checkSkills(options) {
|
|
6
|
+
const root = await realpath(options.root);
|
|
7
|
+
const bundledDirectory = bundledSkillsDirectory();
|
|
8
|
+
const names = await listBundledSkills(bundledDirectory);
|
|
9
|
+
const selected = options.only ?? names;
|
|
10
|
+
if (!selected.length || selected.some((name) => !names.includes(name))) {
|
|
11
|
+
throw new Error("Select one or more bundled skill names; use penpair skills to list them");
|
|
12
|
+
}
|
|
13
|
+
const instructions = await checkSkillInstructions(root, options.agents ?? "AGENTS.md");
|
|
14
|
+
const skills = await Promise.all([
|
|
15
|
+
...new Set(selected),
|
|
16
|
+
].map((name) => checkSkillEntry({
|
|
17
|
+
root,
|
|
18
|
+
name,
|
|
19
|
+
bundledDirectory,
|
|
20
|
+
instructions,
|
|
21
|
+
})));
|
|
22
|
+
return {
|
|
23
|
+
agent: "codex",
|
|
24
|
+
ready: !instructions.problems.length && skills.every((skill) => !skill.problems.length),
|
|
25
|
+
instructions: {
|
|
26
|
+
path: instructions.path,
|
|
27
|
+
problems: instructions.problems,
|
|
28
|
+
},
|
|
29
|
+
skills,
|
|
30
|
+
limitations: [
|
|
31
|
+
"Offline project-file checks only; no connection, adapter/provider execution, credential reads or writes.",
|
|
32
|
+
"Does not inspect global Codex settings, session skill catalogs, context budgets or UI selection badges.",
|
|
33
|
+
"A valid install and allowed invocation policy do not prove model selection, correct workflow application, visual quality or owner approval.",
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { resolveProjectInput } from "./project-input.js";
|
|
4
|
+
import { maxSkillBytes } from "./skill-metadata.js";
|
|
5
|
+
/** Read only bounded regular files inside the explicitly selected project. */
|
|
6
|
+
export async function readSkillFile(root, relative) {
|
|
7
|
+
const resolved = await resolveProjectInput(root, relative);
|
|
8
|
+
const handle = await open(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
9
|
+
try {
|
|
10
|
+
const info = await handle.stat();
|
|
11
|
+
if (!info.isFile() || info.size > maxSkillBytes) {
|
|
12
|
+
throw new Error("Skill input must be a regular file within the 128 KB limit");
|
|
13
|
+
}
|
|
14
|
+
const buffer = Buffer.alloc(maxSkillBytes + 1);
|
|
15
|
+
let length = 0;
|
|
16
|
+
while (length < buffer.length) {
|
|
17
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
|
18
|
+
if (!bytesRead) {
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
length += bytesRead;
|
|
22
|
+
}
|
|
23
|
+
if (length > maxSkillBytes) {
|
|
24
|
+
throw new Error("Skill input exceeds the 128 KB limit");
|
|
25
|
+
}
|
|
26
|
+
return buffer.subarray(0, length).toString("utf8");
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await handle.close();
|
|
30
|
+
}
|
|
31
|
+
}
|