@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.
Files changed (101) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1621 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-instructions.js +91 -22
  66. package/dist/skills.js +129 -39
  67. package/dist/snippet.js +27 -7
  68. package/dist/snippets/read.js +8 -2
  69. package/dist/structure-snapshot.d.ts +12 -0
  70. package/dist/structure-snapshot.js +439 -85
  71. package/dist/token-edit.js +219 -64
  72. package/dist/token-projection.d.ts +56 -0
  73. package/dist/token-projection.js +286 -0
  74. package/dist/tokens-diff.js +125 -32
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +1 -1
  78. package/skills/penpair-design/SKILL.md +3 -0
  79. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  80. package/skills/penpair-design/references/project-facts.md +2 -0
  81. package/skills/penpair-handoff/SKILL.md +2 -0
  82. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  83. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  84. package/skills/penpair-recipes/SKILL.md +5 -0
  85. package/skills/penpair-setup/SKILL.md +3 -1
  86. package/dist/cli.d.ts +0 -2
  87. package/dist/cli.js +0 -3
  88. package/dist/docker/lifecycle.d.ts +0 -96
  89. package/dist/docker/lifecycle.js +0 -273
  90. package/dist/docker/secrets.d.ts +0 -11
  91. package/dist/docker/secrets.js +0 -38
  92. package/dist/docker/template.d.ts +0 -8
  93. package/dist/docker/template.js +0 -158
  94. package/dist/docker-profile.d.ts +0 -114
  95. package/dist/docker-profile.js +0 -18
  96. package/dist/instance/lifecycle.d.ts +0 -129
  97. package/dist/instance/lifecycle.js +0 -336
  98. package/dist/instance/secrets.d.ts +0 -11
  99. package/dist/instance/secrets.js +0 -38
  100. package/dist/instance/template.d.ts +0 -8
  101. 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 { file: path.relative(await realpath(root), target).split(path.sep).join("/"), sha256: digest(body), body, declaration, idempotent: comments.includes("// penpair: idempotent") };
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 || Object.keys(params).some(key => !/^[A-Za-z][A-Za-z0-9]*$/.test(key)))
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
- if (options.page && Object.hasOwn(params, "page"))
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
- if (Object.hasOwn(params, "inspection"))
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 ? await sourceFile(root, include) : { file: include, body: virtual, sha256: digest(virtual), declaration: virtual.startsWith("// penpair: read\n") ? "read" : "unknown", idempotent: false };
60
- if (helper.declaration !== "read")
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 = { ...params, ...(options.page ? { page: options.page } : {}) };
67
- const build = (label, body, effect, inspected) => checked(snippet(label, `${requirePage}\n${prelude}\n${body}`, { ...cfg, ...(effect === "write" ? { inspection: inspected ?? null } : {}) }, effect));
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
- return { kind: "read", source: [...helpers, main].map(({ file, sha256 }) => ({ file, sha256 })), params: cfg, page: options.page, read: build("run-file-read", main.body, "read") };
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
- const inspect = await sourceFile(root, options.inspect), readback = await sourceFile(root, options.readback);
76
- if (inspect.declaration !== "read" || readback.declaration !== "read")
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({ name: "run-file", description: "Explicitly selected reviewed file operation", kind: "write", inspect: inspect.body, apply: main.body, readback: readback.body }, "project");
80
- const operation = { ...normalized.operation, page: () => options.page,
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 { kind: "write", source: [...helpers, main, inspect, readback].map(({ file, sha256 }) => ({ file, sha256 })), params: cfg, page: options.page, operation };
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
- const result = await runOnPage(client, prepared.read, prepared.page, { ...options, effect: "read", retries: 0 });
94
- if (result.ok !== true)
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
- return { phase: "read", kind: "declared-read", source: prepared.source, data: result };
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, { parameters: prepared.params, sourceFiles: prepared.source }, options);
99
- return { ...report, source: prepared.source };
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};`, { name, page }, "read"), page, options);
25
- if (result.truncated)
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
- if (result.ok !== true || typeof result.truncated !== "boolean" || typeof result.fileId !== "string" || !result.fileId || typeof result.pageId !== "string" || !result.pageId || !Number.isInteger(result.revision) || !Array.isArray(result.matches) || result.matches.some((match) => !match || typeof match.id !== "string" || typeof match.name !== "string" || typeof match.type !== "string"))
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
- const result = await exportShapePng(client, lookup.matches[0].id, { ...options, page });
36
- if (result.capture.fileId !== lookup.fileId || result.capture.pageId !== lookup.pageId || result.capture.revision !== lookup.revision)
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({ page: params.page }), params.page, options);
43
- if (typeof listing.fileId !== "string" || typeof listing.pageId !== "string" || !Number.isInteger(listing.revn) || !Array.isArray(listing.frames))
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 = { schemaVersion: 1, complete: false, fileId: listing.fileId, pageId: listing.pageId, revision: Number(listing.revn), captures: [], wrote: [] };
47
- if (!selected.length || selected.length > 100)
48
- return { ...report, reason: !selected.length ? "No matching top-level frames" : "More than 100 frames; narrow the prefix" };
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(), generation = `captures/${runId}`;
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, { ...options, page: params.page, stable: true });
56
- if (!result.stable)
57
- return { ...report, reason: "A frame did not stabilize; no output was published" };
58
- if (result.capture.fileId !== report.fileId || result.capture.pageId !== report.pageId || result.capture.revision !== report.revision)
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
- report.captures.push({ layout: result.layout, ...size, name: frame.name, file: `${generation}/shot-${String(index + 1).padStart(4, "0")}.png`, sha256: createHash("sha256").update(result.png).digest("hex"), capture: result.capture });
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({ page: params.page }), params.page, options);
67
- const identity = (entry) => ({ fileId: entry.fileId, pageId: entry.pageId, revision: entry.revn, frames: entry.frames });
68
- if (observationHash(identity(listing)) !== observationHash(identity(after)))
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"), { recursive: true });
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 = [...report.captures.map((entry) => entry.file), "penpair-shots.json"];
84
- await atomicWriteFile(path.join(folder, "penpair-shots.json"), `${JSON.stringify(report, null, 2)}\n`, { replace: true });
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, { recursive: true, force: true });
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 -->", end = "<!-- penpair:skills:end -->";
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 ["AGENTS.md", "CLAUDE.md"])
22
- if (await exists(path.join(root, name)))
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
- if (candidates.length !== 1)
25
- return { report: { path: null, status: candidates.length ? "choice-required" : "not-selected", diff: "" } };
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, ends = text.split(end).length - 1;
40
- if (starts !== ends || starts > 1 || starts === 1 && text.indexOf(start) > text.indexOf(end))
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.relative(path.dirname(file), path.resolve(canonicalRoot, decodeURIComponent(target))).split(path.sep).join("/");
45
- const link = relative.split("/").map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`)).join("/");
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 = [start, "| Skill | Purpose |", "| --- | --- |", ...relativeRows, end].join(newline);
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
- else
53
- after = text + (text ? text.endsWith(newline) ? newline : newline + newline : "") + "## Penpair skills" + newline + newline + block + newline;
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 ? [`--- ${file}`, `+++ ${file}`, "@@ penpair-owned skills block @@", ...oldBlock.split(/\r?\n/).filter(Boolean).map((line) => `-${line}`), ...(starts ? block : "## Penpair skills" + newline + newline + block).split(/\r?\n/).map((line) => `+${line}`)].join("\n") : "";
58
- return { before, after, mode: before === undefined ? 0o644 : (await lstat(file)).mode & 0o777, report: { path: file, status: changed ? "preview" : "unchanged", diff } };
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
- await atomicWriteFile(plan.report.path, plan.after, { replace: plan.before !== undefined, mode: plan.mode });
74
- return { ...plan.report, status: "written" };
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
  }