@penpair/core 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) 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 +1645 -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-check-entry.d.ts +17 -0
  66. package/dist/skill-check-entry.js +89 -0
  67. package/dist/skill-check-instructions.d.ts +7 -0
  68. package/dist/skill-check-instructions.js +39 -0
  69. package/dist/skill-check.d.ts +15 -0
  70. package/dist/skill-check.js +36 -0
  71. package/dist/skill-file.d.ts +2 -0
  72. package/dist/skill-file.js +31 -0
  73. package/dist/skill-instructions.d.ts +4 -0
  74. package/dist/skill-instructions.js +97 -22
  75. package/dist/skill-metadata.d.ts +8 -0
  76. package/dist/skill-metadata.js +52 -0
  77. package/dist/skill-routing.d.ts +2 -0
  78. package/dist/skill-routing.js +36 -0
  79. package/dist/skills.js +131 -41
  80. package/dist/snippet.js +27 -7
  81. package/dist/snippets/read.js +8 -2
  82. package/dist/structure-snapshot.d.ts +12 -0
  83. package/dist/structure-snapshot.js +439 -85
  84. package/dist/token-edit.js +219 -64
  85. package/dist/token-projection.d.ts +56 -0
  86. package/dist/token-projection.js +286 -0
  87. package/dist/tokens-diff.js +125 -32
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +3 -2
  91. package/skills/design-judgment/SKILL.md +1 -1
  92. package/skills/design-judgment/agents/openai.yaml +6 -0
  93. package/skills/penpair-design/SKILL.md +14 -1
  94. package/skills/penpair-design/agents/openai.yaml +6 -0
  95. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  96. package/skills/penpair-design/references/project-facts.md +2 -0
  97. package/skills/penpair-handoff/SKILL.md +14 -1
  98. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  99. package/skills/penpair-handoff/references/comparison-companion.md +16 -0
  100. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  101. package/skills/penpair-recipes/SKILL.md +13 -1
  102. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  103. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  104. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  105. package/skills/penpair-setup/SKILL.md +9 -3
  106. package/skills/penpair-setup/agents/openai.yaml +6 -0
  107. package/skills/penpair-verify/SKILL.md +49 -0
  108. package/skills/penpair-verify/agents/openai.yaml +6 -0
  109. package/skills/penpair-verify/references/browser-evidence.md +33 -0
  110. package/dist/cli.d.ts +0 -2
  111. package/dist/cli.js +0 -3
  112. package/dist/docker/lifecycle.d.ts +0 -96
  113. package/dist/docker/lifecycle.js +0 -273
  114. package/dist/docker/secrets.d.ts +0 -11
  115. package/dist/docker/secrets.js +0 -38
  116. package/dist/docker/template.d.ts +0 -8
  117. package/dist/docker/template.js +0 -158
  118. package/dist/docker-profile.d.ts +0 -114
  119. package/dist/docker-profile.js +0 -18
  120. package/dist/instance/lifecycle.d.ts +0 -129
  121. package/dist/instance/lifecycle.js +0 -336
  122. package/dist/instance/secrets.d.ts +0 -11
  123. package/dist/instance/secrets.js +0 -38
  124. package/dist/instance/template.d.ts +0 -8
  125. package/dist/instance/template.js +0 -158
package/dist/journal.js CHANGED
@@ -7,41 +7,71 @@ import { version } from "./version.js";
7
7
  import { runSnippet, snippet } from "./snippet.js";
8
8
  export async function readJournalTarget(client) {
9
9
  const result = await runSnippet(client, snippet("journal-target", "return { ok: true, fileId: penpot.currentFile.id, revision: penpot.currentFile.revn };", {}, "read"));
10
- if (result.ok !== true || typeof result.fileId !== "string" || !result.fileId || !Number.isInteger(result.revision))
10
+ if (result.ok !== true ||
11
+ typeof result.fileId !== "string" ||
12
+ !result.fileId ||
13
+ !Number.isInteger(result.revision)) {
11
14
  throw new Error("Journal target identity/revision was not observed");
12
- return { fileId: result.fileId, revision: Number(result.revision) };
15
+ }
16
+ return {
17
+ fileId: result.fileId,
18
+ revision: Number(result.revision),
19
+ };
13
20
  }
14
21
  /** Stable JSON hashing; observation comparisons may explicitly ignore transport execution metadata. */
15
22
  export function observationHash(value, ignoreExecution = false) {
16
23
  const canonical = (entry) => {
17
- if (Array.isArray(entry))
24
+ if (Array.isArray(entry)) {
18
25
  return entry.map(canonical);
19
- if (typeof entry !== "object" || entry === null)
26
+ }
27
+ if (typeof entry !== "object" || entry === null) {
20
28
  return entry;
21
- return Object.fromEntries(Object.entries(entry).filter(([key]) => !ignoreExecution || key !== "execution").sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonical(child)]));
29
+ }
30
+ return Object.fromEntries(Object.entries(entry)
31
+ .filter(([key]) => !ignoreExecution || key !== "execution")
32
+ .sort(([a], [b]) => a.localeCompare(b))
33
+ .map(([key, child]) => [
34
+ key,
35
+ canonical(child),
36
+ ]));
22
37
  };
23
- return createHash("sha256").update(JSON.stringify(canonical(value)) ?? "null").digest("hex");
38
+ return createHash("sha256")
39
+ .update(JSON.stringify(canonical(value)) ?? "null")
40
+ .digest("hex");
24
41
  }
25
42
  function validateRunId(runId) {
26
- if (!/^[a-zA-Z0-9-]{1,100}$/.test(runId))
43
+ if (!/^[a-zA-Z0-9-]{1,100}$/.test(runId)) {
27
44
  throw new Error("Run ID must contain only letters, digits and hyphens (at most 100)");
45
+ }
28
46
  }
29
47
  async function folder(root, create) {
30
- if (create)
31
- await ensureGitignoreEntry(root, ".penpair", { directory: true, comment: "penpair: private local operations and recovery evidence" });
48
+ if (create) {
49
+ await ensureGitignoreEntry(root, ".penpair", {
50
+ directory: true,
51
+ comment: "penpair: private local operations and recovery evidence",
52
+ });
53
+ }
32
54
  let current = root;
33
- for (const segment of [".penpair", "runs"]) {
55
+ for (const segment of [
56
+ ".penpair",
57
+ "runs",
58
+ ]) {
34
59
  current = path.join(current, segment);
35
60
  try {
36
61
  const info = await lstat(current);
37
- if (!info.isDirectory() || info.isSymbolicLink())
62
+ if (!info.isDirectory() || info.isSymbolicLink()) {
38
63
  throw new Error("Operation journal directories must be real directories inside this project");
64
+ }
39
65
  }
40
66
  catch (error) {
41
- if (error.code !== "ENOENT")
67
+ if (error.code !== "ENOENT") {
42
68
  throw error;
43
- if (create)
44
- await mkdir(current, { mode: 0o700 });
69
+ }
70
+ if (create) {
71
+ await mkdir(current, {
72
+ mode: 0o700,
73
+ });
74
+ }
45
75
  }
46
76
  }
47
77
  return current;
@@ -50,11 +80,18 @@ export async function readJournal(root, runId) {
50
80
  validateRunId(runId);
51
81
  const file = path.join(await folder(root, false), `${runId}.json`);
52
82
  const info = await lstat(file);
53
- if (!info.isFile() || info.isSymbolicLink())
83
+ if (!info.isFile() || info.isSymbolicLink()) {
54
84
  throw new Error("Operation journal must be a regular local file");
85
+ }
55
86
  const record = JSON.parse(await readFile(file, "utf8"));
56
- if (record.schemaVersion !== 1 || record.runId !== runId || typeof record.operation !== "string" || !Array.isArray(record.steps) || !record.target || typeof record.target.fileId !== "string")
87
+ if (record.schemaVersion !== 1 ||
88
+ record.runId !== runId ||
89
+ typeof record.operation !== "string" ||
90
+ !Array.isArray(record.steps) ||
91
+ !record.target ||
92
+ typeof record.target.fileId !== "string") {
57
93
  throw new Error("Operation journal is invalid or uses an unsupported schema");
94
+ }
58
95
  return record;
59
96
  }
60
97
  export async function listJournals(root) {
@@ -63,12 +100,23 @@ export async function listJournals(root) {
63
100
  files = await readdir(await folder(root, false));
64
101
  }
65
102
  catch (error) {
66
- if (error.code === "ENOENT")
103
+ if (error.code === "ENOENT") {
67
104
  return [];
105
+ }
68
106
  throw error;
69
107
  }
70
- const records = await Promise.all(files.filter((name) => /^[a-zA-Z0-9-]+\.json$/.test(name)).map((name) => readJournal(root, name.slice(0, -5))));
71
- return records.map(({ runId, operation, state, target, updatedAt }) => ({ runId, operation, state, target, updatedAt })).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
108
+ const records = await Promise.all(files
109
+ .filter((name) => /^[a-zA-Z0-9-]+\.json$/.test(name))
110
+ .map((name) => readJournal(root, name.slice(0, -5))));
111
+ return records
112
+ .map(({ runId, operation, state, target, updatedAt }) => ({
113
+ runId,
114
+ operation,
115
+ state,
116
+ target,
117
+ updatedAt,
118
+ }))
119
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
72
120
  }
73
121
  export class JournalRunError extends Error {
74
122
  runId;
@@ -81,86 +129,183 @@ export class JournalRunError extends Error {
81
129
  }
82
130
  /** Each started phase is persisted before its write. Resume must inspect and reconcile actual state, never replay blindly. */
83
131
  export async function runJournaledSteps(steps, options) {
84
- if (options.requireCheckpoint && !options.checkpoint)
132
+ if (options.requireCheckpoint && !options.checkpoint) {
85
133
  throw new Error("A required checkpoint needs a checkpoint provider");
134
+ }
86
135
  const runId = options.runId ?? randomUUID();
87
136
  validateRunId(runId);
88
137
  const redact = (text) => redactEndpoint(options.redact?.(text) ?? text);
89
138
  const directory = await folder(options.root, true);
90
139
  const destination = path.join(directory, `${runId}.json`);
91
140
  const target = await options.target();
92
- if (!target || typeof target.fileId !== "string" || !target.fileId)
141
+ if (!target || typeof target.fileId !== "string" || !target.fileId) {
93
142
  throw new Error("Operation journal requires an observed file ID");
94
- const inputHash = observationHash({ input: options.input, requireCheckpoint: options.requireCheckpoint === true, checkpointRequested: Boolean(options.checkpoint) });
95
- const planHash = observationHash({ steps: steps.map(({ key, signature }) => ({ key, signature })), finalVerifier: options.finalVerify?.toString() });
96
- if (new Set(steps.map((step) => step.key)).size !== steps.length)
143
+ }
144
+ const inputHash = observationHash({
145
+ input: options.input,
146
+ requireCheckpoint: options.requireCheckpoint === true,
147
+ checkpointRequested: Boolean(options.checkpoint),
148
+ });
149
+ const planHash = observationHash({
150
+ steps: steps.map(({ key, signature }) => ({
151
+ key,
152
+ signature,
153
+ })),
154
+ finalVerifier: options.finalVerify?.toString(),
155
+ });
156
+ if (new Set(steps.map((step) => step.key)).size !== steps.length) {
97
157
  throw new Error("Journal step keys must be unique");
158
+ }
98
159
  let record;
99
160
  if (options.resume) {
100
- if (!options.runId)
161
+ if (!options.runId) {
101
162
  throw new Error("Resume requires the original run ID");
163
+ }
102
164
  record = await readJournal(options.root, runId);
103
- if (record.redacted)
165
+ if (record.redacted) {
104
166
  throw new Error("This journal required redaction and cannot be automatically resumed; inspect and make a fresh plan");
105
- if (record.engineVersion !== version || record.operation !== options.operation || record.inputHash !== inputHash || record.planHash !== planHash || record.target.fileId !== target.fileId || record.steps.length !== steps.length)
167
+ }
168
+ if (record.engineVersion !== version ||
169
+ record.operation !== options.operation ||
170
+ record.inputHash !== inputHash ||
171
+ record.planHash !== planHash ||
172
+ record.target.fileId !== target.fileId ||
173
+ record.steps.length !== steps.length) {
106
174
  throw new Error("Resume target, inputs, step implementation or engine version changed; no write was replayed");
175
+ }
107
176
  }
108
177
  else {
109
178
  const before = [];
110
- for (const step of steps)
179
+ for (const step of steps) {
111
180
  before.push(step.initial === undefined ? await step.inspect() : step.initial);
181
+ }
112
182
  const observed = await options.target();
113
- if (observed.fileId !== target.fileId || (target.revision !== undefined && observed.revision !== target.revision))
183
+ if (observed.fileId !== target.fileId ||
184
+ (target.revision !== undefined && observed.revision !== target.revision)) {
114
185
  throw new Error("File changed while planning; inspect again before starting a run");
186
+ }
115
187
  const now = new Date().toISOString();
116
- record = { schemaVersion: 1, engineVersion: version, runId, operation: options.operation, inputHash, planHash, payload: options.payload, target, state: "planned", createdAt: now, updatedAt: now, redacted: false, steps: steps.map((step, index) => ({ key: step.key, label: step.label, state: "pending", before: before[index] })) };
188
+ record = {
189
+ schemaVersion: 1,
190
+ engineVersion: version,
191
+ runId,
192
+ operation: options.operation,
193
+ inputHash,
194
+ planHash,
195
+ payload: options.payload,
196
+ target,
197
+ state: "planned",
198
+ createdAt: now,
199
+ updatedAt: now,
200
+ redacted: false,
201
+ steps: steps.map((step, index) => ({
202
+ key: step.key,
203
+ label: step.label,
204
+ state: "pending",
205
+ before: before[index],
206
+ })),
207
+ };
117
208
  }
118
209
  const persist = async (create = false) => {
119
210
  record.updatedAt = new Date().toISOString();
120
211
  const sanitize = (value) => {
121
- if (typeof value === "string")
212
+ if (typeof value === "string") {
122
213
  return redact(value);
123
- if (Array.isArray(value))
214
+ }
215
+ if (Array.isArray(value)) {
124
216
  return value.map(sanitize);
125
- if (value && typeof value === "object")
126
- return Object.fromEntries(Object.entries(value).map(([key, entry]) => [redact(key), sanitize(entry)]));
217
+ }
218
+ if (value && typeof value === "object") {
219
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
220
+ redact(key),
221
+ sanitize(entry),
222
+ ]));
223
+ }
127
224
  return value;
128
225
  };
129
226
  const serialize = () => JSON.stringify(sanitize(record), null, 2);
130
- if (serialize() !== JSON.stringify(record, null, 2))
227
+ if (serialize() !== JSON.stringify(record, null, 2)) {
131
228
  record.redacted = true;
229
+ }
132
230
  const temporary = path.join(directory, `.${runId}-${randomUUID()}.tmp`);
133
- await writeFile(temporary, `${serialize()}\n`, { flag: "wx", mode: 0o600 });
231
+ await writeFile(temporary, `${serialize()}\n`, {
232
+ flag: "wx",
233
+ mode: 0o600,
234
+ });
134
235
  try {
135
- if (create)
236
+ if (create) {
136
237
  await link(temporary, destination);
137
- else
238
+ }
239
+ else {
138
240
  await rename(temporary, destination);
241
+ }
139
242
  }
140
243
  finally {
141
- await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
142
- throw error; });
244
+ await unlink(temporary).catch((error) => {
245
+ if (error.code !== "ENOENT") {
246
+ throw error;
247
+ }
248
+ });
143
249
  }
144
250
  };
145
- if (!options.resume)
251
+ if (!options.resume) {
146
252
  await persist(true);
253
+ }
147
254
  options.onStep?.(`operation run ${runId}`);
255
+ const traceStarted = Date.now();
256
+ const trace = (event, step, target) => {
257
+ if (!options.trace && !options.onTrace) {
258
+ return;
259
+ }
260
+ const entry = {
261
+ event,
262
+ at: new Date().toISOString(),
263
+ elapsedMs: Date.now() - traceStarted,
264
+ ...(step === undefined
265
+ ? {}
266
+ : {
267
+ step,
268
+ }),
269
+ ...(target
270
+ ? {
271
+ target: {
272
+ fileId: redact(target.fileId),
273
+ revision: target.revision,
274
+ },
275
+ }
276
+ : {}),
277
+ };
278
+ if (options.trace) {
279
+ record.trace ??= [];
280
+ if (record.trace.length < 10000) {
281
+ record.trace.push(entry);
282
+ }
283
+ }
284
+ options.onTrace?.(structuredClone(entry));
285
+ };
148
286
  const assertTarget = async () => {
149
287
  const actual = await options.target();
150
- if (actual.fileId !== record.target.fileId)
288
+ if (actual.fileId !== record.target.fileId) {
151
289
  throw new Error("The operation target file changed");
290
+ }
291
+ trace("revision-observed", undefined, actual);
152
292
  return actual;
153
293
  };
154
294
  let lease;
155
295
  try {
296
+ trace("lease-acquire-start", undefined, target);
156
297
  lease = await options.coordinate?.(runId, target);
157
- if (options.checkpoint && record.steps.every((step) => step.state === "pending") && record.checkpoint === undefined) {
298
+ trace("lease-acquired", undefined, options.trace || options.onTrace ? await assertTarget() : undefined);
299
+ if (options.checkpoint &&
300
+ record.steps.every((step) => step.state === "pending") &&
301
+ record.checkpoint === undefined) {
158
302
  record.state = "running";
159
303
  await persist();
160
304
  await assertTarget();
161
305
  const checkpoint = await options.checkpoint(runId);
162
- if (checkpoint.supported !== false && checkpoint.report?.verified !== true)
306
+ if (checkpoint.supported !== false && checkpoint.report?.verified !== true) {
163
307
  throw new Error("Checkpoint did not provide verified recovery evidence");
308
+ }
164
309
  if (!checkpoint.supported && options.requireCheckpoint) {
165
310
  record.state = "failed";
166
311
  throw new Error("Required checkpoint API is unavailable; no operation step was applied");
@@ -172,8 +317,11 @@ export async function runJournaledSteps(steps, options) {
172
317
  const step = steps[index];
173
318
  const saved = record.steps[index];
174
319
  await lease?.check();
320
+ trace("lease-check", index);
175
321
  const beforeInspection = await assertTarget();
322
+ trace("inspection-start", index, beforeInspection);
176
323
  const current = await step.inspect();
324
+ trace("inspection-end", index);
177
325
  const afterInspection = await assertTarget();
178
326
  if (beforeInspection.revision !== afterInspection.revision) {
179
327
  record.state = "conflict";
@@ -234,7 +382,7 @@ export async function runJournaledSteps(steps, options) {
234
382
  throw error;
235
383
  }
236
384
  }
237
- if (options.finalVerify && !await options.finalVerify()) {
385
+ if (options.finalVerify && !(await options.finalVerify())) {
238
386
  record.state = "failed";
239
387
  throw new Error("Final operation verification failed");
240
388
  }
@@ -245,16 +393,31 @@ export async function runJournaledSteps(steps, options) {
245
393
  }
246
394
  catch (error) {
247
395
  record.error = redact(error instanceof Error ? error.message : String(error));
248
- if (record.state === "planned" || record.state === "running")
396
+ if (record.state === "planned" || record.state === "running") {
249
397
  record.state = "uncertain";
398
+ }
250
399
  try {
251
400
  await persist();
252
401
  }
253
- catch { /* The pre-write started marker remains the recovery authority when a later local save fails. */ }
402
+ catch {
403
+ /* The pre-write started marker remains the recovery authority when a later local save fails. */
404
+ }
254
405
  throw new JournalRunError(runId, record.state, redact(error instanceof Error ? error.message : String(error)));
255
406
  }
256
407
  finally {
257
- if (lease)
258
- await lease.release().catch((error) => options.onStep?.(`Writer marker release needs inspection: ${redact(error instanceof Error ? error.message : String(error))}`));
408
+ if (lease) {
409
+ trace("lease-release-start");
410
+ try {
411
+ await lease.release();
412
+ trace("lease-released");
413
+ }
414
+ catch (error) {
415
+ trace("lease-release-failed");
416
+ options.onStep?.(`Writer marker release needs inspection: ${redact(error instanceof Error ? error.message : String(error))}`);
417
+ }
418
+ if (options.trace) {
419
+ await persist().catch(() => undefined);
420
+ }
421
+ }
259
422
  }
260
423
  }
package/dist/kit-smoke.js CHANGED
@@ -16,8 +16,9 @@ const state=()=>{
16
16
  const result={fileId:f.id,pageId:p.id,structure,tokens,themes};if(JSON.stringify(result).length>30000)throw new Error("Kit probe needs a smaller scratch page/token context");return result;
17
17
  };`;
18
18
  export const kitSmokeOperation = {
19
- name: "kit-smoke", page: () => undefined,
20
- inspect: params => snippet("kit-smoke-inspect", `${snapshotBody}
19
+ name: "kit-smoke",
20
+ page: () => undefined,
21
+ inspect: (params) => snippet("kit-smoke-inspect", `${snapshotBody}
21
22
  const current=state(),effective=new Map();for(const set of penpot.library.local.tokens.sets)if(set.active)for(const token of set.tokens)effective.set(token.name,token);
22
23
  const pick=type=>{const names=[...effective.values()].filter(token=>token.type===type).map(token=>token.name).sort();if(!names.length)throw new Error("Kit probe needs an active "+type+" token on its scratch file");return names[0];};
23
24
  if(penpot.currentPage.root.children.some(shape=>shape.name===CFG.tag))throw new Error("A prior kit-probe root needs inspection before another run");
@@ -38,46 +39,107 @@ try{
38
39
  result={ok:true,built:true,actual,geometry:{width:card.width,height:card.height},previewBase64:btoa(binary),previewLength:second.length,capture:{fileId:penpot.currentFile.id,pageId:penpot.currentPage.id,frameId:root.id,revision:penpot.currentFile.revn,penpotApiVersion:typeof penpot.version==="string"?penpot.version:null},layout:settled.layout};
39
40
  }catch(error){result.error=String(error&&error.message||error);}
40
41
  finally{if(root)try{root.remove();}catch(error){result.cleanupError=String(error&&error.message||error);}}
41
- return result;`, { ...params, expected: inspected?.state ?? null, selected: inspected?.selected ?? null }, "write"),
42
+ return result;`, {
43
+ ...params,
44
+ expected: inspected?.state ?? null,
45
+ selected: inspected?.selected ?? null,
46
+ }, "write"),
42
47
  readback: () => snippet("kit-smoke-readback", `${snapshotBody}\nreturn {ok:true,readOnly:true,state:state()};`, {}, "read"),
43
48
  verify({ inspect, apply, readback }) {
44
49
  const selected = inspect.selected;
45
50
  const problems = [];
46
- if (!selected || apply.built !== true || apply.error || apply.cleanupError)
51
+ if (!selected || apply.built !== true || apply.error || apply.cleanupError) {
47
52
  problems.push(`Kit example failed: ${apply.error ?? apply.cleanupError ?? "no observed build"}`);
48
- if (!selected || observationHash(apply.actual) !== observationHash({ text: "Hello, card", typography: selected.typo, color: selected.color, space: selected.space, radius: selected.radius }))
53
+ }
54
+ if (!selected ||
55
+ observationHash(apply.actual) !==
56
+ observationHash({
57
+ text: "Hello, card",
58
+ typography: selected.typo,
59
+ color: selected.color,
60
+ space: selected.space,
61
+ radius: selected.radius,
62
+ })) {
49
63
  problems.push("Kit example lacks matching independent text/binding observations");
64
+ }
50
65
  const geometry = apply.geometry;
51
- if (!geometry || typeof geometry.width !== "number" || !Number.isFinite(geometry.width) || geometry.width <= 0 || typeof geometry.height !== "number" || !Number.isFinite(geometry.height) || geometry.height <= 0)
66
+ if (!geometry ||
67
+ typeof geometry.width !== "number" ||
68
+ !Number.isFinite(geometry.width) ||
69
+ geometry.width <= 0 ||
70
+ typeof geometry.height !== "number" ||
71
+ !Number.isFinite(geometry.height) ||
72
+ geometry.height <= 0) {
52
73
  problems.push("Kit example has no finite positive geometry");
53
- if (!inspect.state || !readback.state || observationHash(inspect.state) !== observationHash(readback.state))
74
+ }
75
+ if (!inspect.state ||
76
+ !readback.state ||
77
+ observationHash(inspect.state) !== observationHash(readback.state)) {
54
78
  problems.push("Kit cleanup did not independently preserve the original page and token selection");
55
- if (typeof apply.previewBase64 !== "string" || !Number.isInteger(apply.previewLength) || Number(apply.previewLength) < 1)
79
+ }
80
+ if (typeof apply.previewBase64 !== "string" ||
81
+ !Number.isInteger(apply.previewLength) ||
82
+ Number(apply.previewLength) < 1) {
56
83
  problems.push("Kit example has no stable preview evidence");
84
+ }
57
85
  return problems;
58
86
  },
59
87
  };
60
88
  /** Opt-in scratch operation; uses existing active typed tokens and removes only its directly owned temporary root. */
61
89
  export async function runKitSmoke(client, root, options = {}) {
62
90
  const runId = options.journal?.runId ?? randomUUID();
63
- const report = await runOperation(client, kitSmokeOperation, { tag: `_penpair-kit-${runId}` }, { ...options, journal: options.apply ? { ...options.journal, root, runId } : undefined });
91
+ const report = await runOperation(client, kitSmokeOperation, {
92
+ tag: `_penpair-kit-${runId}`,
93
+ }, {
94
+ ...options,
95
+ journal: options.apply
96
+ ? {
97
+ ...options.journal,
98
+ root,
99
+ runId,
100
+ }
101
+ : undefined,
102
+ });
64
103
  const encoded = report.apply?.previewBase64;
65
- if (report.apply)
104
+ if (report.apply) {
66
105
  delete report.apply.previewBase64;
67
- if (!report.verified || !report.apply)
106
+ }
107
+ if (!report.verified || !report.apply) {
68
108
  return report;
69
- if (typeof encoded !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded))
109
+ }
110
+ if (typeof encoded !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
70
111
  throw new Error("Kit preview encoding is invalid");
112
+ }
71
113
  const bytes = Buffer.from(encoded, "base64");
72
- if (bytes.length !== report.apply.previewLength || bytes.length > 2097152)
114
+ if (bytes.length !== report.apply.previewLength || bytes.length > 2097152) {
73
115
  throw new Error("Kit preview byte evidence is invalid");
116
+ }
74
117
  const destination = await resolveProjectOutput(root, `.penpair/probes/kit-${runId}.png`);
75
- await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
76
- const existing = await readFile(destination).catch((error) => { if (error.code !== "ENOENT")
77
- throw error; return undefined; });
78
- if (existing && !existing.equals(bytes))
118
+ await mkdir(path.dirname(destination), {
119
+ recursive: true,
120
+ mode: 0o700,
121
+ });
122
+ const existing = await readFile(destination).catch((error) => {
123
+ if (error.code !== "ENOENT") {
124
+ throw error;
125
+ }
126
+ return undefined;
127
+ });
128
+ if (existing && !existing.equals(bytes)) {
79
129
  throw new Error("Existing kit preview differs; previous evidence was preserved");
80
- if (!existing)
81
- await atomicWriteFile(destination, bytes, { mode: 0o600 });
82
- return { ...report, preview: { path: destination, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), visualApproval: false } };
130
+ }
131
+ if (!existing) {
132
+ await atomicWriteFile(destination, bytes, {
133
+ mode: 0o600,
134
+ });
135
+ }
136
+ return {
137
+ ...report,
138
+ preview: {
139
+ path: destination,
140
+ bytes: bytes.length,
141
+ sha256: createHash("sha256").update(bytes).digest("hex"),
142
+ visualApproval: false,
143
+ },
144
+ };
83
145
  }
@@ -1,3 +1,4 @@
1
+ import { layoutPolicySource } from "./layout-policy.js";
1
2
  /** Plain plugin-sandbox JavaScript. Inclusion defines helpers without touching the document. */
2
3
  export const coreKitSource = String.raw `// penpair: read
3
4
  const pp = (() => {
@@ -218,45 +219,14 @@ const pp = (() => {
218
219
  const fillV = shape => { if (!shape.layoutChild)
219
220
  throw new Error("Layout child unavailable"); shape.layoutChild.verticalSizing = "fill"; return shape; };
220
221
  const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
222
+ ${layoutPolicySource}
221
223
  const settle = async (root = penpot.currentPage.root, options = {}) => {
222
- const timeout = options.timeoutMs === undefined ? 10000 : options.timeoutMs, interval = options.intervalMs === undefined ? 100 : options.intervalMs, maxNodes = options.maxNodes === undefined ? 10000 : options.maxNodes;
223
- if (!Number.isInteger(timeout) || timeout < 1 || timeout > 30000 || !Number.isInteger(interval) || interval < 1 || interval > 1000 || !Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 10000)
224
- throw new Error("Invalid settling budgets");
225
- const started = Date.now(), sample = () => { const rows = [], stack = [root], seen = new Set(); while (stack.length) {
226
- if (rows.length >= maxNodes)
227
- throw new Error("Settling node budget exceeded");
228
- const shape = stack.pop();
229
- if (!shape || !shape.id || seen.has(shape.id) || ![shape.x, shape.y, shape.width, shape.height].every(Number.isFinite))
230
- throw new Error("Malformed settling geometry");
231
- seen.add(shape.id);
232
- rows.push([shape.id, shape.x, shape.y, shape.width, shape.height]);
233
- for (const child of shape.children || [])
234
- stack.push(child);
235
- } return JSON.stringify(rows); };
236
- try {
237
- const target = root && typeof root.waitForLayoutUpdate === "function" ? root : penpot;
238
- if (typeof target.waitForLayoutUpdate === "function") {
239
- await target.waitForLayoutUpdate(timeout);
240
- sample();
241
- if (Date.now() - started >= timeout)
242
- return { stable: false, method: "native", samples: 1, reason: "Native layout exceeded deadline" };
243
- return { stable: true, method: "native", samples: 1 };
244
- }
245
- let previous = sample();
246
- for (let samples = 2; samples <= 1000 && Date.now() - started < timeout; samples++) {
247
- await wait(Math.min(interval, Math.max(1, timeout - (Date.now() - started))));
248
- const current = sample();
249
- if (Date.now() - started >= timeout)
250
- break;
251
- if (current === previous)
252
- return { stable: true, method: "geometry", samples };
253
- previous = current;
254
- }
255
- return { stable: false, method: "geometry", reason: "Layout did not settle within budget" };
256
- }
257
- catch (error) {
258
- return { stable: false, method: "unavailable", reason: String(error && error.message || error) };
259
- }
224
+ const fileId = penpot.currentFile.id, pageId = penpot.currentPage.id;
225
+ try { return await settleLayout(async nativeMs => {
226
+ if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) throw new Error("Layout target changed between samples");
227
+ return observeLayout(penpot, root, { nativeMs, maxNodes: options.maxNodes });
228
+ }, { timeoutMs: options.timeoutMs === undefined ? 10000 : options.timeoutMs, intervalMs: options.intervalMs === undefined ? 100 : options.intervalMs, strategy: options.strategy === undefined ? "auto" : options.strategy, sleep: wait }); }
229
+ catch (error) { return { stable: false, method: "unavailable", samples: 0, reason: String(error && error.message || error) }; }
260
230
  };
261
231
  const flush = async (options = {}) => {
262
232
  if (flushing)
@@ -269,7 +239,7 @@ const pp = (() => {
269
239
  try {
270
240
  if (queuedContext !== null && context() !== queuedContext)
271
241
  throw new Error("Active token context changed before flush");
272
- let layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining() });
242
+ let layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining(), strategy: options.strategy });
273
243
  if (!layout.stable)
274
244
  throw new Error(layout.reason || "Layout did not settle");
275
245
  if (queuedContext !== null && context() !== queuedContext)
@@ -288,7 +258,7 @@ const pp = (() => {
288
258
  bindings.shift();
289
259
  applied++;
290
260
  }
291
- layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining() });
261
+ layout = await settle(options.root || penpot.currentPage.root, { timeoutMs: remaining(), strategy: options.strategy });
292
262
  if (!layout.stable)
293
263
  throw new Error(layout.reason || "Layout did not settle");
294
264
  queuedContext = null;