@penpair/core 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1645 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-check-entry.d.ts +17 -0
  66. package/dist/skill-check-entry.js +89 -0
  67. package/dist/skill-check-instructions.d.ts +7 -0
  68. package/dist/skill-check-instructions.js +39 -0
  69. package/dist/skill-check.d.ts +15 -0
  70. package/dist/skill-check.js +36 -0
  71. package/dist/skill-file.d.ts +2 -0
  72. package/dist/skill-file.js +31 -0
  73. package/dist/skill-instructions.d.ts +4 -0
  74. package/dist/skill-instructions.js +97 -22
  75. package/dist/skill-metadata.d.ts +8 -0
  76. package/dist/skill-metadata.js +52 -0
  77. package/dist/skill-routing.d.ts +2 -0
  78. package/dist/skill-routing.js +36 -0
  79. package/dist/skills.js +131 -41
  80. package/dist/snippet.js +27 -7
  81. package/dist/snippets/read.js +8 -2
  82. package/dist/structure-snapshot.d.ts +12 -0
  83. package/dist/structure-snapshot.js +439 -85
  84. package/dist/token-edit.js +219 -64
  85. package/dist/token-projection.d.ts +56 -0
  86. package/dist/token-projection.js +286 -0
  87. package/dist/tokens-diff.js +125 -32
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +3 -2
  91. package/skills/design-judgment/SKILL.md +1 -1
  92. package/skills/design-judgment/agents/openai.yaml +6 -0
  93. package/skills/penpair-design/SKILL.md +14 -1
  94. package/skills/penpair-design/agents/openai.yaml +6 -0
  95. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  96. package/skills/penpair-design/references/project-facts.md +2 -0
  97. package/skills/penpair-handoff/SKILL.md +14 -1
  98. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  99. package/skills/penpair-handoff/references/comparison-companion.md +16 -0
  100. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  101. package/skills/penpair-recipes/SKILL.md +13 -1
  102. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  103. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  104. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  105. package/skills/penpair-setup/SKILL.md +9 -3
  106. package/skills/penpair-setup/agents/openai.yaml +6 -0
  107. package/skills/penpair-verify/SKILL.md +49 -0
  108. package/skills/penpair-verify/agents/openai.yaml +6 -0
  109. package/skills/penpair-verify/references/browser-evidence.md +33 -0
  110. package/dist/cli.d.ts +0 -2
  111. package/dist/cli.js +0 -3
  112. package/dist/docker/lifecycle.d.ts +0 -96
  113. package/dist/docker/lifecycle.js +0 -273
  114. package/dist/docker/secrets.d.ts +0 -11
  115. package/dist/docker/secrets.js +0 -38
  116. package/dist/docker/template.d.ts +0 -8
  117. package/dist/docker/template.js +0 -158
  118. package/dist/docker-profile.d.ts +0 -114
  119. package/dist/docker-profile.js +0 -18
  120. package/dist/instance/lifecycle.d.ts +0 -129
  121. package/dist/instance/lifecycle.js +0 -336
  122. package/dist/instance/secrets.d.ts +0 -11
  123. package/dist/instance/secrets.js +0 -38
  124. package/dist/instance/template.d.ts +0 -8
  125. package/dist/instance/template.js +0 -158
package/dist/run-file.js CHANGED
@@ -8,93 +8,192 @@ import { normalize } from "./registry.js";
8
8
  import { runOnPage, runOperation } from "./operation.js";
9
9
  const digest = (value) => createHash("sha256").update(value).digest("hex");
10
10
  async function sourceFile(root, entry) {
11
- if (!/\.m?js$/.test(entry))
11
+ if (!/\.m?js$/.test(entry)) {
12
12
  throw new Error("Plugin bodies must be .js or .mjs files; host imports and TypeScript transpilation are not performed");
13
+ }
13
14
  const target = await resolveProjectInput(root, entry);
14
15
  const handle = await open(target, "r");
15
16
  let body;
16
17
  try {
17
18
  const info = await handle.stat();
18
- if (!info.isFile() || info.size > maxSnippetBytes)
19
+ if (!info.isFile() || info.size > maxSnippetBytes) {
19
20
  throw new Error("Script file exceeds the 90 KB input bound or is not a regular file");
21
+ }
20
22
  body = (await handle.readFile("utf8")).replace(/^\uFEFF/, "");
21
23
  }
22
24
  finally {
23
25
  await handle.close();
24
26
  }
25
- if (body.includes("\0"))
27
+ if (body.includes("\0")) {
26
28
  throw new Error("Plugin script contains a NUL character");
29
+ }
27
30
  const header = body.trimStart().split(/\r?\n/)[0]?.trim();
28
31
  const declaration = header === "// penpair: read" ? "read" : header === "// penpair: write" ? "write" : "unknown";
29
32
  // The idempotency assertion must be in the initial comment block, before executable source.
30
33
  const comments = [];
31
34
  for (const line of body.trimStart().split(/\r?\n/)) {
32
- if (!line.trim().startsWith("//"))
35
+ if (!line.trim().startsWith("//")) {
33
36
  break;
37
+ }
34
38
  comments.push(line.trim());
35
39
  }
36
- return { file: path.relative(await realpath(root), target).split(path.sep).join("/"), sha256: digest(body), body, declaration, idempotent: comments.includes("// penpair: idempotent") };
40
+ return {
41
+ file: path
42
+ .relative(await realpath(root), target)
43
+ .split(path.sep)
44
+ .join("/"),
45
+ sha256: digest(body),
46
+ body,
47
+ declaration,
48
+ idempotent: comments.includes("// penpair: idempotent"),
49
+ };
37
50
  }
38
51
  function checked(value) {
39
52
  const error = compiles(value.code);
40
- if (error)
53
+ if (error) {
41
54
  throw new Error(`${value.name}: invalid plugin body: ${error}`);
55
+ }
42
56
  return value;
43
57
  }
44
58
  /** Reads and compiles explicitly selected files without importing project modules on the host. */
45
59
  export async function prepareFileScript(root, file, options = {}) {
46
60
  const includes = options.include ?? [];
47
- if (includes.length > 20 || new Set(includes).size !== includes.length)
61
+ if (includes.length > 20 || new Set(includes).size !== includes.length) {
48
62
  throw new Error("Use at most 20 distinct ordered includes");
63
+ }
49
64
  const params = options.params ?? {};
50
- if (Object.keys(params).length > 100 || Object.keys(params).some(key => !/^[A-Za-z][A-Za-z0-9]*$/.test(key)))
65
+ if (Object.keys(params).length > 100 ||
66
+ Object.keys(params).some((key) => !/^[A-Za-z][A-Za-z0-9]*$/.test(key))) {
51
67
  throw new Error("Use at most 100 named parameters (letters followed by letters/digits)");
52
- if (options.page && Object.hasOwn(params, "page"))
68
+ }
69
+ if (options.page && Object.hasOwn(params, "page")) {
53
70
  throw new Error("--page owns CFG.page; do not also pass --param page");
54
- if (Object.hasOwn(params, "inspection"))
71
+ }
72
+ if (Object.hasOwn(params, "inspection")) {
55
73
  throw new Error("CFG.inspection is reserved for the independently captured write inspection");
74
+ }
56
75
  const helpers = [];
57
76
  for (const include of includes) {
58
77
  const virtual = options.virtualIncludes?.[include];
59
- const helper = virtual === undefined ? await sourceFile(root, include) : { file: include, body: virtual, sha256: digest(virtual), declaration: virtual.startsWith("// penpair: read\n") ? "read" : "unknown", idempotent: false };
60
- if (helper.declaration !== "read")
78
+ const helper = virtual === undefined
79
+ ? await sourceFile(root, include)
80
+ : {
81
+ file: include,
82
+ body: virtual,
83
+ sha256: digest(virtual),
84
+ declaration: virtual.startsWith("// penpair: read\n") ? "read" : "unknown",
85
+ idempotent: false,
86
+ };
87
+ if (helper.declaration !== "read") {
61
88
  throw new Error(`Include ${include} must declare // penpair: read for its top-level inclusion effects`);
89
+ }
62
90
  helpers.push(helper);
63
91
  }
64
92
  const main = await sourceFile(root, file);
65
- const prelude = helpers.map(helper => `// Included helper\n${helper.body}\n`).join("\n");
66
- const cfg = { ...params, ...(options.page ? { page: options.page } : {}) };
67
- const build = (label, body, effect, inspected) => checked(snippet(label, `${requirePage}\n${prelude}\n${body}`, { ...cfg, ...(effect === "write" ? { inspection: inspected ?? null } : {}) }, effect));
93
+ const prelude = helpers.map((helper) => `// Included helper\n${helper.body}\n`).join("\n");
94
+ const cfg = {
95
+ ...params,
96
+ ...(options.page
97
+ ? {
98
+ page: options.page,
99
+ }
100
+ : {}),
101
+ };
102
+ const build = (label, body, effect, inspected) => checked(snippet(label, `${requirePage}\n${prelude}\n${body}`, {
103
+ ...cfg,
104
+ ...(effect === "write"
105
+ ? {
106
+ inspection: inspected ?? null,
107
+ }
108
+ : {}),
109
+ }, effect));
68
110
  if (main.declaration === "read") {
69
- if (options.inspect || options.readback)
111
+ if (options.inspect || options.readback) {
70
112
  throw new Error("Read scripts do not take write inspect/readback phases");
71
- return { kind: "read", source: [...helpers, main].map(({ file, sha256 }) => ({ file, sha256 })), params: cfg, page: options.page, read: build("run-file-read", main.body, "read") };
113
+ }
114
+ return {
115
+ kind: "read",
116
+ source: [
117
+ ...helpers,
118
+ main,
119
+ ].map(({ file, sha256 }) => ({
120
+ file,
121
+ sha256,
122
+ })),
123
+ params: cfg,
124
+ page: options.page,
125
+ read: build("run-file-read", main.body, "read"),
126
+ };
72
127
  }
73
- if (main.declaration !== "write" || !main.idempotent || !options.inspect || !options.readback)
128
+ if (main.declaration !== "write" || !main.idempotent || !options.inspect || !options.readback) {
74
129
  throw new Error("Write script execution requires // penpair: write, // penpair: idempotent, and explicit --inspect/--readback files. Declarations are trusted code contracts, not a sandbox.");
75
- const inspect = await sourceFile(root, options.inspect), readback = await sourceFile(root, options.readback);
76
- if (inspect.declaration !== "read" || readback.declaration !== "read")
130
+ }
131
+ const inspect = await sourceFile(root, options.inspect);
132
+ const readback = await sourceFile(root, options.readback);
133
+ if (inspect.declaration !== "read" || readback.declaration !== "read") {
77
134
  throw new Error("Inspection and readback files must declare // penpair: read");
135
+ }
78
136
  // Reuse the registry's positive evidence verifier; never equate an acknowledgment with a verified write.
79
- const normalized = normalize({ name: "run-file", description: "Explicitly selected reviewed file operation", kind: "write", inspect: inspect.body, apply: main.body, readback: readback.body }, "project");
80
- const operation = { ...normalized.operation, page: () => options.page,
137
+ const normalized = normalize({
138
+ name: "run-file",
139
+ description: "Explicitly selected reviewed file operation",
140
+ kind: "write",
141
+ inspect: inspect.body,
142
+ apply: main.body,
143
+ readback: readback.body,
144
+ }, "project");
145
+ const operation = {
146
+ ...normalized.operation,
147
+ page: () => options.page,
81
148
  inspect: () => build("run-file-inspect", inspect.body, "read"),
82
149
  apply: (_params, inspected) => build("run-file-apply", main.body, "write", inspected),
83
- readback: () => build("run-file-readback", readback.body, "read"), };
150
+ readback: () => build("run-file-readback", readback.body, "read"),
151
+ };
84
152
  operation.inspect({});
85
153
  operation.apply({});
86
154
  operation.readback({});
87
- return { kind: "write", source: [...helpers, main, inspect, readback].map(({ file, sha256 }) => ({ file, sha256 })), params: cfg, page: options.page, operation };
155
+ return {
156
+ kind: "write",
157
+ source: [
158
+ ...helpers,
159
+ main,
160
+ inspect,
161
+ readback,
162
+ ].map(({ file, sha256 }) => ({
163
+ file,
164
+ sha256,
165
+ })),
166
+ params: cfg,
167
+ page: options.page,
168
+ operation,
169
+ };
88
170
  }
89
171
  export async function runFileScript(client, prepared, options = {}) {
90
172
  if (prepared.kind === "read") {
91
- if (options.apply || options.journal)
173
+ if (options.apply || options.journal) {
92
174
  throw new Error("Read files do not accept apply or write recovery options");
93
- const result = await runOnPage(client, prepared.read, prepared.page, { ...options, effect: "read", retries: 0 });
94
- if (result.ok !== true)
175
+ }
176
+ const result = await runOnPage(client, prepared.read, prepared.page, {
177
+ ...options,
178
+ effect: "read",
179
+ retries: 0,
180
+ });
181
+ if (result.ok !== true) {
95
182
  throw new Error("Read script returned no explicit ok acknowledgment");
96
- return { phase: "read", kind: "declared-read", source: prepared.source, data: result };
183
+ }
184
+ return {
185
+ phase: "read",
186
+ kind: "declared-read",
187
+ source: prepared.source,
188
+ data: result,
189
+ };
97
190
  }
98
- const report = await runOperation(client, prepared.operation, { parameters: prepared.params, sourceFiles: prepared.source }, options);
99
- return { ...report, source: prepared.source };
191
+ const report = await runOperation(client, prepared.operation, {
192
+ parameters: prepared.params,
193
+ sourceFiles: prepared.source,
194
+ }, options);
195
+ return {
196
+ ...report,
197
+ source: prepared.source,
198
+ };
100
199
  }
package/dist/shots.js CHANGED
@@ -12,8 +12,9 @@ import { pngSize } from "./baselines.js";
12
12
  import { observationHash } from "./journal.js";
13
13
  /** Exact names across the selected page. A match never silently resolves an ambiguity. */
14
14
  export async function findShapesByName(client, name, page, options = {}) {
15
- if (!name || !page)
15
+ if (!name || !page) {
16
16
  throw new Error("Name lookup requires an exact name and explicit page");
17
+ }
17
18
  const result = await runOnPage(client, snippet("find-shape-name", `${requirePage}
18
19
  const matches = [], stack = [...penpot.currentPage.root.children]; let visited = 0;
19
20
  while (stack.length && visited < 10000) {
@@ -21,54 +22,127 @@ while (stack.length && visited < 10000) {
21
22
  if (shape.name === CFG.name) matches.push({id:shape.id,name:shape.name,type:shape.type});
22
23
  for (const child of shape.children || []) stack.push(child);
23
24
  }
24
- return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id,revision:penpot.currentFile.revn,matches,truncated:stack.length>0};`, { name, page }, "read"), page, options);
25
- if (result.truncated)
25
+ return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id,revision:penpot.currentFile.revn,matches,truncated:stack.length>0};`, {
26
+ name,
27
+ page,
28
+ }, "read"), page, options);
29
+ if (result.truncated) {
26
30
  throw new PenpairError("incomplete-output", "Shape name search exceeded 10000 nodes; narrow the page");
27
- if (result.ok !== true || typeof result.truncated !== "boolean" || typeof result.fileId !== "string" || !result.fileId || typeof result.pageId !== "string" || !result.pageId || !Number.isInteger(result.revision) || !Array.isArray(result.matches) || result.matches.some((match) => !match || typeof match.id !== "string" || typeof match.name !== "string" || typeof match.type !== "string"))
31
+ }
32
+ if (result.ok !== true ||
33
+ typeof result.truncated !== "boolean" ||
34
+ typeof result.fileId !== "string" ||
35
+ !result.fileId ||
36
+ typeof result.pageId !== "string" ||
37
+ !result.pageId ||
38
+ !Number.isInteger(result.revision) ||
39
+ !Array.isArray(result.matches) ||
40
+ result.matches.some((match) => !match ||
41
+ typeof match.id !== "string" ||
42
+ typeof match.name !== "string" ||
43
+ typeof match.type !== "string")) {
28
44
  throw new Error("Shape search returned incomplete identity evidence");
45
+ }
29
46
  return result;
30
47
  }
31
48
  export async function exportShapeByName(client, name, page, options = {}) {
32
49
  const lookup = await findShapesByName(client, name, page, options);
33
- if (lookup.matches.length !== 1)
50
+ if (lookup.matches.length !== 1) {
34
51
  throw new Error(`Exact name ${JSON.stringify(name)} matched ${lookup.matches.length} shapes on ${JSON.stringify(page)}. Candidates: ${JSON.stringify(lookup.matches.slice(0, 20))}. Use an ID for an ambiguous name.`);
35
- const result = await exportShapePng(client, lookup.matches[0].id, { ...options, page });
36
- if (result.capture.fileId !== lookup.fileId || result.capture.pageId !== lookup.pageId || result.capture.revision !== lookup.revision)
52
+ }
53
+ const result = await exportShapePng(client, lookup.matches[0].id, {
54
+ ...options,
55
+ page,
56
+ });
57
+ if (result.capture.fileId !== lookup.fileId ||
58
+ result.capture.pageId !== lookup.pageId ||
59
+ result.capture.revision !== lookup.revision) {
37
60
  throw new Error("Named export source changed after lookup; no output was published");
61
+ }
38
62
  return result;
39
63
  }
40
64
  /** Ad-hoc captures use immutable generations, but never carry baseline approval records. */
41
65
  export async function captureShots(client, root, params, options = {}) {
42
- const listing = await runOnPage(client, frames({ page: params.page }), params.page, options);
43
- if (typeof listing.fileId !== "string" || typeof listing.pageId !== "string" || !Number.isInteger(listing.revn) || !Array.isArray(listing.frames))
66
+ const listing = await runOnPage(client, frames({
67
+ page: params.page,
68
+ }), params.page, options);
69
+ if (typeof listing.fileId !== "string" ||
70
+ typeof listing.pageId !== "string" ||
71
+ !Number.isInteger(listing.revn) ||
72
+ !Array.isArray(listing.frames)) {
44
73
  throw new Error("Shots listing lacks identity/revision evidence");
74
+ }
45
75
  const selected = listing.frames.filter((frame) => !params.prefix || frame.name.startsWith(params.prefix));
46
- const report = { schemaVersion: 1, complete: false, fileId: listing.fileId, pageId: listing.pageId, revision: Number(listing.revn), captures: [], wrote: [] };
47
- if (!selected.length || selected.length > 100)
48
- return { ...report, reason: !selected.length ? "No matching top-level frames" : "More than 100 frames; narrow the prefix" };
76
+ const report = {
77
+ schemaVersion: 1,
78
+ complete: false,
79
+ fileId: listing.fileId,
80
+ pageId: listing.pageId,
81
+ revision: Number(listing.revn),
82
+ captures: [],
83
+ wrote: [],
84
+ };
85
+ if (!selected.length || selected.length > 100) {
86
+ return {
87
+ ...report,
88
+ reason: !selected.length
89
+ ? "No matching top-level frames"
90
+ : "More than 100 frames; narrow the prefix",
91
+ };
92
+ }
49
93
  const folder = await resolveProjectOutput(root, params.out ?? "design/shots");
50
- const runId = randomUUID(), generation = `captures/${runId}`;
94
+ const runId = randomUUID();
95
+ const generation = `captures/${runId}`;
51
96
  const images = [];
52
97
  for (const [index, frame] of selected.entries()) {
53
98
  options.signal?.throwIfAborted();
54
99
  options.onStep?.(`exporting ${JSON.stringify(frame.name)}`);
55
- const result = await exportShapePng(client, frame.id, { ...options, page: params.page, stable: true });
56
- if (!result.stable)
57
- return { ...report, reason: "A frame did not stabilize; no output was published" };
58
- if (result.capture.fileId !== report.fileId || result.capture.pageId !== report.pageId || result.capture.revision !== report.revision)
100
+ const result = await exportShapePng(client, frame.id, {
101
+ ...options,
102
+ page: params.page,
103
+ stable: true,
104
+ });
105
+ if (!result.stable) {
106
+ return {
107
+ ...report,
108
+ reason: "A frame did not stabilize; no output was published",
109
+ };
110
+ }
111
+ if (result.capture.fileId !== report.fileId ||
112
+ result.capture.pageId !== report.pageId ||
113
+ result.capture.revision !== report.revision) {
59
114
  throw new Error("Shots source changed during capture; no output was published");
115
+ }
60
116
  const size = pngSize(result.png);
61
- if (!size || size.width <= 0 || size.height <= 0)
117
+ if (!size || size.width <= 0 || size.height <= 0) {
62
118
  throw new Error("Shot export is missing valid PNG dimensions");
63
- report.captures.push({ layout: result.layout, ...size, name: frame.name, file: `${generation}/shot-${String(index + 1).padStart(4, "0")}.png`, sha256: createHash("sha256").update(result.png).digest("hex"), capture: result.capture });
119
+ }
120
+ report.captures.push({
121
+ layout: result.layout,
122
+ ...size,
123
+ name: frame.name,
124
+ file: `${generation}/shot-${String(index + 1).padStart(4, "0")}.png`,
125
+ sha256: createHash("sha256").update(result.png).digest("hex"),
126
+ capture: result.capture,
127
+ });
64
128
  images.push(result.png);
65
129
  }
66
- const after = await runOnPage(client, frames({ page: params.page }), params.page, options);
67
- const identity = (entry) => ({ fileId: entry.fileId, pageId: entry.pageId, revision: entry.revn, frames: entry.frames });
68
- if (observationHash(identity(listing)) !== observationHash(identity(after)))
130
+ const after = await runOnPage(client, frames({
131
+ page: params.page,
132
+ }), params.page, options);
133
+ const identity = (entry) => ({
134
+ fileId: entry.fileId,
135
+ pageId: entry.pageId,
136
+ revision: entry.revn,
137
+ frames: entry.frames,
138
+ });
139
+ if (observationHash(identity(listing)) !== observationHash(identity(after))) {
69
140
  throw new Error("Shots source changed before publication; previous output was preserved");
141
+ }
70
142
  options.signal?.throwIfAborted();
71
- await mkdir(path.join(folder, "captures"), { recursive: true });
143
+ await mkdir(path.join(folder, "captures"), {
144
+ recursive: true,
145
+ });
72
146
  const staged = path.join(folder, `.stage-${runId}`);
73
147
  await mkdir(staged);
74
148
  try {
@@ -80,11 +154,19 @@ export async function captureShots(client, root, params, options = {}) {
80
154
  await rename(staged, path.join(folder, generation));
81
155
  options.signal?.throwIfAborted();
82
156
  report.complete = true;
83
- report.wrote = [...report.captures.map((entry) => entry.file), "penpair-shots.json"];
84
- await atomicWriteFile(path.join(folder, "penpair-shots.json"), `${JSON.stringify(report, null, 2)}\n`, { replace: true });
157
+ report.wrote = [
158
+ ...report.captures.map((entry) => entry.file),
159
+ "penpair-shots.json",
160
+ ];
161
+ await atomicWriteFile(path.join(folder, "penpair-shots.json"), `${JSON.stringify(report, null, 2)}\n`, {
162
+ replace: true,
163
+ });
85
164
  return report;
86
165
  }
87
166
  finally {
88
- await rm(staged, { recursive: true, force: true });
167
+ await rm(staged, {
168
+ recursive: true,
169
+ force: true,
170
+ });
89
171
  }
90
172
  }
@@ -0,0 +1,17 @@
1
+ import { type SkillInstructionCheck } from "./skill-check-instructions.js";
2
+ export interface SkillDiagnostic {
3
+ name: string;
4
+ path: string;
5
+ implicitInvocation: boolean | null;
6
+ entrypoint: "current" | "modified" | "unavailable";
7
+ problems: string[];
8
+ warnings: string[];
9
+ }
10
+ interface EntryOptions {
11
+ root: string;
12
+ name: string;
13
+ bundledDirectory: string;
14
+ instructions: SkillInstructionCheck;
15
+ }
16
+ export declare function checkSkillEntry(options: EntryOptions): Promise<SkillDiagnostic>;
17
+ export {};
@@ -0,0 +1,89 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseSkillMetadata, parseSkillYaml } from "./skill-metadata.js";
4
+ import { readSkillFile } from "./skill-file.js";
5
+ import { resolveSkillLink } from "./skill-check-instructions.js";
6
+ async function invocationAllowed(root, skillPath) {
7
+ const relative = path.posix.join(path.posix.dirname(skillPath), "agents/openai.yaml");
8
+ const present = await lstat(path.join(root, relative)).catch((error) => {
9
+ if (error.code !== "ENOENT") {
10
+ throw error;
11
+ }
12
+ return undefined;
13
+ });
14
+ if (!present) {
15
+ return true;
16
+ }
17
+ const metadata = parseSkillYaml(await readSkillFile(root, relative));
18
+ if (metadata.policy === undefined) {
19
+ return true;
20
+ }
21
+ if (!metadata.policy || typeof metadata.policy !== "object" || Array.isArray(metadata.policy)) {
22
+ throw new Error("Invalid invocation policy");
23
+ }
24
+ const allowed = metadata.policy.allow_implicit_invocation;
25
+ if (allowed !== undefined && typeof allowed !== "boolean") {
26
+ throw new Error("Invocation policy must use a boolean");
27
+ }
28
+ return allowed !== false;
29
+ }
30
+ export async function checkSkillEntry(options) {
31
+ const { root, name, bundledDirectory, instructions } = options;
32
+ const skillPath = path.posix.join(".agents/skills", name, "SKILL.md");
33
+ const report = {
34
+ name,
35
+ path: skillPath,
36
+ implicitInvocation: null,
37
+ entrypoint: "unavailable",
38
+ problems: [],
39
+ warnings: [],
40
+ };
41
+ try {
42
+ const source = await readSkillFile(root, skillPath);
43
+ parseSkillMetadata(source, name);
44
+ const bundled = await readFile(path.join(bundledDirectory, name, "SKILL.md"), "utf8");
45
+ report.entrypoint = source === bundled ? "current" : "modified";
46
+ if (report.entrypoint === "modified") {
47
+ report.warnings.push("The discoverable entrypoint differs from the bundled skill; review intentional customization before refresh.");
48
+ }
49
+ const localLinks = [
50
+ ...source.matchAll(/\[[^\]]+\]\(([^)]+)\)/g),
51
+ ]
52
+ .map((match) => match[1])
53
+ .filter((target) => target && !target.startsWith("#") && !/^[a-z][a-z0-9+.-]*:/i.test(target));
54
+ if (localLinks.length > 100) {
55
+ throw new Error("Too many local resource links");
56
+ }
57
+ for (const target of localLinks) {
58
+ await resolveSkillLink(root, skillPath, target);
59
+ }
60
+ report.implicitInvocation = await invocationAllowed(root, skillPath);
61
+ if (!report.implicitInvocation) {
62
+ report.problems.push("Implicit invocation is disabled in openai.yaml; explicit invocation remains available.");
63
+ }
64
+ for (const folder of [
65
+ ".skills",
66
+ ".claude/skills",
67
+ ]) {
68
+ const otherPath = path.posix.join(folder, name, "SKILL.md");
69
+ const other = await readSkillFile(root, otherPath).catch(() => undefined);
70
+ if (other !== undefined && other !== source) {
71
+ report.problems.push("Codex and another installed skill copy differ; review the conflicting copies before refreshing.");
72
+ }
73
+ }
74
+ const routes = instructions.links[name] ?? [];
75
+ if (!routes.length) {
76
+ report.problems.push("No generated task route links this skill from the selected instruction file.");
77
+ }
78
+ const resolvedSkill = await realpath(path.join(root, skillPath));
79
+ for (const target of routes) {
80
+ if ((await resolveSkillLink(root, instructions.path, target)) !== resolvedSkill) {
81
+ report.problems.push("An instruction route points to a different skill copy.");
82
+ }
83
+ }
84
+ }
85
+ catch {
86
+ report.problems.push("Skill discovery, frontmatter, local resources or UI policy is missing, invalid or resolves outside the project; inspect the local files and preview a skill refresh.");
87
+ }
88
+ return report;
89
+ }
@@ -0,0 +1,7 @@
1
+ export interface SkillInstructionCheck {
2
+ path: string;
3
+ problems: string[];
4
+ links: Record<string, string[]>;
5
+ }
6
+ export declare function resolveSkillLink(root: string, from: string, target: string): Promise<string>;
7
+ export declare function checkSkillInstructions(root: string, instructionFile: string): Promise<SkillInstructionCheck>;
@@ -0,0 +1,39 @@
1
+ import path from "node:path";
2
+ import { readSkillFile } from "./skill-file.js";
3
+ import { skillInstructionMarkers } from "./skill-instructions.js";
4
+ import { resolveProjectInput } from "./project-input.js";
5
+ export async function resolveSkillLink(root, from, target) {
6
+ const decoded = decodeURIComponent(target.split("#")[0]);
7
+ if (!decoded || path.isAbsolute(decoded) || /^[a-z][a-z0-9+.-]*:/i.test(decoded)) {
8
+ throw new Error("Skill resource link must resolve inside the project");
9
+ }
10
+ const relative = path.relative(root, path.resolve(root, path.dirname(from), decoded));
11
+ return resolveProjectInput(root, relative);
12
+ }
13
+ export async function checkSkillInstructions(root, instructionFile) {
14
+ const report = {
15
+ path: instructionFile,
16
+ problems: [],
17
+ links: {},
18
+ };
19
+ try {
20
+ const resolved = await resolveProjectInput(root, instructionFile);
21
+ report.path = path.relative(root, resolved).split(path.sep).join("/");
22
+ const source = await readSkillFile(root, report.path);
23
+ const { start, end } = skillInstructionMarkers;
24
+ if (source.split(start).length !== 2 ||
25
+ source.split(end).length !== 2 ||
26
+ source.indexOf(start) > source.indexOf(end)) {
27
+ throw new Error("Missing or malformed installer-owned skill routing block");
28
+ }
29
+ const block = source.slice(source.indexOf(start) + start.length, source.indexOf(end));
30
+ const routing = block.split("| Skill | Purpose |")[0];
31
+ for (const match of routing.matchAll(/\[([a-z0-9-]+)\]\(([^)]+)\)/g)) {
32
+ (report.links[match[1]] ??= []).push(match[2]);
33
+ }
34
+ }
35
+ catch {
36
+ report.problems.push("Project instructions are missing, unreadable or lack a valid owned routing block; preview skills install --agents with this destination.");
37
+ }
38
+ return report;
39
+ }
@@ -0,0 +1,15 @@
1
+ import { type SkillDiagnostic } from "./skill-check-entry.js";
2
+ import { type SkillInstructionCheck } from "./skill-check-instructions.js";
3
+ export interface SkillCheckOptions {
4
+ root: string;
5
+ agents?: string;
6
+ only?: string[];
7
+ }
8
+ export interface SkillCheckReport {
9
+ agent: "codex";
10
+ ready: boolean;
11
+ instructions: Pick<SkillInstructionCheck, "path" | "problems">;
12
+ skills: SkillDiagnostic[];
13
+ limitations: string[];
14
+ }
15
+ export declare function checkSkills(options: SkillCheckOptions): Promise<SkillCheckReport>;
@@ -0,0 +1,36 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { bundledSkillsDirectory, listBundledSkills } from "./skills.js";
3
+ import { checkSkillEntry } from "./skill-check-entry.js";
4
+ import { checkSkillInstructions } from "./skill-check-instructions.js";
5
+ export async function checkSkills(options) {
6
+ const root = await realpath(options.root);
7
+ const bundledDirectory = bundledSkillsDirectory();
8
+ const names = await listBundledSkills(bundledDirectory);
9
+ const selected = options.only ?? names;
10
+ if (!selected.length || selected.some((name) => !names.includes(name))) {
11
+ throw new Error("Select one or more bundled skill names; use penpair skills to list them");
12
+ }
13
+ const instructions = await checkSkillInstructions(root, options.agents ?? "AGENTS.md");
14
+ const skills = await Promise.all([
15
+ ...new Set(selected),
16
+ ].map((name) => checkSkillEntry({
17
+ root,
18
+ name,
19
+ bundledDirectory,
20
+ instructions,
21
+ })));
22
+ return {
23
+ agent: "codex",
24
+ ready: !instructions.problems.length && skills.every((skill) => !skill.problems.length),
25
+ instructions: {
26
+ path: instructions.path,
27
+ problems: instructions.problems,
28
+ },
29
+ skills,
30
+ limitations: [
31
+ "Offline project-file checks only; no connection, adapter/provider execution, credential reads or writes.",
32
+ "Does not inspect global Codex settings, session skill catalogs, context budgets or UI selection badges.",
33
+ "A valid install and allowed invocation policy do not prove model selection, correct workflow application, visual quality or owner approval.",
34
+ ],
35
+ };
36
+ }
@@ -0,0 +1,2 @@
1
+ /** Read only bounded regular files inside the explicitly selected project. */
2
+ export declare function readSkillFile(root: string, relative: string): Promise<string>;
@@ -0,0 +1,31 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { resolveProjectInput } from "./project-input.js";
4
+ import { maxSkillBytes } from "./skill-metadata.js";
5
+ /** Read only bounded regular files inside the explicitly selected project. */
6
+ export async function readSkillFile(root, relative) {
7
+ const resolved = await resolveProjectInput(root, relative);
8
+ const handle = await open(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
9
+ try {
10
+ const info = await handle.stat();
11
+ if (!info.isFile() || info.size > maxSkillBytes) {
12
+ throw new Error("Skill input must be a regular file within the 128 KB limit");
13
+ }
14
+ const buffer = Buffer.alloc(maxSkillBytes + 1);
15
+ let length = 0;
16
+ while (length < buffer.length) {
17
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
18
+ if (!bytesRead) {
19
+ break;
20
+ }
21
+ length += bytesRead;
22
+ }
23
+ if (length > maxSkillBytes) {
24
+ throw new Error("Skill input exceeds the 128 KB limit");
25
+ }
26
+ return buffer.subarray(0, length).toString("utf8");
27
+ }
28
+ finally {
29
+ await handle.close();
30
+ }
31
+ }
@@ -1,3 +1,7 @@
1
+ export declare const skillInstructionMarkers: {
2
+ start: string;
3
+ end: string;
4
+ };
1
5
  export interface InstructionReport {
2
6
  path: string | null;
3
7
  status: "not-selected" | "choice-required" | "unchanged" | "preview" | "written";