@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
@@ -5,75 +5,184 @@ import { readBaselineCoverage } from "./baseline-coverage.js";
5
5
  import { redactEndpoint } from "./endpoint.js";
6
6
  export const projectFileUrl = (config) => {
7
7
  const url = onboardingChecklist(config).fileUrl;
8
- if (!url)
8
+ if (!url) {
9
9
  throw new Error("Pin the owner-confirmed file before opening it");
10
+ }
10
11
  return url;
11
12
  };
12
- export async function openProject(config, open) { const url = projectFileUrl(config); await open(url); return url; }
13
+ export async function openProject(config, open) {
14
+ const url = projectFileUrl(config);
15
+ await open(url);
16
+ return url;
17
+ }
13
18
  export async function projectStatus(root, config, options = {}) {
14
- const origin = onboardingChecklist(config).origin, lines = [];
19
+ const origin = onboardingChecklist(config).origin;
20
+ const lines = [];
15
21
  const safe = (error) => (options.redact ?? redactEndpoint)(String(error.message));
16
22
  let reachable = false;
17
23
  try {
18
- reachable = await (options.reachable ?? (async (origin) => (await fetch(origin, { redirect: "manual", signal: AbortSignal.timeout(5000) })).status < 500))(origin);
24
+ reachable = await (options.reachable ??
25
+ (async (origin) => (await fetch(origin, {
26
+ redirect: "manual",
27
+ signal: AbortSignal.timeout(5000),
28
+ })).status < 500))(origin);
19
29
  }
20
30
  catch { }
21
- lines.push({ id: "deployment", label: "Penpot", state: reachable ? "ok" : "failed", detail: reachable ? `${origin} answers` : `${origin} is unavailable` });
22
- lines.push({ id: "file-pin", label: "File pin", state: config.file ? "ok" : "unknown", detail: config.file ? config.file.name ?? config.file.id : "No owner-confirmed file pin" });
31
+ lines.push({
32
+ id: "deployment",
33
+ label: "Penpot",
34
+ state: reachable ? "ok" : "failed",
35
+ detail: reachable ? `${origin} answers` : `${origin} is unavailable`,
36
+ });
37
+ lines.push({
38
+ id: "file-pin",
39
+ label: "File pin",
40
+ state: config.file ? "ok" : "unknown",
41
+ detail: config.file ? (config.file.name ?? config.file.id) : "No owner-confirmed file pin",
42
+ });
23
43
  let connection = null;
24
44
  if (options.connection) {
25
45
  let client;
26
46
  try {
27
47
  client = await options.connection();
28
48
  const observation = await runSnippet(client, snippet("project-status", `return {ok:true,fileId:penpot.currentFile.id,file:penpot.currentFile.name,revision:penpot.currentFile.revn,pages:penpot.currentFile.pages.map(p=>({id:p.id,name:p.name})),apiVersion:typeof penpot.version==="string"?penpot.version:null,capabilities:{tokens:!!penpot.library?.local?.tokens,fonts:!!penpot.fonts,nativeLayout:typeof penpot.currentPage.root.waitForLayoutUpdate==="function"}};`, {}, "read"));
29
- if (!config.file || observation.fileId !== config.file.id || !Array.isArray(observation.pages) || !Number.isInteger(observation.revision))
49
+ if (!config.file ||
50
+ observation.fileId !== config.file.id ||
51
+ !Array.isArray(observation.pages) ||
52
+ !Number.isInteger(observation.revision)) {
30
53
  throw new Error("Status did not observe the configured file identity/revision");
54
+ }
31
55
  const { execution: _execution, ...data } = observation;
32
56
  connection = data;
33
- lines.push({ id: "connection", label: "Editor and plugin", state: "ok", detail: `Pinned file connected; ${observation.pages.length} pages` });
57
+ lines.push({
58
+ id: "connection",
59
+ label: "Editor and plugin",
60
+ state: "ok",
61
+ detail: `Pinned file connected; ${observation.pages.length} pages`,
62
+ });
34
63
  }
35
64
  catch (error) {
36
- lines.push({ id: "connection", label: "Editor and plugin", state: "failed", detail: safe(error) });
65
+ lines.push({
66
+ id: "connection",
67
+ label: "Editor and plugin",
68
+ state: "failed",
69
+ detail: safe(error),
70
+ });
37
71
  }
38
72
  finally {
39
73
  await client?.close();
40
74
  }
41
75
  }
42
- else
43
- lines.push({ id: "connection", label: "Editor and plugin", state: "unknown", detail: "No resolved project connection" });
76
+ else {
77
+ lines.push({
78
+ id: "connection",
79
+ label: "Editor and plugin",
80
+ state: "unknown",
81
+ detail: "No resolved project connection",
82
+ });
83
+ }
44
84
  try {
45
- const skills = await installSkills({ root, dryRun: true });
46
- lines.push({ id: "skills", label: "Skills", state: skills.wouldInstall?.length || skills.wouldLink?.length || skills.kept.length ? "warning" : "ok", detail: `${skills.wouldInstall?.length ?? 0} missing/update candidates; ${skills.kept.length} locally edited files preserved; discovery is not verified` });
85
+ const skills = await installSkills({
86
+ root,
87
+ dryRun: true,
88
+ });
89
+ lines.push({
90
+ id: "skills",
91
+ label: "Skills",
92
+ state: skills.wouldInstall?.length || skills.wouldLink?.length || skills.kept.length
93
+ ? "warning"
94
+ : "ok",
95
+ detail: `${skills.wouldInstall?.length ?? 0} missing/update candidates; ${skills.kept.length} locally edited files preserved; discovery is not verified`,
96
+ });
47
97
  }
48
98
  catch (error) {
49
- lines.push({ id: "skills", label: "Skills", state: "unknown", detail: safe(error) });
99
+ lines.push({
100
+ id: "skills",
101
+ label: "Skills",
102
+ state: "unknown",
103
+ detail: safe(error),
104
+ });
50
105
  }
51
106
  let coverage = null;
52
107
  if (config.baselines) {
53
108
  try {
54
109
  const found = await readBaselineCoverage(root, config.baselines, config.file?.id);
55
110
  coverage = found;
56
- lines.push({ id: "baselines", label: "Baselines", state: found.designCasesComplete ? "ok" : "warning", detail: found.configured ? "See explicit case coverage" : "No complete rich coverage matrix declared" });
111
+ lines.push({
112
+ id: "baselines",
113
+ label: "Baselines",
114
+ state: found.designCasesComplete ? "ok" : "warning",
115
+ detail: found.configured
116
+ ? "See explicit case coverage"
117
+ : "No complete rich coverage matrix declared",
118
+ });
57
119
  }
58
120
  catch (error) {
59
- lines.push({ id: "baselines", label: "Baselines", state: "failed", detail: safe(error) });
121
+ lines.push({
122
+ id: "baselines",
123
+ label: "Baselines",
124
+ state: "failed",
125
+ detail: safe(error),
126
+ });
60
127
  }
61
128
  }
62
- else
63
- lines.push({ id: "baselines", label: "Baselines", state: "unknown", detail: "No baseline configuration" });
129
+ else {
130
+ lines.push({
131
+ id: "baselines",
132
+ label: "Baselines",
133
+ state: "unknown",
134
+ detail: "No baseline configuration",
135
+ });
136
+ }
64
137
  if (options.adapterStatus) {
65
138
  try {
66
139
  const adapterLines = await options.adapterStatus();
67
- if (!Array.isArray(adapterLines) || adapterLines.length > 100 || adapterLines.some(l => !l || typeof l.id !== "string" || typeof l.label !== "string" || typeof l.detail !== "string" || !["ok", "warning", "failed", "unknown"].includes(l.state)))
140
+ if (!Array.isArray(adapterLines) ||
141
+ adapterLines.length > 100 ||
142
+ adapterLines.some((l) => !l ||
143
+ typeof l.id !== "string" ||
144
+ typeof l.label !== "string" ||
145
+ typeof l.detail !== "string" ||
146
+ ![
147
+ "ok",
148
+ "warning",
149
+ "failed",
150
+ "unknown",
151
+ ].includes(l.state))) {
68
152
  throw new Error("Invalid adapter status lines");
153
+ }
69
154
  lines.push(...adapterLines);
70
155
  }
71
156
  catch (error) {
72
- lines.push({ id: "adapters", label: "Adapters", state: "failed", detail: safe(error) });
157
+ lines.push({
158
+ id: "adapters",
159
+ label: "Adapters",
160
+ state: "failed",
161
+ detail: safe(error),
162
+ });
73
163
  }
74
164
  }
75
- else if (config.adapters?.length)
76
- lines.push({ id: "adapters", label: "Adapters", state: "unknown", detail: "Not loaded; --adapters explicitly executes trusted status hooks" });
77
- const complete = lines.every(l => l.state !== "unknown"), ok = complete && lines.every(l => l.state === "ok");
78
- return { schemaVersion: 1, kind: "project-status", project: config.instance, origin, fileUrl: config.file ? projectFileUrl(config) : null, connection, coverage, lines, complete, ok, visualApproval: false };
165
+ else if (config.adapters?.length) {
166
+ lines.push({
167
+ id: "adapters",
168
+ label: "Adapters",
169
+ state: "unknown",
170
+ detail: "Not loaded; --adapters explicitly executes trusted status hooks",
171
+ });
172
+ }
173
+ const complete = lines.every((l) => l.state !== "unknown");
174
+ const ok = complete && lines.every((l) => l.state === "ok");
175
+ return {
176
+ schemaVersion: 1,
177
+ kind: "project-status",
178
+ project: config.instance,
179
+ origin,
180
+ fileUrl: config.file ? projectFileUrl(config) : null,
181
+ connection,
182
+ coverage,
183
+ lines,
184
+ complete,
185
+ ok,
186
+ visualApproval: false,
187
+ };
79
188
  }
package/dist/registry.js CHANGED
@@ -8,44 +8,77 @@ export const defineSnippet = (definition) => definition;
8
8
  export class RegistryError extends Error {
9
9
  }
10
10
  const namePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
11
- const paramTypes = ["string", "number", "boolean", "string[]", "number[]"];
12
- const bodyOf = (body, params) => (typeof body === "function" ? body(params) : body);
11
+ const paramTypes = [
12
+ "string",
13
+ "number",
14
+ "boolean",
15
+ "string[]",
16
+ "number[]",
17
+ ];
18
+ const bodyOf = (body, params) => typeof body === "function" ? body(params) : body;
13
19
  const pageOf = (page) => page === undefined ? undefined : typeof page === "function" ? page : () => page;
14
20
  /** Whatever page the definition names is handed to the snippet as CFG.page, with the guard in front of the body. */
15
21
  function pageAware(def, params) {
16
22
  const page = pageOf(def.page)?.(params);
17
- return page === undefined ? { cfg: params, guard: "" } : { cfg: { ...params, page }, guard: `${requirePage}\n` };
23
+ return page === undefined
24
+ ? {
25
+ cfg: params,
26
+ guard: "",
27
+ }
28
+ : {
29
+ cfg: {
30
+ ...params,
31
+ page,
32
+ },
33
+ guard: `${requirePage}\n`,
34
+ };
18
35
  }
19
36
  /** Turns a definition into what the registry runs. Throws a RegistryError that says what is wrong with the definition. */
20
37
  export function normalize(def, source) {
21
38
  const fail = (message) => {
22
39
  throw new RegistryError(`snippet ${JSON.stringify(def.name)}: ${message}`);
23
40
  };
24
- if (typeof def !== "object" || def === null)
41
+ if (typeof def !== "object" || def === null) {
25
42
  return fail("a snippet must be an object");
26
- if (typeof def.name !== "string" || !namePattern.test(def.name))
43
+ }
44
+ if (typeof def.name !== "string" || !namePattern.test(def.name)) {
27
45
  fail("name must be lowercase words joined by hyphens");
28
- if (typeof def.description !== "string" || def.description.trim() === "")
46
+ }
47
+ if (typeof def.description !== "string" || def.description.trim() === "") {
29
48
  fail("a description is required");
49
+ }
30
50
  const params = def.params ?? [];
31
51
  const seen = new Set();
32
52
  for (const spec of params) {
33
- if (!/^[A-Za-z][A-Za-z0-9]*$/.test(spec.name))
53
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(spec.name)) {
34
54
  fail(`parameter name ${JSON.stringify(spec.name)} is not valid`);
35
- if (!paramTypes.includes(spec.type))
55
+ }
56
+ if (!paramTypes.includes(spec.type)) {
36
57
  fail(`parameter ${spec.name} has an unknown type ${JSON.stringify(spec.type)}`);
37
- if (seen.has(spec.name))
58
+ }
59
+ if (seen.has(spec.name)) {
38
60
  fail(`parameter ${spec.name} is declared twice`);
61
+ }
39
62
  seen.add(spec.name);
40
63
  }
41
- const common = { name: def.name, description: def.description, params, source };
64
+ const common = {
65
+ name: def.name,
66
+ description: def.description,
67
+ params,
68
+ source,
69
+ };
42
70
  if (def.kind === "read") {
43
- if (typeof def.body !== "string" && typeof def.body !== "function")
71
+ if (typeof def.body !== "string" && typeof def.body !== "function") {
44
72
  fail("a read snippet needs a body");
73
+ }
45
74
  return {
46
75
  kind: "read",
47
76
  ...common,
48
- ...(def.page === undefined ? {} : { page: pageOf(def.page) }),
77
+ ...(def.page === undefined
78
+ ? {}
79
+ : {
80
+ page: pageOf(def.page),
81
+ }),
49
82
  build(p) {
50
83
  const { cfg, guard } = pageAware(def, p);
51
84
  return snippet(def.name, `${guard}${bodyOf(def.body, p)}`, cfg);
@@ -53,9 +86,15 @@ export function normalize(def, source) {
53
86
  };
54
87
  }
55
88
  if (def.kind === "write") {
56
- for (const phase of ["inspect", "apply", "readback"])
57
- if (typeof def[phase] !== "string" && typeof def[phase] !== "function")
89
+ for (const phase of [
90
+ "inspect",
91
+ "apply",
92
+ "readback",
93
+ ]) {
94
+ if (typeof def[phase] !== "string" && typeof def[phase] !== "function") {
58
95
  fail(`a write snippet needs ${phase}`);
96
+ }
97
+ }
59
98
  const phaseSnippet = (phase) => (p) => {
60
99
  const { cfg, guard } = pageAware(def, p);
61
100
  return snippet(`${def.name}-${phase}`, `${guard}${bodyOf(def[phase], p)}`, cfg);
@@ -69,9 +108,12 @@ export function normalize(def, source) {
69
108
  inspect: phaseSnippet("inspect"),
70
109
  apply: phaseSnippet("apply"),
71
110
  readback: phaseSnippet("readback"),
72
- verify: def.verify ??
73
- (({ readback }) => verifyEvidence(readback)),
74
- ...(def.reconcile ? { reconcile: def.reconcile } : {}),
111
+ verify: def.verify ?? (({ readback }) => verifyEvidence(readback)),
112
+ ...(def.reconcile
113
+ ? {
114
+ reconcile: def.reconcile,
115
+ }
116
+ : {}),
75
117
  },
76
118
  };
77
119
  }
@@ -80,27 +122,33 @@ export function normalize(def, source) {
80
122
  /** Checks that every phase of a snippet is valid plugin code and small enough, using only the declared defaults. No connection needed. */
81
123
  export function checkEntry(entry) {
82
124
  const sample = {};
83
- for (const spec of entry.params)
84
- if (spec.default !== undefined)
125
+ for (const spec of entry.params) {
126
+ if (spec.default !== undefined) {
85
127
  sample[spec.name] = spec.default;
128
+ }
129
+ }
86
130
  const problems = [];
87
131
  const snippets = [];
88
132
  try {
89
- if (entry.kind === "read")
133
+ if (entry.kind === "read") {
90
134
  snippets.push(entry.build(sample));
91
- else
135
+ }
136
+ else {
92
137
  snippets.push(entry.operation.inspect(sample), entry.operation.apply(sample), entry.operation.readback(sample));
138
+ }
93
139
  }
94
140
  catch (error) {
95
141
  // A builder may need a real parameter to build at all; then only the rest of the checks apply.
96
142
  const message = String(error.message);
97
- if (/bytes/.test(message))
143
+ if (/bytes/.test(message)) {
98
144
  problems.push(message);
145
+ }
99
146
  }
100
147
  for (const s of snippets) {
101
148
  const error = compiles(s.code);
102
- if (error)
149
+ if (error) {
103
150
  problems.push(`${s.name}: ${error}`);
151
+ }
104
152
  }
105
153
  return problems;
106
154
  }
@@ -108,16 +156,22 @@ export function checkEntry(entry) {
108
156
  export function coerceParams(specs, raw) {
109
157
  const out = {};
110
158
  const known = new Set(specs.map((s) => s.name));
111
- for (const key of Object.keys(raw))
112
- if (!known.has(key))
113
- throw new RegistryError(`unknown parameter ${key}; known: ${[...known].join(", ") || "none"}`);
159
+ for (const key of Object.keys(raw)) {
160
+ if (!known.has(key)) {
161
+ throw new RegistryError(`unknown parameter ${key}; known: ${[
162
+ ...known,
163
+ ].join(", ") || "none"}`);
164
+ }
165
+ }
114
166
  for (const spec of specs) {
115
167
  const value = raw[spec.name];
116
168
  if (value === undefined) {
117
- if (spec.default !== undefined)
169
+ if (spec.default !== undefined) {
118
170
  out[spec.name] = spec.default;
119
- else if (spec.required)
171
+ }
172
+ else if (spec.required) {
120
173
  throw new RegistryError(`parameter ${spec.name} is required`);
174
+ }
121
175
  continue;
122
176
  }
123
177
  const text = Array.isArray(value) ? value.join(",") : value;
@@ -127,23 +181,32 @@ export function coerceParams(specs, raw) {
127
181
  break;
128
182
  case "number": {
129
183
  const n = Number(text);
130
- if (!Number.isFinite(n))
184
+ if (!Number.isFinite(n)) {
131
185
  throw new RegistryError(`parameter ${spec.name} must be a number`);
186
+ }
132
187
  out[spec.name] = n;
133
188
  break;
134
189
  }
135
190
  case "boolean":
136
- if (!["true", "false"].includes(text))
191
+ if (![
192
+ "true",
193
+ "false",
194
+ ].includes(text)) {
137
195
  throw new RegistryError(`parameter ${spec.name} must be true or false`);
196
+ }
138
197
  out[spec.name] = text === "true";
139
198
  break;
140
199
  case "string[]":
141
- out[spec.name] = text.split(",").map((v) => v.trim()).filter(Boolean);
200
+ out[spec.name] = text
201
+ .split(",")
202
+ .map((v) => v.trim())
203
+ .filter(Boolean);
142
204
  break;
143
205
  case "number[]": {
144
206
  const list = text.split(",").map((v) => Number(v.trim()));
145
- if (list.some((n) => !Number.isFinite(n)))
207
+ if (list.some((n) => !Number.isFinite(n))) {
146
208
  throw new RegistryError(`parameter ${spec.name} must be numbers separated by commas`);
209
+ }
147
210
  out[spec.name] = list;
148
211
  break;
149
212
  }
@@ -153,40 +216,70 @@ export function coerceParams(specs, raw) {
153
216
  }
154
217
  /** An absent error list is not evidence that the intended state exists. */
155
218
  function verifyEvidence(readback) {
156
- if (readback.problems !== undefined && (!Array.isArray(readback.problems) || readback.problems.some((problem) => typeof problem !== "string")))
157
- return ["Readback problems must be a list of strings"];
158
- if (Array.isArray(readback.problems) && readback.problems.length > 0)
219
+ if (readback.problems !== undefined &&
220
+ (!Array.isArray(readback.problems) ||
221
+ readback.problems.some((problem) => typeof problem !== "string"))) {
222
+ return [
223
+ "Readback problems must be a list of strings",
224
+ ];
225
+ }
226
+ if (Array.isArray(readback.problems) && readback.problems.length > 0) {
159
227
  return readback.problems;
228
+ }
160
229
  const evidence = readback.verification;
161
- const observation = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
162
- if (!evidence || !observation(evidence.expected) || !observation(evidence.actual))
163
- return ["Readback needs nonempty verification.expected and verification.actual observations, or an explicit verifier"];
164
- return isDeepStrictEqual(evidence.expected, evidence.actual) ? [] : ["Readback observations do not match the expected state"];
230
+ const observation = (value) => typeof value === "object" &&
231
+ value !== null &&
232
+ !Array.isArray(value) &&
233
+ Object.keys(value).length > 0;
234
+ if (!evidence || !observation(evidence.expected) || !observation(evidence.actual)) {
235
+ return [
236
+ "Readback needs nonempty verification.expected and verification.actual observations, or an explicit verifier",
237
+ ];
238
+ }
239
+ return isDeepStrictEqual(evidence.expected, evidence.actual)
240
+ ? []
241
+ : [
242
+ "Readback observations do not match the expected state",
243
+ ];
165
244
  }
166
245
  export class SnippetRegistry {
167
246
  entries = new Map();
168
247
  register(def, source = "project") {
169
- const entry = "build" in def || "operation" in def ? def : normalize(def, source);
170
- if (this.entries.has(entry.name))
248
+ const entry = "build" in def || "operation" in def
249
+ ? def
250
+ : normalize(def, source);
251
+ if (this.entries.has(entry.name)) {
171
252
  throw new RegistryError(`snippet ${entry.name} is already registered (${this.entries.get(entry.name).source})`);
253
+ }
172
254
  this.entries.set(entry.name, entry);
173
255
  return entry;
174
256
  }
175
257
  get(name) {
176
258
  const entry = this.entries.get(name);
177
- if (!entry)
178
- throw new RegistryError(`no snippet named ${name}. Known: ${[...this.entries.keys()].sort().join(", ")}`);
259
+ if (!entry) {
260
+ throw new RegistryError(`no snippet named ${name}. Known: ${[
261
+ ...this.entries.keys(),
262
+ ]
263
+ .sort()
264
+ .join(", ")}`);
265
+ }
179
266
  return entry;
180
267
  }
181
268
  list() {
182
- return [...this.entries.values()].sort((a, b) => a.name.localeCompare(b.name));
269
+ return [
270
+ ...this.entries.values(),
271
+ ].sort((a, b) => a.name.localeCompare(b.name));
183
272
  }
184
273
  /** Runs a snippet by name. A write snippet only looks unless `apply` is set. */
185
274
  async run(client, name, params, options = {}) {
186
275
  const entry = this.get(name);
187
- if (entry.kind === "write")
276
+ if (entry.kind === "write") {
188
277
  return runOperation(client, entry.operation, params, options);
189
- const record = await runOnPage(client, entry.build(params), entry.page?.(params), { ...options, effect: "read" });
278
+ }
279
+ const record = await runOnPage(client, entry.build(params), entry.page?.(params), {
280
+ ...options,
281
+ effect: "read",
282
+ });
190
283
  return record;
191
284
  }
192
285
  }
@@ -196,7 +289,11 @@ const readBuiltIn = (name, description, params, build) => ({
196
289
  description,
197
290
  params,
198
291
  source: "built-in",
199
- ...(params.some((p) => p.name === "page") ? { page: (p) => p.page } : {}),
292
+ ...(params.some((p) => p.name === "page")
293
+ ? {
294
+ page: (p) => p.page,
295
+ }
296
+ : {}),
200
297
  build,
201
298
  });
202
299
  /** The registry with everything penpair ships. Project snippets are added to it. */
@@ -204,16 +301,52 @@ export function builtInRegistry() {
204
301
  const registry = new SnippetRegistry();
205
302
  registry.register(readBuiltIn("pages", "The file, its revision, its pages and how many top-level shapes each has.", [], () => pages()), "built-in");
206
303
  registry.register(readBuiltIn("tree", "One line per shape: type, name, size, position, layout and sizing, and the text it carries.", [
207
- { name: "page", type: "string", required: true, description: "Page to read" },
208
- { name: "depth", type: "number", default: 3, description: "How many levels down" },
209
- { name: "namePrefix", type: "string", description: "Only top-level shapes whose name starts with this" },
210
- { name: "limit", type: "number", default: 400, description: "Stop after this many lines" },
304
+ {
305
+ name: "page",
306
+ type: "string",
307
+ required: true,
308
+ description: "Page to read",
309
+ },
310
+ {
311
+ name: "depth",
312
+ type: "number",
313
+ default: 3,
314
+ description: "How many levels down",
315
+ },
316
+ {
317
+ name: "namePrefix",
318
+ type: "string",
319
+ description: "Only top-level shapes whose name starts with this",
320
+ },
321
+ {
322
+ name: "limit",
323
+ type: "number",
324
+ default: 400,
325
+ description: "Stop after this many lines",
326
+ },
211
327
  ], (p) => tree(p)), "built-in");
212
328
  registry.register(readBuiltIn("audit", "Blank text, colours and strokes not bound to a token, children sticking out of their parent, gaps off the allowed steps.", [
213
- { name: "page", type: "string", required: true },
214
- { name: "framePrefix", type: "string", required: true, description: "Audit top-level frames whose names start with this" },
215
- { name: "spacingSteps", type: "number[]", description: "Allowed gap and padding values; leave out to skip that check" },
216
- { name: "ignoreUnbound", type: "string[]", description: "Names (starts) not reported as unbound" },
329
+ {
330
+ name: "page",
331
+ type: "string",
332
+ required: true,
333
+ },
334
+ {
335
+ name: "framePrefix",
336
+ type: "string",
337
+ required: true,
338
+ description: "Audit top-level frames whose names start with this",
339
+ },
340
+ {
341
+ name: "spacingSteps",
342
+ type: "number[]",
343
+ description: "Allowed gap and padding values; leave out to skip that check",
344
+ },
345
+ {
346
+ name: "ignoreUnbound",
347
+ type: "string[]",
348
+ description: "Names (starts) not reported as unbound",
349
+ },
217
350
  ], (p) => audit(p)), "built-in");
218
351
  registry.register(readBuiltIn("tokens-dump", "Bounded typed token preview; tokens dump captures complete chunked evidence.", [], () => tokensDump()), "built-in");
219
352
  registry.register({
@@ -221,11 +354,35 @@ export function builtInRegistry() {
221
354
  name: "archive",
222
355
  description: "Nest the frames that are not part of the live work into one archive board. Frames keep names, ids and component links.",
223
356
  params: [
224
- { name: "page", type: "string", required: true },
225
- { name: "archiveName", type: "string", required: true, description: "Name of the archive board" },
226
- { name: "keepPrefixes", type: "string[]", default: [], description: "Boards whose names start with one of these stay live" },
227
- { name: "partPrefixes", type: "string[]", default: [], description: "Library parts, never archived" },
228
- { name: "x", type: "number", default: 9000, description: "Where the archive board is placed" },
357
+ {
358
+ name: "page",
359
+ type: "string",
360
+ required: true,
361
+ },
362
+ {
363
+ name: "archiveName",
364
+ type: "string",
365
+ required: true,
366
+ description: "Name of the archive board",
367
+ },
368
+ {
369
+ name: "keepPrefixes",
370
+ type: "string[]",
371
+ default: [],
372
+ description: "Boards whose names start with one of these stay live",
373
+ },
374
+ {
375
+ name: "partPrefixes",
376
+ type: "string[]",
377
+ default: [],
378
+ description: "Library parts, never archived",
379
+ },
380
+ {
381
+ name: "x",
382
+ type: "number",
383
+ default: 9000,
384
+ description: "Where the archive board is placed",
385
+ },
229
386
  ],
230
387
  source: "built-in",
231
388
  operation: archiveOperation,