@penpair/core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.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 +1621 -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-instructions.js +91 -22
- package/dist/skills.js +129 -39
- 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 +1 -1
- package/skills/penpair-design/SKILL.md +3 -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 +2 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +5 -0
- package/skills/penpair-setup/SKILL.md +3 -1
- 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
|
}
|
|
@@ -2,15 +2,17 @@ import { lstat, readFile, realpath } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { resolveProjectOutput } from "./project-input.js";
|
|
4
4
|
import { atomicWriteFile } from "./atomic-output.js";
|
|
5
|
-
const start = "<!-- penpair:skills:start -->"
|
|
5
|
+
const start = "<!-- penpair:skills:start -->";
|
|
6
|
+
const end = "<!-- penpair:skills:end -->";
|
|
6
7
|
async function exists(file) {
|
|
7
8
|
try {
|
|
8
9
|
await lstat(file);
|
|
9
10
|
return true;
|
|
10
11
|
}
|
|
11
12
|
catch (error) {
|
|
12
|
-
if (error.code === "ENOENT")
|
|
13
|
+
if (error.code === "ENOENT") {
|
|
13
14
|
return false;
|
|
15
|
+
}
|
|
14
16
|
throw error;
|
|
15
17
|
}
|
|
16
18
|
}
|
|
@@ -18,11 +20,23 @@ export async function planSkillInstructions(root, rows, destination) {
|
|
|
18
20
|
let selected = destination;
|
|
19
21
|
if (!selected) {
|
|
20
22
|
const candidates = [];
|
|
21
|
-
for (const name of [
|
|
22
|
-
|
|
23
|
+
for (const name of [
|
|
24
|
+
"AGENTS.md",
|
|
25
|
+
"CLAUDE.md",
|
|
26
|
+
]) {
|
|
27
|
+
if (await exists(path.join(root, name))) {
|
|
23
28
|
candidates.push(name);
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (candidates.length !== 1) {
|
|
32
|
+
return {
|
|
33
|
+
report: {
|
|
34
|
+
path: null,
|
|
35
|
+
status: candidates.length ? "choice-required" : "not-selected",
|
|
36
|
+
diff: "",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
26
40
|
selected = candidates[0];
|
|
27
41
|
}
|
|
28
42
|
const canonicalRoot = await realpath(root);
|
|
@@ -32,44 +46,99 @@ export async function planSkillInstructions(root, rows, destination) {
|
|
|
32
46
|
before = await readFile(file, "utf8");
|
|
33
47
|
}
|
|
34
48
|
catch (error) {
|
|
35
|
-
if (error.code !== "ENOENT")
|
|
49
|
+
if (error.code !== "ENOENT") {
|
|
36
50
|
throw error;
|
|
51
|
+
}
|
|
37
52
|
}
|
|
38
53
|
const text = before ?? "";
|
|
39
|
-
const starts = text.split(start).length - 1
|
|
40
|
-
|
|
54
|
+
const starts = text.split(start).length - 1;
|
|
55
|
+
const ends = text.split(end).length - 1;
|
|
56
|
+
if (starts !== ends || starts > 1 || (starts === 1 && text.indexOf(start) > text.indexOf(end))) {
|
|
41
57
|
throw new Error("Malformed penpair skills markers; repair them before installation (no instruction text was changed)");
|
|
58
|
+
}
|
|
42
59
|
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
43
60
|
const relativeRows = rows.map((row) => row.replace(/\]\(([^)]+)\)/g, (_match, target) => {
|
|
44
|
-
const relative = path
|
|
45
|
-
|
|
61
|
+
const relative = path
|
|
62
|
+
.relative(path.dirname(file), path.resolve(canonicalRoot, decodeURIComponent(target)))
|
|
63
|
+
.split(path.sep)
|
|
64
|
+
.join("/");
|
|
65
|
+
const link = relative
|
|
66
|
+
.split("/")
|
|
67
|
+
.map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`))
|
|
68
|
+
.join("/");
|
|
46
69
|
return `](${link})`;
|
|
47
70
|
}));
|
|
48
|
-
const block = [
|
|
71
|
+
const block = [
|
|
72
|
+
start,
|
|
73
|
+
"| Skill | Purpose |",
|
|
74
|
+
"| --- | --- |",
|
|
75
|
+
...relativeRows,
|
|
76
|
+
end,
|
|
77
|
+
].join(newline);
|
|
49
78
|
let after;
|
|
50
|
-
if (starts === 1)
|
|
79
|
+
if (starts === 1) {
|
|
51
80
|
after = text.slice(0, text.indexOf(start)) + block + text.slice(text.indexOf(end) + end.length);
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
after =
|
|
84
|
+
text +
|
|
85
|
+
(text ? (text.endsWith(newline) ? newline : newline + newline) : "") +
|
|
86
|
+
"## Penpair skills" +
|
|
87
|
+
newline +
|
|
88
|
+
newline +
|
|
89
|
+
block +
|
|
90
|
+
newline;
|
|
91
|
+
}
|
|
54
92
|
const changed = text !== after;
|
|
55
93
|
// Diff only the owned region, never repeat unrelated instruction content in reports.
|
|
56
94
|
const oldBlock = starts ? text.slice(text.indexOf(start), text.indexOf(end) + end.length) : "";
|
|
57
|
-
const diff = changed
|
|
58
|
-
|
|
95
|
+
const diff = changed
|
|
96
|
+
? [
|
|
97
|
+
`--- ${file}`,
|
|
98
|
+
`+++ ${file}`,
|
|
99
|
+
"@@ penpair-owned skills block @@",
|
|
100
|
+
...oldBlock
|
|
101
|
+
.split(/\r?\n/)
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.map((line) => `-${line}`),
|
|
104
|
+
...(starts ? block : "## Penpair skills" + newline + newline + block)
|
|
105
|
+
.split(/\r?\n/)
|
|
106
|
+
.map((line) => `+${line}`),
|
|
107
|
+
].join("\n")
|
|
108
|
+
: "";
|
|
109
|
+
return {
|
|
110
|
+
before,
|
|
111
|
+
after,
|
|
112
|
+
mode: before === undefined ? 0o644 : (await lstat(file)).mode & 0o777,
|
|
113
|
+
report: {
|
|
114
|
+
path: file,
|
|
115
|
+
status: changed ? "preview" : "unchanged",
|
|
116
|
+
diff,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
59
119
|
}
|
|
60
120
|
export async function applySkillInstructions(plan) {
|
|
61
|
-
if (plan.report.status !== "preview" || !plan.report.path || plan.after === undefined)
|
|
121
|
+
if (plan.report.status !== "preview" || !plan.report.path || plan.after === undefined) {
|
|
62
122
|
return plan.report;
|
|
123
|
+
}
|
|
63
124
|
let current;
|
|
64
125
|
try {
|
|
65
126
|
current = await readFile(plan.report.path, "utf8");
|
|
66
127
|
}
|
|
67
128
|
catch (error) {
|
|
68
|
-
if (error.code !== "ENOENT")
|
|
129
|
+
if (error.code !== "ENOENT") {
|
|
69
130
|
throw error;
|
|
131
|
+
}
|
|
70
132
|
}
|
|
71
|
-
if (current !== plan.before)
|
|
133
|
+
if (current !== plan.before) {
|
|
72
134
|
throw new Error("Instruction file changed after planning; nothing was overwritten");
|
|
73
|
-
|
|
74
|
-
|
|
135
|
+
}
|
|
136
|
+
await atomicWriteFile(plan.report.path, plan.after, {
|
|
137
|
+
replace: plan.before !== undefined,
|
|
138
|
+
mode: plan.mode,
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
...plan.report,
|
|
142
|
+
status: "written",
|
|
143
|
+
};
|
|
75
144
|
}
|