@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
@@ -0,0 +1,85 @@
1
+ import { type InspectionDocument } from "./inspection.js";
2
+ import type { ExecuteOptions, PenpotToolClient } from "./client.js";
3
+ export interface StructuralPage {
4
+ schemaVersion: 1;
5
+ kind: "page-structure";
6
+ fileId: string;
7
+ pageId: string;
8
+ revision: number;
9
+ name: string;
10
+ nodes: Record<string, unknown>[];
11
+ }
12
+ export interface StructuralTokens {
13
+ schemaVersion: 1;
14
+ kind: "token-structure";
15
+ fileId: string;
16
+ revision: number;
17
+ sets: Record<string, unknown>[];
18
+ themes: Record<string, unknown>[];
19
+ }
20
+ export interface StructuralSnapshot {
21
+ schemaVersion: 1;
22
+ kind: "structural-snapshot";
23
+ fileId: string;
24
+ revision: number;
25
+ pages: StructuralPage[];
26
+ tokens: StructuralTokens;
27
+ fidelity: "review-only";
28
+ }
29
+ export interface StructuralManifest {
30
+ schemaVersion: 1;
31
+ kind: "structural-snapshot-manifest";
32
+ fileId: string;
33
+ revision: number;
34
+ producer: string;
35
+ fidelity: "review-only";
36
+ pages: Array<{
37
+ id: string;
38
+ name: string;
39
+ file: string;
40
+ hash: string;
41
+ }>;
42
+ tokens: {
43
+ file: "tokens.json";
44
+ hash: string;
45
+ };
46
+ hash: string;
47
+ }
48
+ export declare const structuralJson: (value: unknown) => string;
49
+ export declare function structuralPage(raw: InspectionDocument, name: string): StructuralPage;
50
+ export declare function assembleStructuralSnapshot(pages: StructuralPage[], rawTokens: InspectionDocument): StructuralSnapshot;
51
+ export interface SnapshotProgress {
52
+ pageId: string;
53
+ stage: "start" | "complete";
54
+ elapsedMs: number;
55
+ }
56
+ export declare class SnapshotCaptureError extends Error {
57
+ readonly progress: SnapshotProgress[];
58
+ readonly restorationError?: string | undefined;
59
+ constructor(message: string, progress: SnapshotProgress[], restorationError?: string | undefined);
60
+ }
61
+ export declare function captureStructuralSnapshot(client: PenpotToolClient, options?: ExecuteOptions & {
62
+ signal?: AbortSignal;
63
+ onStep?: (message: string) => void;
64
+ onProgress?: (event: SnapshotProgress) => void;
65
+ }): Promise<StructuralSnapshot>;
66
+ export declare function writeStructuralSnapshot(root: string, out: string, snapshot: StructuralSnapshot, redact?: (text: string) => string): Promise<StructuralManifest>;
67
+ export declare function readStructuralSnapshot(root: string, folder: string): Promise<StructuralSnapshot>;
68
+ export declare function diffStructuralSnapshots(before: StructuralSnapshot, after: StructuralSnapshot, limit?: number): {
69
+ schemaVersion: number;
70
+ kind: string;
71
+ fileId: string;
72
+ beforeRevision: number;
73
+ afterRevision: number;
74
+ totalChanges: number;
75
+ complete: boolean;
76
+ changes: {
77
+ kind: string;
78
+ pageId?: string;
79
+ nodeId?: string;
80
+ before: unknown;
81
+ after: unknown;
82
+ }[];
83
+ fidelity: string;
84
+ };
85
+ export declare const renderStructuralDiff: (diff: ReturnType<typeof diffStructuralSnapshots>) => string;
@@ -0,0 +1,525 @@
1
+ import { withCleanupDeadline } from "./command-result.js";
2
+ import { withPageSession, observePage, selectPage, rememberPage, pinPageSnippet, } from "./page-session.js";
3
+ import { mkdir, open, readFile, stat, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { captureInspection } from "./inspection.js";
6
+ import { validateDesignInspection } from "./design-spec.js";
7
+ import { observationHash } from "./journal.js";
8
+ import { atomicWriteFile } from "./atomic-output.js";
9
+ import { designDataBlock, resolveProjectInput, resolveProjectOutput } from "./project-input.js";
10
+ import { runSnippet, snippet } from "./snippet.js";
11
+ import { version } from "./version.js";
12
+ function ordered(value) {
13
+ if (Array.isArray(value)) {
14
+ return value.map(ordered);
15
+ }
16
+ if (!value || typeof value !== "object") {
17
+ return value;
18
+ }
19
+ return Object.fromEntries(Object.entries(value)
20
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
21
+ .map(([k, v]) => [
22
+ k,
23
+ ordered(v),
24
+ ]));
25
+ }
26
+ export const structuralJson = (value) => JSON.stringify(ordered(value), null, 2) + "\n";
27
+ export function structuralPage(raw, name) {
28
+ const capture = validateDesignInspection(raw);
29
+ return {
30
+ schemaVersion: 1,
31
+ kind: "page-structure",
32
+ fileId: capture.fileId,
33
+ pageId: capture.pageId,
34
+ revision: capture.revision,
35
+ name,
36
+ nodes: structuredClone(capture.nodes).sort((a, b) => String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0),
37
+ };
38
+ }
39
+ export function assembleStructuralSnapshot(pages, rawTokens) {
40
+ if (rawTokens.schemaVersion !== 1 ||
41
+ rawTokens.kind !== "token-inspection" ||
42
+ rawTokens.complete !== true ||
43
+ rawTokens.redacted === true ||
44
+ rawTokens.reasons.length ||
45
+ !Array.isArray(rawTokens.sets) ||
46
+ !Array.isArray(rawTokens.themes)) {
47
+ throw new Error("Complete unredacted token inspection required");
48
+ }
49
+ if (!pages.length ||
50
+ pages.length > 100 ||
51
+ new Set(pages.map((p) => p.pageId)).size !== pages.length ||
52
+ pages.some((p) => p.fileId !== rawTokens.fileId || p.revision !== rawTokens.revision) ||
53
+ pages.reduce((n, p) => n + p.nodes.length, 0) > 50000) {
54
+ throw new Error("Snapshot identity/revision/page/node budget mismatch");
55
+ }
56
+ for (const page of pages) {
57
+ validateDesignInspection({
58
+ schemaVersion: 1,
59
+ kind: "shape-inspection",
60
+ fileId: page.fileId,
61
+ pageId: page.pageId,
62
+ revision: page.revision,
63
+ complete: true,
64
+ counts: {},
65
+ reasons: [],
66
+ producer: {
67
+ name: "penpair",
68
+ version,
69
+ },
70
+ nodes: page.nodes,
71
+ });
72
+ if (Buffer.byteLength(structuralJson(page)) > 8 * 1024 * 1024) {
73
+ throw new Error("Page snapshot exceeds 8 MiB; no partial snapshot was published");
74
+ }
75
+ }
76
+ const snapshot = {
77
+ schemaVersion: 1,
78
+ kind: "structural-snapshot",
79
+ fileId: rawTokens.fileId,
80
+ revision: rawTokens.revision,
81
+ pages: structuredClone(pages).sort((a, b) => a.pageId < b.pageId ? -1 : a.pageId > b.pageId ? 1 : 0),
82
+ tokens: {
83
+ schemaVersion: 1,
84
+ kind: "token-structure",
85
+ fileId: rawTokens.fileId,
86
+ revision: rawTokens.revision,
87
+ sets: structuredClone(rawTokens.sets),
88
+ themes: structuredClone(rawTokens.themes),
89
+ },
90
+ fidelity: "review-only",
91
+ };
92
+ if (Buffer.byteLength(structuralJson(snapshot.tokens)) > 8 * 1024 * 1024 ||
93
+ Buffer.byteLength(structuralJson(snapshot)) > 128 * 1024 * 1024) {
94
+ throw new Error("Snapshot token/total byte budget exceeded");
95
+ }
96
+ return snapshot;
97
+ }
98
+ const listingSnippet = () => snippet("snapshot-pages", `
99
+ if (penpot.currentFile.pages.length > 100) return {ok:false,message:"Snapshot supports at most 100 pages"};
100
+ return {ok:true,fileId:penpot.currentFile.id,revision:penpot.currentFile.revn,pages:penpot.currentFile.pages.map(p=>({id:p.id,name:p.name}))};`, {}, "read");
101
+ export class SnapshotCaptureError extends Error {
102
+ progress;
103
+ restorationError;
104
+ constructor(message, progress, restorationError) {
105
+ super(message +
106
+ (restorationError ? `; visible-page restoration also failed: ${restorationError}` : ""));
107
+ this.progress = progress;
108
+ this.restorationError = restorationError;
109
+ }
110
+ }
111
+ export async function captureStructuralSnapshot(client, options = {}) {
112
+ return withPageSession(client, async () => {
113
+ const started = Date.now();
114
+ const progress = [];
115
+ const budget = () => {
116
+ const remaining = Math.min(300000, options.timeoutMs ?? 300000) - (Date.now() - started);
117
+ if (remaining < 1) {
118
+ throw new Error("Snapshot collection exceeded time budget");
119
+ }
120
+ return {
121
+ maxTotalBytes: 8 * 1024 * 1024,
122
+ timeoutMs: Math.min(60000, remaining),
123
+ };
124
+ };
125
+ const original = await observePage(client, {
126
+ ...options,
127
+ timeoutMs: budget().timeoutMs,
128
+ });
129
+ rememberPage(client, original);
130
+ let result;
131
+ let failure;
132
+ let restorationError;
133
+ const notify = (pageId, stage) => {
134
+ const event = {
135
+ pageId,
136
+ stage,
137
+ elapsedMs: Date.now() - started,
138
+ };
139
+ progress.push(event);
140
+ options.onProgress?.(event);
141
+ options.onStep?.(`snapshot ${stage}: page ${pageId} (${event.elapsedMs} ms)`);
142
+ };
143
+ try {
144
+ const before = await runSnippet(client, pinPageSnippet(client, listingSnippet()), {
145
+ ...options,
146
+ timeoutMs: budget().timeoutMs,
147
+ });
148
+ if (!Array.isArray(before.pages) ||
149
+ !before.pages.length ||
150
+ typeof before.fileId !== "string" ||
151
+ !Number.isInteger(before.revision)) {
152
+ throw new Error("Snapshot listing identity missing");
153
+ }
154
+ const pages = before.pages;
155
+ if (pages.some((p) => typeof p.id !== "string" || !p.id || typeof p.name !== "string") ||
156
+ new Set(pages.map((p) => p.id)).size !== pages.length ||
157
+ new Set(pages.map((p) => p.name)).size !== pages.length) {
158
+ throw new Error("Ambiguous page identity/name; page selection must be explicit and unique");
159
+ }
160
+ const captures = [];
161
+ let nodes = 0;
162
+ for (const page of pages) {
163
+ options.signal?.throwIfAborted();
164
+ notify(page.id, "start");
165
+ await selectPage(client, {
166
+ id: page.id,
167
+ }, {
168
+ ...options,
169
+ timeoutMs: budget().timeoutMs,
170
+ });
171
+ const capture = await captureInspection(client, {
172
+ kind: "shape",
173
+ shapeId: "@page-root",
174
+ page: page.name,
175
+ depth: 10000,
176
+ maxNodes: 50000,
177
+ documentBytes: 8 * 1024 * 1024,
178
+ }, budget());
179
+ await observePage(client, {
180
+ ...options,
181
+ timeoutMs: budget().timeoutMs,
182
+ });
183
+ if (!capture.complete ||
184
+ capture.kind !== "shape-inspection" ||
185
+ capture.pageId !== page.id) {
186
+ throw new Error(`Snapshot page ${page.id} inspection incomplete or selected identity changed`);
187
+ }
188
+ const data = structuralPage(capture, page.name);
189
+ nodes += data.nodes.length;
190
+ if (nodes > 50000) {
191
+ throw new Error("Snapshot file exceeds 50000 nodes");
192
+ }
193
+ captures.push(data);
194
+ notify(page.id, "complete");
195
+ }
196
+ const tokens = await captureInspection(client, {
197
+ kind: "tokens",
198
+ documentBytes: 8 * 1024 * 1024,
199
+ }, budget());
200
+ const after = await runSnippet(client, pinPageSnippet(client, listingSnippet()), {
201
+ ...options,
202
+ timeoutMs: budget().timeoutMs,
203
+ });
204
+ if (!tokens.complete ||
205
+ tokens.kind !== "token-inspection" ||
206
+ observationHash(before, true) !== observationHash(after, true)) {
207
+ throw new Error("Snapshot source changed or token inspection incomplete");
208
+ }
209
+ result = assembleStructuralSnapshot(captures, tokens);
210
+ if (result.fileId !== before.fileId || result.revision !== before.revision) {
211
+ throw new Error("Snapshot source changed during collection");
212
+ }
213
+ }
214
+ catch (error) {
215
+ failure = error;
216
+ }
217
+ finally {
218
+ try {
219
+ await withCleanupDeadline(async () => {
220
+ const current = await runSnippet(client, snippet("restore-page-state", "return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id};", {}, "read"), {
221
+ retries: 0,
222
+ timeoutMs: 5000,
223
+ });
224
+ if (current.fileId !== original.fileId || typeof current.pageId !== "string") {
225
+ throw new Error("Original file no longer open; cannot restore its visible page");
226
+ }
227
+ rememberPage(client, {
228
+ fileId: original.fileId,
229
+ pageId: current.pageId,
230
+ });
231
+ if (current.pageId !== original.pageId) {
232
+ await selectPage(client, {
233
+ id: original.pageId,
234
+ }, {
235
+ timeoutMs: 5000,
236
+ });
237
+ }
238
+ }, 10000);
239
+ }
240
+ catch (error) {
241
+ restorationError = error instanceof Error ? error.message : String(error);
242
+ }
243
+ }
244
+ if (failure || restorationError) {
245
+ throw new SnapshotCaptureError(failure instanceof Error
246
+ ? failure.message
247
+ : failure
248
+ ? String(failure)
249
+ : "Snapshot visible-page restoration failed", progress, restorationError);
250
+ }
251
+ return result;
252
+ });
253
+ }
254
+ async function readBounded(file, maximum) {
255
+ if ((await stat(file)).size > maximum) {
256
+ throw new Error("Structural artifact exceeds byte budget");
257
+ }
258
+ return JSON.parse(await readFile(file, "utf8"));
259
+ }
260
+ export async function writeStructuralSnapshot(root, out, snapshot, redact) {
261
+ snapshot = assembleStructuralSnapshot(snapshot.pages, {
262
+ ...snapshot.tokens,
263
+ kind: "token-inspection",
264
+ pageId: null,
265
+ complete: true,
266
+ reasons: [],
267
+ counts: {},
268
+ producer: {
269
+ name: "penpair",
270
+ version,
271
+ },
272
+ });
273
+ const pointer = await resolveProjectOutput(root, path.join(out, "snapshot.json"));
274
+ await mkdir(path.dirname(pointer), {
275
+ recursive: true,
276
+ });
277
+ const lock = await open(pointer + ".lock", "wx", 0o600);
278
+ try {
279
+ return await publishStructuralSnapshot(root, out, snapshot, redact);
280
+ }
281
+ finally {
282
+ await lock.close();
283
+ await unlink(pointer + ".lock");
284
+ }
285
+ }
286
+ async function publishStructuralSnapshot(root, out, snapshot, redact) {
287
+ const artifacts = new Map();
288
+ const pages = snapshot.pages.map((page) => {
289
+ const file = `page-${observationHash(page.pageId).slice(0, 16)}.json`;
290
+ artifacts.set(file, page);
291
+ return {
292
+ id: page.pageId,
293
+ name: page.name,
294
+ file,
295
+ hash: observationHash(page),
296
+ };
297
+ });
298
+ artifacts.set("tokens.json", snapshot.tokens);
299
+ const body = {
300
+ schemaVersion: 1,
301
+ kind: "structural-snapshot-manifest",
302
+ fileId: snapshot.fileId,
303
+ revision: snapshot.revision,
304
+ producer: `penpair@${version}`,
305
+ fidelity: "review-only",
306
+ pages,
307
+ tokens: {
308
+ file: "tokens.json",
309
+ hash: observationHash(snapshot.tokens),
310
+ },
311
+ };
312
+ const manifest = {
313
+ ...body,
314
+ hash: observationHash(body),
315
+ };
316
+ const encoded = structuralJson({
317
+ manifest,
318
+ artifacts: [
319
+ ...artifacts,
320
+ ],
321
+ });
322
+ if (redact && redact(encoded) !== encoded) {
323
+ throw new Error("Snapshot evidence requires redaction; previous artifacts preserved");
324
+ }
325
+ const pointer = await resolveProjectOutput(root, path.join(out, "snapshot.json"));
326
+ try {
327
+ const old = await readStructuralSnapshot(root, out);
328
+ if (old.fileId !== snapshot.fileId || old.revision > snapshot.revision) {
329
+ throw new Error("Refusing to replace another file's or newer structural snapshot");
330
+ }
331
+ }
332
+ catch (error) {
333
+ if (error.code !== "ENOENT") {
334
+ throw error;
335
+ }
336
+ }
337
+ for (const [file, data] of artifacts) {
338
+ const target = await resolveProjectOutput(root, path.join(out, "generations", manifest.hash, file));
339
+ await mkdir(path.dirname(target), {
340
+ recursive: true,
341
+ });
342
+ try {
343
+ await atomicWriteFile(target, structuralJson(data));
344
+ }
345
+ catch (error) {
346
+ if (error.code !== "EEXIST" ||
347
+ observationHash(await readBounded(target, 8 * 1024 * 1024)) !== observationHash(data)) {
348
+ throw error;
349
+ }
350
+ }
351
+ }
352
+ await atomicWriteFile(pointer, structuralJson(manifest), {
353
+ replace: true,
354
+ });
355
+ return manifest;
356
+ }
357
+ export async function readStructuralSnapshot(root, folder) {
358
+ const manifest = (await readBounded(await resolveProjectInput(root, path.join(folder, "snapshot.json")), 1024 * 1024));
359
+ const { hash, ...body } = manifest;
360
+ if (manifest.schemaVersion !== 1 ||
361
+ manifest.kind !== "structural-snapshot-manifest" ||
362
+ manifest.fidelity !== "review-only" ||
363
+ !/^[a-f0-9]{64}$/.test(hash) ||
364
+ hash !== observationHash(body) ||
365
+ !Array.isArray(manifest.pages) ||
366
+ !manifest.pages.length ||
367
+ manifest.pages.length > 100 ||
368
+ manifest.tokens.file !== "tokens.json") {
369
+ throw new Error("Unsupported or altered structural manifest");
370
+ }
371
+ const read = async (file, expected) => {
372
+ const data = await readBounded(await resolveProjectInput(root, path.join(folder, "generations", hash, file)), 8 * 1024 * 1024);
373
+ if (observationHash(data) !== expected) {
374
+ throw new Error("Structural artifact hash mismatch");
375
+ }
376
+ return data;
377
+ };
378
+ const pages = [];
379
+ for (const entry of manifest.pages) {
380
+ if (!/^page-[a-f0-9]{16}\.json$/.test(entry.file)) {
381
+ throw new Error("Invalid structural page artifact path");
382
+ }
383
+ const page = (await read(entry.file, entry.hash));
384
+ if (page.kind !== "page-structure" ||
385
+ page.pageId !== entry.id ||
386
+ page.name !== entry.name ||
387
+ page.fileId !== manifest.fileId ||
388
+ page.revision !== manifest.revision) {
389
+ throw new Error("Structural page identity mismatch");
390
+ }
391
+ pages.push(page);
392
+ }
393
+ const tokens = (await read("tokens.json", manifest.tokens.hash));
394
+ if (tokens.kind !== "token-structure" ||
395
+ tokens.fileId !== manifest.fileId ||
396
+ tokens.revision !== manifest.revision) {
397
+ throw new Error("Structural token identity mismatch");
398
+ }
399
+ return assembleStructuralSnapshot(pages, {
400
+ ...tokens,
401
+ kind: "token-inspection",
402
+ pageId: null,
403
+ complete: true,
404
+ reasons: [],
405
+ counts: {},
406
+ producer: {
407
+ name: "penpair",
408
+ version,
409
+ },
410
+ });
411
+ }
412
+ export function diffStructuralSnapshots(before, after, limit = 1000) {
413
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10000) {
414
+ throw new Error("Structural diff limit must be 1–10000");
415
+ }
416
+ if (before.fileId !== after.fileId) {
417
+ throw new Error("Structural snapshots belong to different files; identities are not comparable");
418
+ }
419
+ const changes = [];
420
+ let totalChanges = 0;
421
+ const add = (change) => {
422
+ totalChanges++;
423
+ if (changes.length < limit) {
424
+ changes.push(change);
425
+ }
426
+ };
427
+ const oldPages = new Map(before.pages.map((p) => [
428
+ p.pageId,
429
+ p,
430
+ ]));
431
+ const newPages = new Map(after.pages.map((p) => [
432
+ p.pageId,
433
+ p,
434
+ ]));
435
+ for (const id of [
436
+ ...new Set([
437
+ ...oldPages.keys(),
438
+ ...newPages.keys(),
439
+ ]),
440
+ ].sort()) {
441
+ const a = oldPages.get(id);
442
+ const b = newPages.get(id);
443
+ if (!a || !b) {
444
+ add({
445
+ kind: a ? "page-removed" : "page-added",
446
+ pageId: id,
447
+ before: a ?? null,
448
+ after: b ?? null,
449
+ });
450
+ continue;
451
+ }
452
+ if (a.name !== b.name) {
453
+ add({
454
+ kind: "page-name",
455
+ pageId: id,
456
+ before: a.name,
457
+ after: b.name,
458
+ });
459
+ }
460
+ const oldNodes = new Map(a.nodes.map((n) => [
461
+ n.id,
462
+ n,
463
+ ]));
464
+ const newNodes = new Map(b.nodes.map((n) => [
465
+ n.id,
466
+ n,
467
+ ]));
468
+ for (const nodeId of [
469
+ ...new Set([
470
+ ...oldNodes.keys(),
471
+ ...newNodes.keys(),
472
+ ]),
473
+ ].sort()) {
474
+ const x = oldNodes.get(nodeId);
475
+ const y = newNodes.get(nodeId);
476
+ if (!x || !y) {
477
+ add({
478
+ kind: x ? "node-removed" : "node-added",
479
+ pageId: id,
480
+ nodeId: String(nodeId),
481
+ before: x ?? null,
482
+ after: y ?? null,
483
+ });
484
+ continue;
485
+ }
486
+ for (const key of [
487
+ ...new Set([
488
+ ...Object.keys(x),
489
+ ...Object.keys(y),
490
+ ]),
491
+ ].sort()) {
492
+ if (observationHash(x[key] ?? null) !== observationHash(y[key] ?? null)) {
493
+ add({
494
+ kind: key,
495
+ pageId: id,
496
+ nodeId: String(nodeId),
497
+ before: x[key] ?? null,
498
+ after: y[key] ?? null,
499
+ });
500
+ }
501
+ }
502
+ }
503
+ }
504
+ if (observationHash(before.tokens.sets) !== observationHash(after.tokens.sets) ||
505
+ observationHash(before.tokens.themes) !== observationHash(after.tokens.themes)) {
506
+ add({
507
+ kind: "tokens",
508
+ before: before.tokens,
509
+ after: after.tokens,
510
+ });
511
+ }
512
+ return {
513
+ schemaVersion: 1,
514
+ kind: "structural-diff",
515
+ fileId: before.fileId,
516
+ beforeRevision: before.revision,
517
+ afterRevision: after.revision,
518
+ totalChanges,
519
+ complete: totalChanges <= limit,
520
+ changes,
521
+ fidelity: "review-only",
522
+ };
523
+ }
524
+ export const renderStructuralDiff = (diff) => "# Structural design diff\n\nReview evidence; not a full-fidelity backup or restore archive.\n\n" +
525
+ designDataBlock(diff);
@@ -0,0 +1,53 @@
1
+ import type { Operation } from "./operation.js";
2
+ export declare const tokenTypes: readonly ["borderRadius", "shadow", "color", "dimension", "fontFamilies", "fontSizes", "fontWeights", "letterSpacing", "number", "opacity", "rotation", "sizing", "spacing", "borderWidth", "textCase", "textDecoration", "typography"];
3
+ export interface EditableToken {
4
+ name: string;
5
+ type: string;
6
+ value: string | Record<string, string>;
7
+ description: string;
8
+ }
9
+ export type TokenEdit = {
10
+ kind: "add-set";
11
+ set: string;
12
+ } | {
13
+ kind: "set";
14
+ set: string;
15
+ token: EditableToken;
16
+ } | {
17
+ kind: "remove";
18
+ set: string;
19
+ name: string;
20
+ } | {
21
+ kind: "promote";
22
+ set: string;
23
+ to: string;
24
+ };
25
+ export interface TokenEditState {
26
+ sets: {
27
+ name: string;
28
+ active: boolean;
29
+ tokens: EditableToken[];
30
+ }[];
31
+ themes: {
32
+ group: string;
33
+ name: string;
34
+ active: boolean;
35
+ sets: string[];
36
+ }[];
37
+ bindings: {
38
+ id: string;
39
+ page: string;
40
+ tokens: Record<string, string>;
41
+ }[];
42
+ }
43
+ export interface TokenEditPlan {
44
+ before: TokenEditState;
45
+ after: TokenEditState;
46
+ changed: boolean;
47
+ impact: string[];
48
+ }
49
+ export declare function validateEditableToken(value: unknown): EditableToken;
50
+ export declare function validateTokenEdit(edit: TokenEdit): TokenEdit;
51
+ /** Pure desired-state planner. Kept self-contained so the delivered mutation rechecks the same facts. */
52
+ export declare function planTokenEdit(before: TokenEditState, edit: TokenEdit): TokenEditPlan;
53
+ export declare const tokenEditOperation: Operation<TokenEdit>;