@penpair/core 0.1.3 → 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.
Files changed (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
package/dist/skills.js CHANGED
@@ -1,22 +1,37 @@
1
- import { lstat, mkdir, readdir, readFile, symlink, writeFile } from "node:fs/promises";
1
+ import { atomicWriteFile } from "./atomic-output.js";
2
+ import { resolveProjectOutput } from "./project-input.js";
3
+ import { planSkillInstructions, applySkillInstructions, } from "./skill-instructions.js";
4
+ import { lstat, mkdir, readdir, readFile, symlink } from "node:fs/promises";
2
5
  import path from "node:path";
3
6
  import { fileURLToPath } from "node:url";
4
7
  /** The skills shipped inside the package (`skills/` next to `dist/`). */
5
8
  export const bundledSkillsDirectory = () => path.join(path.dirname(fileURLToPath(import.meta.url)), "../skills");
6
9
  /** Files a project fills in. They are written once and never replaced, not even with --force. */
7
- const projectOwned = new Set(["references/project-facts.md", "PROJECT.md"]);
10
+ const projectOwned = new Set([
11
+ "references/project-facts.md",
12
+ "PROJECT.md",
13
+ ]);
8
14
  export async function listBundledSkills(directory = bundledSkillsDirectory()) {
9
- const entries = await readdir(directory, { withFileTypes: true });
10
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
15
+ const entries = await readdir(directory, {
16
+ withFileTypes: true,
17
+ });
18
+ return entries
19
+ .filter((entry) => entry.isDirectory())
20
+ .map((entry) => entry.name)
21
+ .sort();
11
22
  }
12
23
  async function filesUnder(directory, prefix = "") {
13
24
  const out = [];
14
- for (const entry of await readdir(path.join(directory, prefix), { withFileTypes: true })) {
25
+ for (const entry of await readdir(path.join(directory, prefix), {
26
+ withFileTypes: true,
27
+ })) {
15
28
  const relative = path.posix.join(prefix, entry.name);
16
- if (entry.isDirectory())
29
+ if (entry.isDirectory()) {
17
30
  out.push(...(await filesUnder(directory, relative)));
18
- else
31
+ }
32
+ else {
19
33
  out.push(relative);
34
+ }
20
35
  }
21
36
  return out.sort();
22
37
  }
@@ -24,8 +39,11 @@ async function readIfExists(file) {
24
39
  try {
25
40
  return await readFile(file, "utf8");
26
41
  }
27
- catch {
28
- return undefined;
42
+ catch (error) {
43
+ if (error.code === "ENOENT") {
44
+ return undefined;
45
+ }
46
+ throw error;
29
47
  }
30
48
  }
31
49
  async function exists(file) {
@@ -33,8 +51,11 @@ async function exists(file) {
33
51
  await lstat(file);
34
52
  return true;
35
53
  }
36
- catch {
37
- return false;
54
+ catch (error) {
55
+ if (error.code === "ENOENT") {
56
+ return false;
57
+ }
58
+ throw error;
38
59
  }
39
60
  }
40
61
  /** The skill's name and the first sentence of its description, from the frontmatter, for a row in a project's agent guide. */
@@ -42,17 +63,31 @@ export async function describeSkill(skillDirectory) {
42
63
  const text = (await readIfExists(path.join(skillDirectory, "SKILL.md"))) ?? "";
43
64
  const name = /^name:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? path.basename(skillDirectory);
44
65
  const description = /^description:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "";
45
- return { name, summary: description.split(/(?<=\.)\s/)[0] ?? description };
66
+ return {
67
+ name,
68
+ summary: description.split(/(?<=\.)\s/)[0] ?? description,
69
+ };
46
70
  }
47
71
  /** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
48
- export const renamedSkills = { "penpot-design": "penpair-design" };
72
+ export const renamedSkills = {
73
+ "penpot-design": "penpair-design",
74
+ };
49
75
  /** Where the penpair skills already live in a project as real folders (not links), so a later install goes to the same place. */
50
76
  export async function existingSkillsFolder(root) {
51
- for (const folder of [".skills", ".claude/skills", ".agents/skills"]) {
52
- for (const name of ["penpair-design", "penpair-setup", ...Object.keys(renamedSkills)]) {
77
+ for (const folder of [
78
+ ".skills",
79
+ ".claude/skills",
80
+ ".agents/skills",
81
+ ]) {
82
+ for (const name of [
83
+ "penpair-design",
84
+ "penpair-setup",
85
+ ...Object.keys(renamedSkills),
86
+ ]) {
53
87
  const found = await lstat(path.join(root, folder, name)).catch(() => undefined);
54
- if (found?.isDirectory())
88
+ if (found?.isDirectory()) {
55
89
  return folder;
90
+ }
56
91
  }
57
92
  }
58
93
  return undefined;
@@ -60,58 +95,140 @@ export async function existingSkillsFolder(root) {
60
95
  export async function installSkills(options) {
61
96
  const source = options.source ?? bundledSkillsDirectory();
62
97
  const to = options.to ?? (await existingSkillsFolder(options.root)) ?? ".skills";
98
+ await resolveProjectOutput(options.root, to);
63
99
  const names = await listBundledSkills(source);
64
- const chosen = options.only ? options.only : names;
65
- for (const name of chosen)
66
- if (!names.includes(name))
100
+ const chosen = options.only ?? names;
101
+ for (const name of chosen) {
102
+ if (!names.includes(name)) {
67
103
  throw new Error(`no skill named ${name}; shipped: ${names.join(", ")}`);
68
- const report = { to, installed: [], unchanged: [], kept: [], linked: [], agentsRows: [], legacy: [] };
104
+ }
105
+ }
106
+ const rows = await Promise.all(chosen.map(async (name) => {
107
+ const { summary } = await describeSkill(path.join(source, name));
108
+ const link = path.posix
109
+ .join(to, name, "SKILL.md")
110
+ .split("/")
111
+ .map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`))
112
+ .join("/");
113
+ return `| [${name}](${link}) | ${summary.replace(/\|/g, "\\|")} |`;
114
+ }));
115
+ const instructionPlan = await planSkillInstructions(options.root, rows, options.agents);
116
+ const report = {
117
+ to,
118
+ installed: [],
119
+ unchanged: [],
120
+ kept: [],
121
+ linked: [],
122
+ agentsRows: rows,
123
+ legacy: [],
124
+ instructions: instructionPlan.report,
125
+ ...(options.dryRun
126
+ ? {
127
+ dryRun: true,
128
+ wouldInstall: [],
129
+ wouldLink: [],
130
+ }
131
+ : {}),
132
+ };
133
+ const writes = new Map();
134
+ const links = [];
69
135
  for (const name of chosen) {
70
136
  const from = path.join(source, name);
71
137
  for (const file of await filesUnder(from)) {
72
- const target = path.join(options.root, to, name, file);
138
+ const target = await resolveProjectOutput(options.root, path.join(to, name, file));
73
139
  const shipped = await readFile(path.join(from, file), "utf8");
74
140
  const present = await readIfExists(target);
75
141
  const label = path.posix.join(to, name, file);
76
- if (present === undefined) {
77
- await mkdir(path.dirname(target), { recursive: true });
78
- await writeFile(target, shipped);
79
- report.installed.push(label);
80
- }
81
- else if (present === shipped)
142
+ if (present === shipped) {
82
143
  report.unchanged.push(label);
83
- else if (options.force && !projectOwned.has(file)) {
84
- await writeFile(target, shipped);
85
- report.installed.push(label);
86
144
  }
87
- else
145
+ else if (present === undefined || (options.force && !projectOwned.has(file))) {
146
+ writes.set(target, {
147
+ label,
148
+ before: present,
149
+ content: shipped,
150
+ });
151
+ }
152
+ else {
88
153
  report.kept.push(label);
154
+ }
89
155
  }
90
156
  if (options.links !== false) {
91
- for (const hub of [".agents/skills", ".claude/skills"]) {
157
+ for (const hub of [
158
+ ".agents/skills",
159
+ ".claude/skills",
160
+ ]) {
92
161
  const link = path.join(options.root, hub, name);
93
- if (await exists(link))
162
+ if (path.resolve(link) === path.resolve(options.root, to, name) || (await exists(link))) {
163
+ continue;
164
+ }
165
+ const safe = await resolveProjectOutput(options.root, path.join(hub, name));
166
+ const target = await resolveProjectOutput(options.root, path.join(to, name));
167
+ if (safe === target) {
94
168
  continue;
95
- await mkdir(path.dirname(link), { recursive: true });
96
- await symlink(path.relative(path.dirname(link), path.join(options.root, to, name)), link);
97
- report.linked.push(path.posix.join(hub, name));
169
+ }
170
+ links.push({
171
+ file: safe,
172
+ target: path.relative(path.dirname(safe), target),
173
+ label: path.posix.join(hub, name),
174
+ });
98
175
  }
99
176
  }
100
- const { summary } = await describeSkill(from);
101
- report.agentsRows.push(`| [${name}](${path.posix.join(to, name, "SKILL.md")}) | ${summary} |`);
102
177
  }
103
178
  for (const [oldName, newName] of Object.entries(renamedSkills)) {
104
- if (!chosen.includes(newName) || !(await exists(path.join(options.root, to, oldName))))
179
+ if (!chosen.includes(newName) || !(await exists(path.join(options.root, to, oldName)))) {
105
180
  continue;
106
- // The project-facts file is the project's own work: carry it over when the new copy is still the empty template.
107
- const oldFacts = await readIfExists(path.join(options.root, to, oldName, "references/project-facts.md"));
108
- const newFactsFile = path.join(options.root, to, newName, "references/project-facts.md");
181
+ }
182
+ const oldFacts = await readIfExists(await resolveProjectOutput(options.root, path.join(to, oldName, "references/project-facts.md")));
183
+ const newFactsFile = await resolveProjectOutput(options.root, path.join(to, newName, "references/project-facts.md"));
109
184
  const template = await readIfExists(path.join(source, newName, "references/project-facts.md"));
110
- const current = await readIfExists(newFactsFile);
185
+ const current = writes.get(newFactsFile)?.content ?? (await readIfExists(newFactsFile));
111
186
  const carriedFacts = oldFacts !== undefined && oldFacts !== template && current === template;
112
- if (carriedFacts)
113
- await writeFile(newFactsFile, oldFacts);
114
- report.legacy.push({ folder: path.posix.join(to, oldName), renamedTo: newName, carriedFacts });
187
+ if (carriedFacts) {
188
+ writes.set(newFactsFile, {
189
+ label: path.posix.join(to, newName, "references/project-facts.md"),
190
+ before: await readIfExists(newFactsFile),
191
+ content: oldFacts,
192
+ });
193
+ }
194
+ report.legacy.push({
195
+ folder: path.posix.join(to, oldName),
196
+ renamedTo: newName,
197
+ carriedFacts: carriedFacts && !options.dryRun,
198
+ });
199
+ }
200
+ if (options.dryRun) {
201
+ report.wouldInstall = [
202
+ ...writes.values(),
203
+ ].map((write) => write.label);
204
+ report.wouldLink = links.map((link) => link.label);
205
+ return report;
206
+ }
207
+ for (const [file, write] of writes) {
208
+ if ((await readIfExists(file)) !== write.before) {
209
+ throw new Error("A skill file changed after planning; nothing overwrote that file");
210
+ }
211
+ await mkdir(path.dirname(file), {
212
+ recursive: true,
213
+ });
214
+ await atomicWriteFile(file, write.content, {
215
+ replace: write.before !== undefined,
216
+ mode: write.before === undefined ? 0o644 : (await lstat(file)).mode & 0o777,
217
+ });
218
+ report.installed.push(write.label);
219
+ }
220
+ for (const link of links) {
221
+ await mkdir(path.dirname(link.file), {
222
+ recursive: true,
223
+ });
224
+ await symlink(link.target, link.file);
225
+ report.linked.push(link.label);
226
+ }
227
+ if (instructionPlan.report.path) {
228
+ await mkdir(path.dirname(instructionPlan.report.path), {
229
+ recursive: true,
230
+ });
115
231
  }
232
+ report.instructions = await applySkillInstructions(instructionPlan);
116
233
  return report;
117
234
  }
package/dist/snippet.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ExecuteOptions, type PenpotToolClient } from "./client.js";
2
2
  /** Plugin source ready to send, with the parameters already injected. */
3
3
  export interface Snippet {
4
+ effect?: ExecuteOptions["effect"];
4
5
  name: string;
5
6
  code: string;
6
7
  }
@@ -10,8 +11,8 @@ export interface Snippet {
10
11
  */
11
12
  export declare function wrap(body: string, params?: unknown): string;
12
13
  /** Plugin code for snippets that work on one page: they say so instead of acting on whatever page is open. */
13
- export declare const requirePage = "if (CFG.page && penpot.currentPage.name !== CFG.page) return { ok: false, wrongPage: true, current: penpot.currentPage.name, message: \"the editor shows \" + penpot.currentPage.name + \", not \" + CFG.page };";
14
- export declare function snippet(name: string, body: string, params?: unknown): Snippet;
14
+ export declare const requirePage = "if (CFG.page && penpot.currentFile?.pages?.filter((p) => p.name === CFG.page).length > 1) return { ok:false, message:\"page name is ambiguous; rename pages before selecting by name\" };\nif (CFG.page && penpot.currentPage.name !== CFG.page) return { ok: false, wrongPage: true, current: penpot.currentPage.name, message: \"the editor shows \" + penpot.currentPage.name + \", not \" + CFG.page };";
15
+ export declare function snippet(name: string, body: string, params?: unknown, effect?: ExecuteOptions["effect"]): Snippet;
15
16
  /** Whether the plugin source is valid JavaScript for an async function. Catches typos without a Penpot connection. */
16
17
  export declare function compiles(code: string): string | undefined;
17
18
  export interface SnippetResult {
package/dist/snippet.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
- import { executeCode, maxSnippetBytes } from "./client.js";
2
+ import { executeCode, maxSnippetBytes, UncertainExecutionError, } from "./client.js";
3
3
  import { PenpairError } from "./errors.js";
4
4
  /**
5
5
  * Wraps snippet body text for the Penpot editor: the parameters arrive as `CFG`, and any error comes back as
@@ -9,12 +9,18 @@ export function wrap(body, params = {}) {
9
9
  return `const CFG = ${JSON.stringify(params)};\ntry {\n${body}\n} catch (error) { return { ok: false, message: String(error && error.message || error) }; }`;
10
10
  }
11
11
  /** Plugin code for snippets that work on one page: they say so instead of acting on whatever page is open. */
12
- export const requirePage = `if (CFG.page && penpot.currentPage.name !== CFG.page) return { ok: false, wrongPage: true, current: penpot.currentPage.name, message: "the editor shows " + penpot.currentPage.name + ", not " + CFG.page };`;
13
- export function snippet(name, body, params = {}) {
12
+ export const requirePage = `if (CFG.page && penpot.currentFile?.pages?.filter((p) => p.name === CFG.page).length > 1) return { ok:false, message:"page name is ambiguous; rename pages before selecting by name" };
13
+ if (CFG.page && penpot.currentPage.name !== CFG.page) return { ok: false, wrongPage: true, current: penpot.currentPage.name, message: "the editor shows " + penpot.currentPage.name + ", not " + CFG.page };`;
14
+ export function snippet(name, body, params = {}, effect = "unknown") {
14
15
  const code = wrap(body, params);
15
- if (Buffer.byteLength(code, "utf8") > maxSnippetBytes)
16
+ if (Buffer.byteLength(code, "utf8") > maxSnippetBytes) {
16
17
  throw new PenpairError("snippet-too-large", `Snippet ${name} is ${Buffer.byteLength(code, "utf8")} bytes`);
17
- return { name, code };
18
+ }
19
+ return {
20
+ name,
21
+ code,
22
+ effect,
23
+ };
18
24
  }
19
25
  /** Whether the plugin source is valid JavaScript for an async function. Catches typos without a Penpot connection. */
20
26
  export function compiles(code) {
@@ -29,9 +35,27 @@ export function compiles(code) {
29
35
  }
30
36
  /** Runs a snippet and returns its result record. A snippet that reports `ok: false` throws, except a wrong page, which the caller handles. */
31
37
  export async function runSnippet(client, s, options) {
32
- const { result } = await executeCode(client, s.code, options);
33
- const record = typeof result === "object" && result !== null ? result : { ok: true, value: result };
34
- if (record.ok === false && !record.wrongPage)
38
+ const { result, runId, metrics } = await executeCode(client, s.code, {
39
+ effect: s.effect,
40
+ ...options,
41
+ });
42
+ const record = typeof result === "object" && result !== null && !Array.isArray(result)
43
+ ? result
44
+ : {
45
+ value: result,
46
+ };
47
+ if (record.ok === false && !record.wrongPage) {
48
+ if ((options?.effect ?? s.effect) !== "read") {
49
+ throw new UncertainExecutionError(runId, `${s.name}: ${record.message ?? "failed"}`);
50
+ }
35
51
  throw new PenpairError("tool-failed", `${s.name}: ${record.message ?? "failed"}`);
36
- return record;
52
+ }
53
+ return {
54
+ ...record,
55
+ execution: {
56
+ runId,
57
+ outcome: "ran",
58
+ metrics,
59
+ },
60
+ };
37
61
  }
@@ -1,5 +1,14 @@
1
1
  import { type Snippet } from "../snippet.js";
2
2
  export interface ArchiveParams {
3
+ expectedPlan?: {
4
+ id: string;
5
+ name: string;
6
+ x: number;
7
+ y: number;
8
+ w: number;
9
+ h: number;
10
+ }[];
11
+ expectedArchiveId?: string | null;
3
12
  page: string;
4
13
  /** Name of the board the archived frames are nested into. */
5
14
  archiveName: string;
@@ -4,17 +4,21 @@ const select = `${requirePage}
4
4
  const top = penpot.currentPage.root.children;
5
5
  const isPart = (n) => CFG.partPrefixes.some((p) => n.startsWith(p));
6
6
  const isKept = (n) => CFG.keepPrefixes.some((p) => n.startsWith(p));
7
- const existing = top.find((c) => c.name === CFG.archiveName && c.type === "board");
7
+ const archives = top.filter((c) => c.name === CFG.archiveName && c.type === "board");
8
+ if (archives.length > 1) return { ok: false, message: "archive name is ambiguous" };
9
+ const existing = archives[0];
8
10
  const candidates = top.filter((c) => c.type === "board" && c !== existing && !isPart(c.name) && !isKept(c.name));
9
11
  const plan = candidates.map((c) => ({ id: c.id, name: c.name, x: Math.round(c.x), y: Math.round(c.y), w: Math.round(c.width), h: Math.round(c.height) }));`;
10
12
  /** Read-only: what would move and what stays. */
11
13
  export function archiveInspect(params) {
12
14
  return snippet("archive-inspect", `${select}
13
- return { ok: true, readOnly: true, archiveExists: !!existing, willMove: plan.length, stays: top.filter((c) => c !== existing && !candidates.includes(c)).length, plan };`, params);
15
+ return { ok: true, readOnly: true, archiveExists: !!existing, archiveId: existing ? existing.id : null, insideIds: existing ? existing.children.map((c) => c.id) : [], willMove: plan.length, stays: top.filter((c) => c !== existing && !candidates.includes(c)).length, plan };`, params);
14
16
  }
15
17
  /** One write: nests the frames in the archive board and moves the board aside. Safe to run again; frames already inside are not touched. */
16
18
  export function archiveApply(params) {
17
19
  return snippet("archive-apply", `${select}
20
+ if (CFG.expectedPlan && JSON.stringify(plan) !== JSON.stringify(CFG.expectedPlan)) return { ok: false, message: "Archive source changed since inspection; no frames were moved" };
21
+ if (Object.prototype.hasOwnProperty.call(CFG, "expectedArchiveId") && CFG.expectedArchiveId !== (existing ? existing.id : null)) return { ok: false, message: "Archive target changed since inspection; no frames were moved" };
18
22
  if (plan.length === 0) return { ok: true, moved: 0, archiveId: existing ? existing.id : null };
19
23
  const minX = Math.min(...plan.map((p) => p.x)), minY = Math.min(...plan.map((p) => p.y));
20
24
  const maxX = Math.max(...plan.map((p) => p.x + p.w)), maxY = Math.max(...plan.map((p) => p.y + p.h));
@@ -32,10 +36,12 @@ return { ok: true, submitted: true, moved: plan.length, archiveId: archive.id };
32
36
  export function archiveReadback(params) {
33
37
  return snippet("archive-readback", `${requirePage}
34
38
  const top = penpot.currentPage.root.children;
35
- const archive = top.find((c) => c.name === CFG.archiveName && c.type === "board");
39
+ const archives = top.filter((c) => c.name === CFG.archiveName && c.type === "board");
40
+ if (archives.length > 1) return { ok: false, message: "archive name is ambiguous" };
41
+ const archive = archives[0];
36
42
  const inside = archive ? archive.children.map((c) => c.id) : [];
37
43
  const isPart = (n) => CFG.partPrefixes.some((p) => n.startsWith(p));
38
44
  const isKept = (n) => CFG.keepPrefixes.some((p) => n.startsWith(p));
39
45
  const loose = top.filter((c) => c !== archive && c.type === "board" && !isPart(c.name) && !isKept(c.name)).map((c) => c.name);
40
- return { ok: true, readOnly: true, archive: archive ? { id: archive.id, x: Math.round(archive.x), y: Math.round(archive.y), children: inside.length } : null, loose };`, params);
46
+ return { ok: true, readOnly: true, archive: archive ? { id: archive.id, x: Math.round(archive.x), y: Math.round(archive.y), children: inside.length } : null, insideIds: inside, loose };`, params);
41
47
  }
@@ -28,5 +28,5 @@ export interface AuditParams {
28
28
  }
29
29
  /** Blank text, colours and strokes not bound to a token, children sticking out of their parent, gaps off the allowed steps. */
30
30
  export declare function audit(params: AuditParams): Snippet;
31
- /** Token sets (in order, with whether each is active) and themes. Object-valued tokens print as [object Object]; compare names for those. */
31
+ /** Bounded typed preview. Use captureInspection(kind: tokens) for chunked complete evidence. */
32
32
  export declare function tokensDump(): Snippet;
@@ -1,6 +1,7 @@
1
1
  import { requirePage, snippet } from "../snippet.js";
2
+ const readSnippet = (name, body, params = {}) => snippet(name, body, params, "read");
2
3
  export function pages() {
3
- return snippet("pages", `return { ok: true, readOnly: true, file: penpot.currentFile.name, revn: penpot.currentFile.revn, current: penpot.currentPage.name,
4
+ return readSnippet("pages", `return { ok: true, readOnly: true, file: penpot.currentFile.name, revn: penpot.currentFile.revn, current: penpot.currentPage.name,
4
5
  pages: penpot.currentFile.pages.map((p) => ({ id: p.id, name: p.name, count: p.root.children.length })) };`);
5
6
  }
6
7
  /** The editor can only change page between snippets: it switches, and the next snippet sees the new page. */
@@ -9,57 +10,93 @@ export function switchPage(name) {
9
10
  if (!target) return { ok: false, message: "no page named " + CFG.name };
10
11
  if (penpot.currentPage.id === target.id) return { ok: true, alreadyThere: true };
11
12
  penpot.openPage(target);
12
- return { ok: true, switched: true };`, { name });
13
+ return { ok: true, switched: true };`, {
14
+ name,
15
+ });
13
16
  }
14
17
  /** The top-level frames of a page: id, name and size, so they can be found by name and exported. */
15
18
  export function frames(params) {
16
- return snippet("frames", `${requirePage}
17
- return { ok: true, readOnly: true, file: penpot.currentFile.name, revn: penpot.currentFile.revn, page: penpot.currentPage.name,
19
+ return readSnippet("frames", `${requirePage}
20
+ return { ok: true, readOnly: true, file: penpot.currentFile.name, fileId: penpot.currentFile.id, revn: penpot.currentFile.revn, page: penpot.currentPage.name, pageId: penpot.currentPage.id,
18
21
  frames: penpot.currentPage.root.children.filter((c) => c.type === "board").map((c) => ({ id: c.id, name: c.name, width: Math.round(c.width), height: Math.round(c.height) })) };`, params);
19
22
  }
20
23
  /** One line per shape: type, name, size, position on the page, layout and sizing, the text it carries. */
21
24
  export function tree(params) {
22
- return snippet("tree", `${requirePage}
23
- const depth = CFG.depth === undefined ? 3 : CFG.depth, limit = CFG.limit || 400, lines = [];
24
- const fx = (s) => (s.flex ? " flex:" + s.flex.dir + "/" + s.flex.horizontalSizing + "-" + s.flex.verticalSizing : "");
25
- const lc = (s) => (s.layoutChild ? " lc:" + (s.layoutChild.horizontalSizing || "") + "-" + (s.layoutChild.verticalSizing || "") + (s.layoutChild.absolute ? " ABS" : "") : "");
26
- const walk = (s, d) => {
27
- if (d > depth || lines.length >= limit) return;
28
- lines.push(" ".repeat(d) + s.type[0] + " " + s.name + " " + Math.round(s.width) + "x" + Math.round(s.height) + "@" + Math.round(s.x) + "," + Math.round(s.y) + fx(s) + lc(s) + (s.type === "text" ? " «" + (s.characters || "").slice(0, 24) + "»" : "") + (s.hidden ? " HIDDEN" : ""));
29
- (s.children || []).forEach((c) => walk(c, d + 1));
30
- };
31
- penpot.currentPage.root.children.filter((c) => !CFG.namePrefix || c.name.startsWith(CFG.namePrefix)).forEach((c) => walk(c, 0));
32
- return { ok: true, readOnly: true, page: penpot.currentPage.name, lines, truncated: lines.length >= limit };`, params);
25
+ if ((params.limit !== undefined &&
26
+ (!Number.isInteger(params.limit) || params.limit < 1 || params.limit > 1000)) ||
27
+ (params.depth !== undefined &&
28
+ (!Number.isInteger(params.depth) || params.depth < 0 || params.depth > 100))) {
29
+ throw new Error("Readable tree budgets require limit 1..1000 and depth 0..100; use inspect for larger structured scopes");
30
+ }
31
+ return readSnippet("tree", `${requirePage}
32
+ const depth=CFG.depth===undefined?3:CFG.depth,limit=CFG.limit||400,lines=[];
33
+ let depthLimited=false,textLimited=false,labelLimited=false;
34
+ const stack=[],roots=penpot.currentPage.root.children.filter(c=>!CFG.namePrefix||c.name.startsWith(CFG.namePrefix));
35
+ for(let i=Math.min(roots.length,20000)-1;i>=0;i--)stack.push({shape:roots[i],depth:0});
36
+ const fx=s=>s.flex?" flex:"+s.flex.dir+"/"+s.flex.horizontalSizing+"-"+s.flex.verticalSizing:"";
37
+ const lc=s=>s.layoutChild?" lc:"+(s.layoutChild.horizontalSizing||"")+"-"+(s.layoutChild.verticalSizing||"")+(s.layoutChild.absolute?" ABS":""):"";
38
+ while(stack.length&&lines.length<limit){
39
+ const item=stack.pop(),s=item.shape,name=String(s.name||""),text=String(s.characters||"");
40
+ if(name.length>200)labelLimited=true;if(s.type==="text"&&text.length>24)textLimited=true;
41
+ lines.push(" ".repeat(item.depth)+(s.type||"?")[0]+" "+s.id+" "+JSON.stringify(name.slice(0,200))+" "+Math.round(s.width)+"x"+Math.round(s.height)+"@"+Math.round(s.x)+","+Math.round(s.y)+fx(s)+lc(s)+(s.type==="text"?" «"+JSON.stringify(text.slice(0,24))+"»":"")+(s.hidden?" HIDDEN":""));
42
+ const children=s.children||[];
43
+ if(item.depth>=depth){if(children.length)depthLimited=true;continue;}
44
+ const take=Math.min(children.length,20000-stack.length);
45
+ if(take<children.length)depthLimited=true;
46
+ for(let i=take-1;i>=0;i--)stack.push({shape:children[i],depth:item.depth+1});
47
+ }
48
+ const truncated=stack.length>0||roots.length>20000||depthLimited||textLimited||labelLimited;
49
+ return {ok:true,readOnly:true,page:penpot.currentPage.name,fileId:penpot.currentFile?.id??null,pageId:penpot.currentPage.id??null,revision:penpot.currentFile?.revn??null,lines,returnedLines:lines.length,depthLimit:depth,lineLimit:limit,truncated,complete:!truncated,limits:{depthLimited,textLimited,labelLimited,pendingBranches:stack.length}};`, params);
33
50
  }
34
51
  /** Blank text, colours and strokes not bound to a token, children sticking out of their parent, gaps off the allowed steps. */
35
52
  export function audit(params) {
36
- return snippet("audit", `${requirePage}
37
- const frames = penpot.currentPage.root.children.filter((c) => c.name.startsWith(CFG.framePrefix));
38
- const skip = (n) => (CFG.ignoreUnbound || []).some((p) => n.startsWith(p));
39
- const out = {};
40
- for (const fr of frames) {
41
- const f = { nodes: 0, texts: 0, blankText: 0, unboundFills: [], unboundStrokes: [], badSpacing: [], overflow: [] };
42
- const walk = (s, parent) => {
43
- f.nodes++;
44
- const tk = s.tokens ? Object.keys(s.tokens) : [];
45
- if (s.type === "text") { if (!(s.characters || "").trim()) f.blankText++; else f.texts++; }
46
- else if (!skip(s.name) && s.fills && s.fills.some((x) => x.fillColor) && !tk.some((k) => k.startsWith("fill"))) f.unboundFills.push(s.name + ":" + s.fills[0].fillColor);
47
- if (s.type !== "text" && !skip(s.name) && s.strokes && s.strokes.length && !tk.some((k) => k.startsWith("stroke"))) f.unboundStrokes.push(s.name);
48
- if (s.flex && CFG.spacingSteps) for (const v of [s.flex.rowGap, s.flex.columnGap, s.flex.topPadding, s.flex.rightPadding, s.flex.bottomPadding, s.flex.leftPadding])
49
- if (v !== undefined && v !== null && !CFG.spacingSteps.includes(Math.round(v))) f.badSpacing.push(s.name + ":" + v);
50
- if (parent && parent.type === "board" && !(s.layoutChild && s.layoutChild.absolute) && (s.x + s.width > parent.x + parent.width + 1 || s.x < parent.x - 1)) f.overflow.push(s.name + " in " + parent.name);
51
- (s.children || []).forEach((c) => { if (!c.hidden) walk(c, s); });
52
- };
53
- walk(fr, null);
54
- for (const k of ["unboundFills", "unboundStrokes", "badSpacing", "overflow"]) f[k] = f[k].slice(0, 8);
55
- out[fr.name] = f;
53
+ return readSnippet("audit", `${requirePage}
54
+ const candidates=penpot.currentPage.root.children.filter(c=>c.name.startsWith(CFG.framePrefix)),frames=candidates.slice(0,20),out=Object.create(null);
55
+ const skip=n=>(CFG.ignoreUnbound||[]).some(prefix=>n.startsWith(prefix));
56
+ let visited=0,exampleBytes=0,partial=candidates.length>20;const deadline=Date.now()+5000;
57
+ const size=text=>{let n=0;for(const c of text){const p=c.codePointAt(0);n+=p<128?1:p<2048?2:p<65536?3:4;}return n;};
58
+ for(const fr of frames){
59
+ const f={id:fr.id,name:fr.name.slice(0,200),nodes:0,texts:0,blankText:0,unboundFills:[],unboundStrokes:[],badSpacing:[],overflow:[],totals:{unboundFills:0,unboundStrokes:0,badSpacing:0,overflow:0},truncated:fr.name.length>200,scanComplete:true};
60
+ const issue=(category,s,label)=>{f.totals[category]++;const example={id:s.id,label:String(label).slice(0,80)},bytes=size(JSON.stringify(example));if(f[category].length<8&&exampleBytes+bytes<=65536){f[category].push(example);exampleBytes+=bytes;}else f.truncated=true;if(String(label).length>80)f.truncated=true;};
61
+ const stack=[{shape:fr,parent:null}];
62
+ while(stack.length){
63
+ if(visited>=20000||Date.now()>deadline){f.scanComplete=false;f.truncated=true;partial=true;break;}
64
+ const {shape:s,parent}=stack.pop();visited++;f.nodes++;
65
+ const tk=Object.keys(s.tokens||{});
66
+ if(s.type==="text"){if(!(s.characters||"").trim())f.blankText++;else f.texts++;}
67
+ else if(!skip(s.name)&&s.fills&&s.fills.some(fill=>fill.fillColor)&&!tk.some(key=>key.startsWith("fill")))issue("unboundFills",s,s.name+":"+s.fills[0].fillColor);
68
+ if(s.type!=="text"&&!skip(s.name)&&s.strokes&&s.strokes.length&&!tk.some(key=>key.startsWith("stroke")))issue("unboundStrokes",s,s.name);
69
+ if(s.flex&&CFG.spacingSteps)for(const value of [s.flex.rowGap,s.flex.columnGap,s.flex.topPadding,s.flex.rightPadding,s.flex.bottomPadding,s.flex.leftPadding])if(value!==undefined&&value!==null&&!CFG.spacingSteps.includes(Math.round(value)))issue("badSpacing",s,s.name+":"+value);
70
+ if(parent&&parent.type==="board"&&!(s.layoutChild&&s.layoutChild.absolute)&&(s.x+s.width>parent.x+parent.width+1||s.x<parent.x-1))issue("overflow",s,s.name+" in "+parent.name);
71
+ const children=(s.children||[]).filter(child=>!child.hidden),take=Math.min(children.length,Math.max(0,20000-visited-stack.length));
72
+ if(take<children.length){f.scanComplete=false;f.truncated=true;partial=true;}
73
+ for(let i=take-1;i>=0;i--)stack.push({shape:children[i],parent:s});
74
+ }
75
+ f.totalNodes=f.scanComplete?f.nodes:null;out[fr.id]=f;
56
76
  }
57
- return { ok: true, readOnly: true, frames: out };`, params);
77
+ const truncated=partial||Object.values(out).some(frame=>frame.truncated);
78
+ return {ok:true,readOnly:true,schemaVersion:1,kind:"audit-preview",scope:{framePrefix:CFG.framePrefix,includeHiddenDescendants:false},frames:out,frameCount:candidates.length,returnedFrames:frames.length,visitedNodes:visited,countsComplete:!partial,truncated,complete:!truncated,budgets:{frames:20,nodes:20000,timeMs:5000,examplesPerCategory:8,exampleBytes:65536}};`, params);
58
79
  }
59
- /** Token sets (in order, with whether each is active) and themes. Object-valued tokens print as [object Object]; compare names for those. */
80
+ /** Bounded typed preview. Use captureInspection(kind: tokens) for chunked complete evidence. */
60
81
  export function tokensDump() {
61
- return snippet("tokens-dump", `const tokens = penpot.library.local.tokens;
62
- return { ok: true, readOnly: true,
63
- sets: tokens.sets.map((s, i) => ({ order: i, name: s.name, active: !!s.active, tokens: s.tokens.map((t) => t.name + "=" + t.value) })),
64
- themes: tokens.themes.map((t) => ({ group: t.group, name: t.name, active: !!t.active, sets: (t.activeSets || []).map((x) => (typeof x === "string" ? x : x.name)) })) };`);
82
+ return readSnippet("tokens-dump", `const tokens=penpot.library.local.tokens;
83
+ const sets=[],themes=[];let records=0,bytes=1024,returnedTokens=0,complete=true;
84
+ const size=(value)=>{let n=0;for(const c of JSON.stringify(value)){const p=c.codePointAt(0);n+=p<128?1:p<2048?2:p<65536?3:4;}return n;};
85
+ outer:for(const [order,set] of tokens.sets.entries()){
86
+ const value={order,name:set.name,active:!!set.active,tokens:[]};
87
+ const count=size(value);
88
+ if(records>=5000||bytes+count>262144){complete=false;break;}
89
+ sets.push(value);records++;bytes+=count+1;
90
+ for(const t of set.tokens){
91
+ const token={name:t.name,value:t.value===undefined?null:t.value,type:t.type??null,description:t.description||"",unknowns:[...(t.type===undefined?["type"]:[]),...(t.value===undefined?["value"]:[])]},count=size(token);
92
+ if(records>=5000||bytes+count>262144){complete=false;break outer;}
93
+ value.tokens.push(token);records++;returnedTokens++;bytes+=count+1;
94
+ }
95
+ }
96
+ for(const theme of tokens.themes){
97
+ const value={group:theme.group,name:theme.name,active:!!theme.active,sets:(theme.activeSets||[]).map(s=>typeof s==="string"?s:s.name)},count=size(value);
98
+ if(records>=5000||bytes+count>262144){complete=false;break;}
99
+ themes.push(value);records++;bytes+=count+1;
100
+ }
101
+ return {ok:true,readOnly:true,schemaVersion:1,kind:"token-preview",fileId:penpot.currentFile.id,revision:penpot.currentFile.revn,complete,truncated:!complete,sets,themes,counts:{returnedTokens,totalTokens:complete?returnedTokens:null,setsTotal:tokens.sets.length,themesTotal:tokens.themes.length},previewBudget:{entities:5000,bytes:262144}};`);
65
102
  }