@penpair/core 0.3.6 → 0.4.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 (129) hide show
  1. package/dist/adapters.d.ts +1 -1
  2. package/dist/adapters.js +3 -0
  3. package/dist/approvals.d.ts +4 -1
  4. package/dist/approvals.js +7 -4
  5. package/dist/baseline-coverage.js +3 -3
  6. package/dist/baselines-config.d.ts +3 -0
  7. package/dist/baselines-config.js +3 -0
  8. package/dist/baselines.d.ts +7 -0
  9. package/dist/baselines.js +90 -21
  10. package/dist/bounded-file.d.ts +2 -0
  11. package/dist/bounded-file.js +38 -0
  12. package/dist/cli-main.js +64 -3
  13. package/dist/design-commands/context.d.ts +9 -0
  14. package/dist/design-commands/context.js +1 -0
  15. package/dist/design-commands/index.d.ts +3 -0
  16. package/dist/design-commands/index.js +7 -0
  17. package/dist/design-commands/map-command.d.ts +4 -0
  18. package/dist/design-commands/map-command.js +685 -0
  19. package/dist/design-commands/release-command.d.ts +3 -0
  20. package/dist/design-commands/release-command.js +239 -0
  21. package/dist/design-releases/artifact-paths.d.ts +6 -0
  22. package/dist/design-releases/artifact-paths.js +82 -0
  23. package/dist/design-releases/assemble.d.ts +3 -0
  24. package/dist/design-releases/assemble.js +176 -0
  25. package/dist/design-releases/check.d.ts +16 -0
  26. package/dist/design-releases/check.js +323 -0
  27. package/dist/design-releases/context.d.ts +2 -0
  28. package/dist/design-releases/context.js +119 -0
  29. package/dist/design-releases/contract-graph.d.ts +13 -0
  30. package/dist/design-releases/contract-graph.js +75 -0
  31. package/dist/design-releases/fidelity.d.ts +23 -0
  32. package/dist/design-releases/fidelity.js +328 -0
  33. package/dist/design-releases/finalize.d.ts +4 -0
  34. package/dist/design-releases/finalize.js +201 -0
  35. package/dist/design-releases/index.d.ts +8 -0
  36. package/dist/design-releases/index.js +7 -0
  37. package/dist/design-releases/live.d.ts +22 -0
  38. package/dist/design-releases/live.js +97 -0
  39. package/dist/design-releases/snippets.d.ts +5 -0
  40. package/dist/design-releases/snippets.js +89 -0
  41. package/dist/design-releases/store.d.ts +10 -0
  42. package/dist/design-releases/store.js +205 -0
  43. package/dist/design-releases/types.d.ts +161 -0
  44. package/dist/design-releases/types.js +1 -0
  45. package/dist/design-releases/validation.d.ts +6 -0
  46. package/dist/design-releases/validation.js +384 -0
  47. package/dist/design-releases/verify.d.ts +3 -0
  48. package/dist/design-releases/verify.js +148 -0
  49. package/dist/image-codec.d.ts +3 -0
  50. package/dist/image-codec.js +78 -0
  51. package/dist/image-evidence.d.ts +15 -0
  52. package/dist/image-evidence.js +121 -0
  53. package/dist/index.d.ts +5 -0
  54. package/dist/index.js +5 -0
  55. package/dist/inspection.js +13 -5
  56. package/dist/instance/config-schema.d.ts +4 -0
  57. package/dist/instance/config-schema.js +7 -0
  58. package/dist/map-penpot/content-validation.d.ts +3 -0
  59. package/dist/map-penpot/content-validation.js +57 -0
  60. package/dist/map-penpot/content.d.ts +22 -0
  61. package/dist/map-penpot/content.js +173 -0
  62. package/dist/map-penpot/details.d.ts +4 -0
  63. package/dist/map-penpot/details.js +84 -0
  64. package/dist/map-penpot/evidence-error.d.ts +5 -0
  65. package/dist/map-penpot/evidence-error.js +9 -0
  66. package/dist/map-penpot/icons.d.ts +5 -0
  67. package/dist/map-penpot/icons.js +79 -0
  68. package/dist/map-penpot/import-content.d.ts +2 -0
  69. package/dist/map-penpot/import-content.js +150 -0
  70. package/dist/map-penpot/import.d.ts +5 -0
  71. package/dist/map-penpot/import.js +255 -0
  72. package/dist/map-penpot/index.d.ts +11 -0
  73. package/dist/map-penpot/index.js +10 -0
  74. package/dist/map-penpot/path-geometry.d.ts +5 -0
  75. package/dist/map-penpot/path-geometry.js +51 -0
  76. package/dist/map-penpot/presentation.d.ts +20 -0
  77. package/dist/map-penpot/presentation.js +205 -0
  78. package/dist/map-penpot/projection.d.ts +8 -0
  79. package/dist/map-penpot/projection.js +176 -0
  80. package/dist/map-penpot/publish.d.ts +4 -0
  81. package/dist/map-penpot/publish.js +301 -0
  82. package/dist/map-penpot/release-maps.d.ts +21 -0
  83. package/dist/map-penpot/release-maps.js +104 -0
  84. package/dist/map-penpot/snippets.d.ts +4 -0
  85. package/dist/map-penpot/snippets.js +164 -0
  86. package/dist/map-penpot/status-labels.d.ts +7 -0
  87. package/dist/map-penpot/status-labels.js +7 -0
  88. package/dist/map-penpot/types.d.ts +161 -0
  89. package/dist/map-penpot/types.js +1 -0
  90. package/dist/maps/annotation-layout.d.ts +24 -0
  91. package/dist/maps/annotation-layout.js +85 -0
  92. package/dist/maps/context.d.ts +13 -0
  93. package/dist/maps/context.js +207 -0
  94. package/dist/maps/index.d.ts +9 -0
  95. package/dist/maps/index.js +9 -0
  96. package/dist/maps/layout.d.ts +35 -0
  97. package/dist/maps/layout.js +364 -0
  98. package/dist/maps/proposal.d.ts +5 -0
  99. package/dist/maps/proposal.js +67 -0
  100. package/dist/maps/render.d.ts +4 -0
  101. package/dist/maps/render.js +331 -0
  102. package/dist/maps/review.d.ts +16 -0
  103. package/dist/maps/review.js +90 -0
  104. package/dist/maps/store.d.ts +21 -0
  105. package/dist/maps/store.js +222 -0
  106. package/dist/maps/types.d.ts +206 -0
  107. package/dist/maps/types.js +1 -0
  108. package/dist/maps/validation.d.ts +10 -0
  109. package/dist/maps/validation.js +375 -0
  110. package/dist/skill-routing.js +4 -0
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +2 -1
  114. package/skills/penpair-design-release/SKILL.md +55 -0
  115. package/skills/penpair-design-release/agents/openai.yaml +6 -0
  116. package/skills/penpair-design-release/references/workflow.md +55 -0
  117. package/skills/penpair-diagrams/SKILL.md +52 -0
  118. package/skills/penpair-diagrams/agents/openai.yaml +6 -0
  119. package/skills/penpair-diagrams/references/workflow.md +84 -0
  120. package/skills/penpair-handoff/SKILL.md +7 -0
  121. package/skills/penpair-handoff/references/handoff-flow.md +7 -1
  122. package/skills/penpair-integration-map/SKILL.md +44 -0
  123. package/skills/penpair-integration-map/agents/openai.yaml +6 -0
  124. package/skills/penpair-integration-map/references/workflow.md +33 -0
  125. package/skills/penpair-journey-map/SKILL.md +42 -0
  126. package/skills/penpair-journey-map/agents/openai.yaml +6 -0
  127. package/skills/penpair-journey-map/references/workflow.md +35 -0
  128. package/skills/penpair-verify/SKILL.md +7 -0
  129. package/skills/penpair-verify/references/browser-evidence.md +11 -1
@@ -51,7 +51,7 @@ export interface Adapter {
51
51
  /** Declares an adapter. Returns the definition unchanged, so a module can `export default defineAdapter({...})` with types checked. */
52
52
  export declare const defineAdapter: <T extends Adapter>(adapter: T) => T;
53
53
  /** The commands of the core itself; an adapter cannot take one of these names. */
54
- export declare const reservedCommands: readonly ["formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
54
+ export declare const reservedCommands: readonly ["journey-map", "integration-map", "design-release", "formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
55
55
  export interface LoadedAdapters {
56
56
  adapters: Adapter[];
57
57
  problems: string[];
package/dist/adapters.js CHANGED
@@ -7,6 +7,9 @@ import { readProjectConfig } from "./instance/config.js";
7
7
  export const defineAdapter = (adapter) => adapter;
8
8
  /** The commands of the core itself; an adapter cannot take one of these names. */
9
9
  export const reservedCommands = [
10
+ "journey-map",
11
+ "integration-map",
12
+ "design-release",
10
13
  "formats",
11
14
  "snapshot",
12
15
  "approve",
@@ -1,5 +1,6 @@
1
1
  import type { CaptureIdentity, ExportResult } from "./export.js";
2
2
  import { type BaselineCase } from "./baselines-config.js";
3
+ import type { BaselineImageFormat } from "./image-evidence.js";
3
4
  export interface ApprovalRecord {
4
5
  schemaVersion: 1;
5
6
  id: string;
@@ -28,5 +29,7 @@ export declare function readApprovals(root: string): Promise<ApprovalFile>;
28
29
  export declare function recordApproval(root: string, exported: ExportResult, words: {
29
30
  note: string;
30
31
  by: string;
31
- }, intent?: BaselineCase, redact?: (text: string) => string): Promise<ApprovalRecord>;
32
+ }, intent?: BaselineCase, redact?: (text: string) => string, options?: {
33
+ format?: BaselineImageFormat;
34
+ }): Promise<ApprovalRecord>;
32
35
  export declare function matchingApproval(root: string, capture: CaptureIdentity, sha256: string, intent?: BaselineCase): Promise<ApprovalRecord | undefined>;
package/dist/approvals.js CHANGED
@@ -7,6 +7,7 @@ import { observationHash } from "./journal.js";
7
7
  import { resolveProjectInput, resolveProjectOutput } from "./project-input.js";
8
8
  import { validateBaselineCase } from "./baselines-config.js";
9
9
  import { pngSize } from "./png.js";
10
+ import { encodeBaselineImage } from "./image-codec.js";
10
11
  export const approvalFile = "design/approvals.json";
11
12
  export const artifactSha = (bytes) => createHash("sha256").update(bytes).digest("hex");
12
13
  export function approvalFingerprint(capture, sha256, intent) {
@@ -91,7 +92,7 @@ export async function readApprovals(root) {
91
92
  }
92
93
  }
93
94
  /** Records supplied owner words; hashes detect accidental changes, not identity or consent. */
94
- export async function recordApproval(root, exported, words, intent, redact) {
95
+ export async function recordApproval(root, exported, words, intent, redact, options = {}) {
95
96
  exported = {
96
97
  ...exported,
97
98
  capture: structuredClone(exported.capture),
@@ -113,7 +114,9 @@ export async function recordApproval(root, exported, words, intent, redact) {
113
114
  words.by.length > 200) {
114
115
  throw new Error("Supply the owner's exact nonempty words and identity; an agent cannot invent approval");
115
116
  }
116
- const sha256 = artifactSha(exported.png);
117
+ const format = options.format ?? "png";
118
+ const imageBytes = await encodeBaselineImage(exported.png, format);
119
+ const sha256 = artifactSha(imageBytes);
117
120
  const fingerprint = approvalFingerprint(exported.capture, sha256, intent);
118
121
  if (intent &&
119
122
  (!matchesDesignViewport(exported.capture.designSize, intent.viewport) ||
@@ -142,7 +145,7 @@ export async function recordApproval(root, exported, words, intent, redact) {
142
145
  }
143
146
  : {}),
144
147
  sha256,
145
- image: `design/approvals/${sha256}.png`,
148
+ image: `design/approvals/${sha256}.${format}`,
146
149
  fingerprint,
147
150
  };
148
151
  const record = {
@@ -168,7 +171,7 @@ export async function recordApproval(root, exported, words, intent, redact) {
168
171
  recursive: true,
169
172
  });
170
173
  try {
171
- await atomicWriteFile(image, exported.png);
174
+ await atomicWriteFile(image, imageBytes);
172
175
  }
173
176
  catch (error) {
174
177
  if (error.code !== "EEXIST" ||
@@ -2,7 +2,7 @@ import { open } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { resolveProjectOutput } from "./project-input.js";
5
- import { pngSize } from "./png.js";
5
+ import { inspectImageEvidence } from "./image-evidence.js";
6
6
  import { matchesDesignViewport } from "./design-viewport.js";
7
7
  import { validateBaselines, } from "./baselines-config.js";
8
8
  import { observationHash } from "./journal.js";
@@ -228,13 +228,13 @@ export async function readBaselineCoverage(root, config, expectedFileId) {
228
228
  }
229
229
  const target = await resolveProjectOutput(root, path.join(config.root, result.file));
230
230
  const bytes = await boundedRead(target, 64 * 1024 * 1024);
231
- const size = pngSize(bytes);
231
+ const size = inspectImageEvidence(bytes);
232
232
  if (!size ||
233
233
  size.width < 1 ||
234
234
  size.height < 1 ||
235
235
  createHash("sha256").update(bytes).digest("hex") !== result.approved.sha256 ||
236
236
  result.sha256 !== result.approved.sha256) {
237
- throw new Error("Approved PNG bytes do not match their manifest");
237
+ throw new Error("Approved image bytes do not match their manifest");
238
238
  }
239
239
  result.imageWidth = size.width;
240
240
  result.imageHeight = size.height;
@@ -1,3 +1,4 @@
1
+ import type { BaselineImageFormat } from "./image-evidence.js";
1
2
  /** Declared design intent. It does not switch themes or establish browser behavior. */
2
3
  export interface BaselineCase {
3
4
  viewport: {
@@ -34,6 +35,8 @@ export interface BaselineFrame {
34
35
  }
35
36
  /** What `penpair baselines` exports: the frames of one page, as images in a folder of the project. */
36
37
  export interface BaselinesConfig {
38
+ /** Encoding for new references; existing approved formats require explicit migration. */
39
+ format?: BaselineImageFormat;
37
40
  page: string;
38
41
  /** Folder for the images, relative to the project. */
39
42
  root: string;
@@ -6,6 +6,9 @@ export function validateBaselines(value, fail) {
6
6
  return fail("baselines must be an object");
7
7
  }
8
8
  const v = value;
9
+ if (v.format !== undefined && v.format !== "png" && v.format !== "webp") {
10
+ fail("baselines.format must be png or webp");
11
+ }
9
12
  if (typeof v.page !== "string" || v.page.length === 0) {
10
13
  fail("baselines.page must be the name of a page");
11
14
  }
@@ -1,3 +1,4 @@
1
+ import { type BaselineImageFormat } from "./image-evidence.js";
1
2
  export { pngSize } from "./png.js";
2
3
  import type { PenpotToolClient } from "./client.js";
3
4
  import { type BaselineCoverage } from "./baseline-coverage.js";
@@ -70,6 +71,10 @@ export interface BaselinesReport {
70
71
  wrote: string[];
71
72
  }
72
73
  export interface BaselinesOptions extends ExportOptions {
74
+ /** Encoding for new references, or the explicit migration target. */
75
+ format?: BaselineImageFormat;
76
+ /** Change an existing reference's encoding; apply also requires update and exact-artifact approval. */
77
+ migrateFormat?: boolean;
73
78
  signal?: AbortSignal;
74
79
  /** Write images that are new. Without it nothing is written, whatever is found. */
75
80
  apply?: boolean;
@@ -78,6 +83,8 @@ export interface BaselinesOptions extends ExportOptions {
78
83
  /** Explicit waiver for a changed baseline; retained as a forced reference, never an owner approval. */
79
84
  forceReason?: string;
80
85
  }
86
+ /** Resolve the exact encoding used by both approval and baseline capture. */
87
+ export declare function resolveBaselineImageFormat(projectRoot: string, config: BaselinesConfig, baselineId: string, options?: Pick<BaselinesOptions, "format" | "migrateFormat">): Promise<BaselineImageFormat>;
81
88
  /**
82
89
  * Exports the configured frames of a page as images into the project's folder: the approved design that an implementation is
83
90
  * checked against. Looks first; writes only with `apply`, and replaces an image that differs only with `update`, so a change of
package/dist/baselines.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { matchingApproval } from "./approvals.js";
2
2
  import { matchesDesignViewport } from "./design-viewport.js";
3
3
  import { pngSize } from "./png.js";
4
+ import { encodeBaselineImage } from "./image-codec.js";
5
+ import { inspectImageEvidence } from "./image-evidence.js";
4
6
  export { pngSize } from "./png.js";
5
7
  import { createHash, randomUUID } from "node:crypto";
6
- import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
8
+ import { mkdir, readFile, writeFile, rename, rm, stat } from "node:fs/promises";
7
9
  import path from "node:path";
8
10
  import { baselineCoverage } from "./baseline-coverage.js";
9
11
  import { resolveProjectOutput } from "./project-input.js";
@@ -16,9 +18,16 @@ import { runOnPage } from "./operation.js";
16
18
  import { frames } from "./snippets/read.js";
17
19
  export * from "./baselines-config.js";
18
20
  const sha = (buffer) => createHash("sha256").update(buffer).digest("hex");
19
- async function readIfExists(file) {
21
+ async function readIfExists(file, maximum = 64 * 1024 * 1024) {
20
22
  try {
21
- return await readFile(file);
23
+ if ((await stat(file)).size > maximum) {
24
+ throw new Error("Baseline artifact exceeds its read budget");
25
+ }
26
+ const bytes = await readFile(file);
27
+ if (bytes.length > maximum) {
28
+ throw new Error("Baseline artifact exceeds its read budget");
29
+ }
30
+ return bytes;
22
31
  }
23
32
  catch (error) {
24
33
  if (error.code === "ENOENT") {
@@ -27,12 +36,72 @@ async function readIfExists(file) {
27
36
  throw error;
28
37
  }
29
38
  }
39
+ async function existingBaseline(projectRoot, config, id, old) {
40
+ const candidates = old
41
+ ? [
42
+ old.file,
43
+ ]
44
+ : [
45
+ `${id}.png`,
46
+ `${id}.webp`,
47
+ ];
48
+ let found = {};
49
+ for (const file of candidates) {
50
+ if (typeof file !== "string" || path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
51
+ throw new Error("Existing baseline path leaves its folder");
52
+ }
53
+ const bytes = await readIfExists(await resolveProjectOutput(projectRoot, path.join(config.root, file)));
54
+ if (!bytes) {
55
+ continue;
56
+ }
57
+ if (found.bytes) {
58
+ throw new Error("Existing baseline candidates are ambiguous; inspect before capture");
59
+ }
60
+ found = {
61
+ file,
62
+ bytes,
63
+ format: inspectImageEvidence(bytes).format,
64
+ };
65
+ }
66
+ if (old &&
67
+ (!found.bytes ||
68
+ old.sha256 !== sha(found.bytes) ||
69
+ (old.approved && old.approved.sha256 !== old.sha256))) {
70
+ throw new Error("Approved baseline bytes no longer match their manifest; inspect before replacing");
71
+ }
72
+ return found;
73
+ }
74
+ /** Resolve the exact encoding used by both approval and baseline capture. */
75
+ export async function resolveBaselineImageFormat(projectRoot, config, baselineId, options = {}) {
76
+ const target = options.format ?? config.format ?? "webp";
77
+ if (target !== "png" && target !== "webp") {
78
+ throw new Error("Baseline format must be png or webp");
79
+ }
80
+ const manifestPath = await resolveProjectOutput(projectRoot, path.join(config.root, "penpair-baselines.json"));
81
+ const bytes = await readIfExists(manifestPath, 16 * 1024 * 1024);
82
+ const previous = bytes ? JSON.parse(bytes.toString("utf8")) : undefined;
83
+ if (previous &&
84
+ ((previous.schemaVersion !== undefined && previous.schemaVersion !== 2) ||
85
+ !Array.isArray(previous.frames) ||
86
+ previous.frames.length > 1000)) {
87
+ throw new Error("Existing baseline manifest is invalid; preserve and inspect it");
88
+ }
89
+ const old = previous?.frames?.find((entry) => entry.id === baselineId);
90
+ const existing = await existingBaseline(projectRoot, config, baselineId, old);
91
+ return existing.format && !options.migrateFormat ? existing.format : target;
92
+ }
30
93
  /**
31
94
  * Exports the configured frames of a page as images into the project's folder: the approved design that an implementation is
32
95
  * checked against. Looks first; writes only with `apply`, and replaces an image that differs only with `update`, so a change of
33
96
  * the design never silently becomes the new reference.
34
97
  */
35
98
  export async function exportBaselines(client, projectRoot, config, options = {}) {
99
+ if (options.format !== undefined && options.format !== "png" && options.format !== "webp") {
100
+ throw new Error("Baseline format must be png or webp");
101
+ }
102
+ if (options.migrateFormat && options.apply && !options.update) {
103
+ throw new Error("Applying a baseline format migration requires update");
104
+ }
36
105
  if (options.forceReason !== undefined &&
37
106
  (!options.update ||
38
107
  !options.apply ||
@@ -65,7 +134,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
65
134
  const wrote = [];
66
135
  const folder = await resolveProjectOutput(projectRoot, config.root);
67
136
  const manifestPath = await resolveProjectOutput(projectRoot, path.join(config.root, "penpair-baselines.json"));
68
- const previousBytes = await readIfExists(manifestPath);
137
+ const previousBytes = await readIfExists(manifestPath, 16 * 1024 * 1024);
69
138
  const previous = previousBytes
70
139
  ? JSON.parse(previousBytes.toString("utf8"))
71
140
  : undefined;
@@ -80,7 +149,13 @@ export async function exportBaselines(client, projectRoot, config, options = {})
80
149
  for (const wanted of config.frames) {
81
150
  options.signal?.throwIfAborted();
82
151
  const old = previous?.frames?.find((entry) => entry.id === wanted.id);
83
- const file = old?.file ?? `${wanted.id}.png`;
152
+ const existingEvidence = await existingBaseline(projectRoot, config, wanted.id, old);
153
+ const format = existingEvidence.format && !options.migrateFormat
154
+ ? existingEvidence.format
155
+ : (options.format ?? config.format ?? "webp");
156
+ const migrating = Boolean(existingEvidence.format && existingEvidence.format !== format);
157
+ const existing = existingEvidence.bytes;
158
+ const file = existingEvidence.file ?? `${wanted.id}.${format}`;
84
159
  if (path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
85
160
  throw new Error("Existing baseline path leaves its folder");
86
161
  }
@@ -157,13 +232,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
157
232
  if (!pngSize(exported.png)) {
158
233
  throw new Error("Baseline export is not a PNG with dimensions");
159
234
  }
160
- const existing = await readIfExists(await resolveProjectOutput(projectRoot, path.join(config.root, file)));
161
- if (old &&
162
- (!existing ||
163
- old.sha256 !== sha(existing) ||
164
- (old.approved && old.approved.sha256 !== old.sha256))) {
165
- throw new Error("Approved baseline bytes no longer match their manifest; inspect before replacing");
166
- }
235
+ const encoded = await encodeBaselineImage(exported.png, format);
167
236
  const captured = {
168
237
  ...(wanted.case
169
238
  ? {
@@ -172,10 +241,10 @@ export async function exportBaselines(client, projectRoot, config, options = {})
172
241
  : {}),
173
242
  layout: exported.layout,
174
243
  ...exported.capture,
175
- sha256: sha(exported.png),
244
+ sha256: sha(encoded),
176
245
  capturedAt: new Date().toISOString(),
177
246
  toolVersion: version,
178
- file: `${generation}/${wanted.id}.png`,
247
+ file: `${generation}/${wanted.id}.${format}`,
179
248
  };
180
249
  const caseProblems = [];
181
250
  if (wanted.case) {
@@ -237,7 +306,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
237
306
  recordHash: record.hash,
238
307
  };
239
308
  }
240
- else if (options.forceReason) {
309
+ else if (options.forceReason && !migrating) {
241
310
  captured.approval = {
242
311
  kind: "forced-reference",
243
312
  reason: options.forceReason,
@@ -256,8 +325,8 @@ export async function exportBaselines(client, projectRoot, config, options = {})
256
325
  legacy: true,
257
326
  sha256: sha(existing),
258
327
  });
259
- const image = approve ? exported.png : existing;
260
- const size = pngSize(image);
328
+ const image = approve ? encoded : existing;
329
+ const size = inspectImageEvidence(image);
261
330
  results.push({
262
331
  ...base,
263
332
  file: approve && options.apply ? captured.file : file,
@@ -270,7 +339,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
270
339
  imageWidth: size?.width,
271
340
  imageHeight: size?.height,
272
341
  });
273
- captures.set(wanted.id, exported.png);
342
+ captures.set(`${wanted.id}.${format}`, encoded);
274
343
  }
275
344
  const after = await runOnPage(client, frames({
276
345
  page: config.page,
@@ -311,10 +380,10 @@ export async function exportBaselines(client, projectRoot, config, options = {})
311
380
  const staged = path.join(folder, `.stage-${runId}`);
312
381
  await mkdir(staged);
313
382
  try {
314
- for (const [id, bytes] of captures) {
383
+ for (const [file, bytes] of captures) {
315
384
  options.signal?.throwIfAborted();
316
- await atomicWriteFile(path.join(staged, `${id}.png`), bytes);
317
- options.onStep?.(`staging ${id}`);
385
+ await atomicWriteFile(path.join(staged, file), bytes);
386
+ options.onStep?.(`staging ${path.parse(file).name}`);
318
387
  }
319
388
  const cases = reflectionCases(config, results);
320
389
  await writeFile(path.join(staged, "reflection-cases.json"), `${JSON.stringify({
@@ -0,0 +1,2 @@
1
+ /** Read a regular project file with an allocation bound even if another process grows it. */
2
+ export declare function readProjectBytes(root: string, file: string, maxBytes: number): Promise<Buffer>;
@@ -0,0 +1,38 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { resolveProjectInput } from "./project-input.js";
4
+ /** Read a regular project file with an allocation bound even if another process grows it. */
5
+ export async function readProjectBytes(root, file, maxBytes) {
6
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
7
+ throw new Error("A positive file byte budget is required");
8
+ }
9
+ const input = await resolveProjectInput(root, file);
10
+ if (!(await lstat(input)).isFile()) {
11
+ throw new Error("Artifact input must be a regular file");
12
+ }
13
+ const handle = await open(input, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
14
+ try {
15
+ const info = await handle.stat();
16
+ if (!info.isFile() || info.size > maxBytes) {
17
+ throw new Error("Artifact input is not a regular file within its byte budget");
18
+ }
19
+ const chunks = [];
20
+ let total = 0;
21
+ while (total <= maxBytes) {
22
+ const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
23
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
24
+ if (bytesRead === 0) {
25
+ return Buffer.concat(chunks, total);
26
+ }
27
+ total += bytesRead;
28
+ if (total > maxBytes) {
29
+ throw new Error("Artifact input exceeds its byte budget");
30
+ }
31
+ chunks.push(chunk.subarray(0, bytesRead));
32
+ }
33
+ throw new Error("Artifact input exceeds its byte budget");
34
+ }
35
+ finally {
36
+ await handle.close();
37
+ }
38
+ }
package/dist/cli-main.js CHANGED
@@ -23,7 +23,8 @@ import { resolveProjectOutput } from "./project-input.js";
23
23
  import { mkdir } from "node:fs/promises";
24
24
  import { captureShots, exportShapeByName, findShapesByName } from "./shots.js";
25
25
  import { exportShapePng } from "./export.js";
26
- import { exportBaselines } from "./baselines.js";
26
+ import { exportBaselines, resolveBaselineImageFormat } from "./baselines.js";
27
+ import { encodeBaselineImage } from "./image-codec.js";
27
28
  import { archiveOperation, runOnPage, runOperation } from "./operation.js";
28
29
  import { formatDeepReport, runDeepCheck } from "./deep-check.js";
29
30
  import { probe } from "./probe.js";
@@ -54,6 +55,11 @@ import { backupNoteCheck } from "./backup-note.js";
54
55
  import { createCheckpoint } from "./native-api.js";
55
56
  import { listJournals, readJournal } from "./journal.js";
56
57
  const usageHead = `penpair ${version}
58
+ Design agreements and release pages:
59
+ penpair journey-map check|render|publish|import|accept|approve|context --from FILE
60
+ penpair integration-map check|render|publish|import|accept|approve|context --from FILE
61
+ penpair design-release plan|assemble|verify|finalize|check|context --from FILE
62
+ (publication previews by default; --apply writes Penpot, --write records local acceptance/review)
57
63
  Start here (guided, plain language; safe to run again):
58
64
  penpair setup asks where your Penpot is (web is the default), sets everything up and checks it
59
65
  penpair setup --web [--external <address>] Penpot on the web: design.penpot.app, or your team's address; no questions about it
@@ -202,6 +208,12 @@ async function main(argv, cli) {
202
208
  case: {
203
209
  type: "string",
204
210
  },
211
+ format: {
212
+ type: "string",
213
+ },
214
+ "migrate-format": {
215
+ type: "boolean",
216
+ },
205
217
  "force-reason": {
206
218
  type: "string",
207
219
  },
@@ -436,6 +448,7 @@ async function main(argv, cli) {
436
448
  "by",
437
449
  "case",
438
450
  "write",
451
+ "format",
439
452
  ],
440
453
  spec: [
441
454
  "from",
@@ -539,6 +552,8 @@ async function main(argv, cli) {
539
552
  "apply",
540
553
  "update",
541
554
  "force-reason",
555
+ "format",
556
+ "migrate-format",
542
557
  ],
543
558
  probe: [
544
559
  "deep",
@@ -629,6 +644,12 @@ async function main(argv, cli) {
629
644
  throw new Error(`--${key} is not supported by ${command || "this command"}`);
630
645
  }
631
646
  }
647
+ if (values.format && values.format !== "png" && values.format !== "webp") {
648
+ throw new CommandArgumentError("--format must be png or webp");
649
+ }
650
+ if (values["migrate-format"] && (!values.format || (values.apply && !values.update))) {
651
+ throw new CommandArgumentError("--migrate-format requires --format; applying it requires --update");
652
+ }
632
653
  for (const [key, min, max, integer] of [
633
654
  [
634
655
  "depth",
@@ -1786,6 +1807,8 @@ async function main(argv, cli) {
1786
1807
  apply: values.apply === true,
1787
1808
  update: values.update === true,
1788
1809
  forceReason: values["force-reason"],
1810
+ format: values.format,
1811
+ migrateFormat: values["migrate-format"],
1789
1812
  });
1790
1813
  output.result(report);
1791
1814
  for (const r of report.results) {
@@ -1908,6 +1931,7 @@ async function main(argv, cli) {
1908
1931
  throw new CommandArgumentError("approve --page PAGE --frame ID [--case CASE_ID] previews evidence; --write requires --note exact-owner-words and --by identity");
1909
1932
  }
1910
1933
  let intent;
1934
+ let imageFormat = values.format ?? "webp";
1911
1935
  if (values.case) {
1912
1936
  const baseline = projectConfig?.baselines;
1913
1937
  const wanted = baseline?.frames.find((f) => f.id === values.case);
@@ -1926,6 +1950,9 @@ async function main(argv, cli) {
1926
1950
  }
1927
1951
  }
1928
1952
  intent = wanted.case;
1953
+ imageFormat = values.format
1954
+ ? values.format
1955
+ : await resolveBaselineImageFormat(root, baseline, wanted.id);
1929
1956
  }
1930
1957
  const captured = await exportShapePng(client, values.frame, {
1931
1958
  ...options,
@@ -1935,17 +1962,21 @@ async function main(argv, cli) {
1935
1962
  if (!captured.stable) {
1936
1963
  throw new Error("Approval export did not stabilize");
1937
1964
  }
1938
- const sha256 = artifactSha(captured.png);
1965
+ const approvalImage = await encodeBaselineImage(captured.png, imageFormat);
1966
+ const sha256 = artifactSha(approvalImage);
1939
1967
  const report = values.write
1940
1968
  ? await recordApproval(root, captured, {
1941
1969
  note: values.note,
1942
1970
  by: values.by,
1943
- }, intent, endpointRedactor(endpoint))
1971
+ }, intent, endpointRedactor(endpoint), {
1972
+ format: imageFormat,
1973
+ })
1944
1974
  : {
1945
1975
  schemaVersion: 1,
1946
1976
  kind: "approval-candidate",
1947
1977
  capture: captured.capture,
1948
1978
  sha256,
1979
+ imageFormat,
1949
1980
  fingerprint: approvalFingerprint(captured.capture, sha256, intent),
1950
1981
  recorded: false,
1951
1982
  };
@@ -2148,6 +2179,36 @@ async function runAdapterCommand(command, root, args) {
2148
2179
  async function runCliInternal(argv, options = {}) {
2149
2180
  try {
2150
2181
  const root = projectRoot();
2182
+ if (argv[0] === "journey-map" ||
2183
+ argv[0] === "integration-map" ||
2184
+ argv[0] === "design-release") {
2185
+ const { runDesignCommand } = await import("./design-commands/index.js");
2186
+ let designRedactor = (text) => redactEndpoint(text);
2187
+ return await runDesignCommand(argv[0], argv.slice(1), {
2188
+ root,
2189
+ output,
2190
+ redact: (text) => designRedactor(text),
2191
+ async withPenpot(work) {
2192
+ const config = await loadCommandConfig(root);
2193
+ if (!config.file?.id) {
2194
+ throw new Error("Live design commands require an explicitly pinned Penpot file");
2195
+ }
2196
+ const endpoint = await projectEndpoint(root);
2197
+ designRedactor = endpointRedactor(endpoint);
2198
+ commandRedactor(endpointRedactor(endpoint));
2199
+ assertEndpointIsInstance(endpoint.description, config);
2200
+ const client = guardFile(await connect(endpoint), config.file, {
2201
+ mode: "strict",
2202
+ });
2203
+ try {
2204
+ return await work(client);
2205
+ }
2206
+ finally {
2207
+ await client.close();
2208
+ }
2209
+ },
2210
+ });
2211
+ }
2151
2212
  if (argv[0] === "onboard") {
2152
2213
  if (argv.slice(1).some((arg) => arg !== "--json")) {
2153
2214
  throw new Error("usage: penpair onboard [--json]");
@@ -0,0 +1,9 @@
1
+ import type { CommandOutput } from "../command-result.js";
2
+ import type { PenpotToolClient } from "../client.js";
3
+ /** Project-owned artifact commands can work offline; a live operation opens a strictly pinned connection. */
4
+ export interface DesignCommandContext {
5
+ root: string;
6
+ output: CommandOutput;
7
+ redact(value: string): string;
8
+ withPenpot<T>(work: (client: PenpotToolClient) => Promise<T>): Promise<T>;
9
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { DesignCommandContext } from "./context.js";
2
+ export type { DesignCommandContext } from "./context.js";
3
+ export declare function runDesignCommand(family: "journey-map" | "integration-map" | "design-release", args: string[], context: DesignCommandContext): Promise<number>;
@@ -0,0 +1,7 @@
1
+ import { runMapCommand } from "./map-command.js";
2
+ import { runDesignReleaseCommand } from "./release-command.js";
3
+ export async function runDesignCommand(family, args, context) {
4
+ return family === "design-release"
5
+ ? runDesignReleaseCommand(args, context)
6
+ : runMapCommand(family, args, context);
7
+ }
@@ -0,0 +1,4 @@
1
+ import type { MapKind } from "../maps/types.js";
2
+ import type { DesignCommandContext } from "./context.js";
3
+ /** Explicit local artifact actions remain offline; native publication is preview-only until --apply. */
4
+ export declare function runMapCommand(kind: MapKind, args: string[], context: DesignCommandContext): Promise<number>;