@agent-native/core 0.45.0 → 0.45.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 (93) hide show
  1. package/dist/action.d.ts +8 -1
  2. package/dist/action.d.ts.map +1 -1
  3. package/dist/action.js +20 -10
  4. package/dist/action.js.map +1 -1
  5. package/dist/cli/app-skill.d.ts +3 -1
  6. package/dist/cli/app-skill.d.ts.map +1 -1
  7. package/dist/cli/app-skill.js +50 -8
  8. package/dist/cli/app-skill.js.map +1 -1
  9. package/dist/cli/connect.d.ts.map +1 -1
  10. package/dist/cli/connect.js +39 -5
  11. package/dist/cli/connect.js.map +1 -1
  12. package/dist/cli/create.d.ts.map +1 -1
  13. package/dist/cli/create.js +9 -7
  14. package/dist/cli/create.js.map +1 -1
  15. package/dist/cli/index.js +42 -10
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/cli/mcp-config-writers.d.ts +10 -0
  18. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  19. package/dist/cli/mcp-config-writers.js +60 -6
  20. package/dist/cli/mcp-config-writers.js.map +1 -1
  21. package/dist/cli/mcp.d.ts.map +1 -1
  22. package/dist/cli/mcp.js +4 -6
  23. package/dist/cli/mcp.js.map +1 -1
  24. package/dist/cli/plan-local.d.ts.map +1 -1
  25. package/dist/cli/plan-local.js +15 -2
  26. package/dist/cli/plan-local.js.map +1 -1
  27. package/dist/cli/plan-publish-store.d.ts +17 -7
  28. package/dist/cli/plan-publish-store.d.ts.map +1 -1
  29. package/dist/cli/plan-publish-store.js +33 -8
  30. package/dist/cli/plan-publish-store.js.map +1 -1
  31. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  32. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  33. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  34. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  35. package/dist/cli/recap.d.ts +63 -5
  36. package/dist/cli/recap.d.ts.map +1 -1
  37. package/dist/cli/recap.js +641 -48
  38. package/dist/cli/recap.js.map +1 -1
  39. package/dist/cli/skills.d.ts +26 -11
  40. package/dist/cli/skills.d.ts.map +1 -1
  41. package/dist/cli/skills.js +644 -972
  42. package/dist/cli/skills.js.map +1 -1
  43. package/dist/cli/templates-meta.d.ts.map +1 -1
  44. package/dist/cli/templates-meta.js +3 -2
  45. package/dist/cli/templates-meta.js.map +1 -1
  46. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  47. package/dist/client/blocks/library/AnnotatedCodeBlock.js +37 -9
  48. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  49. package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
  50. package/dist/client/blocks/library/DiffBlock.js +44 -12
  51. package/dist/client/blocks/library/DiffBlock.js.map +1 -1
  52. package/dist/client/blocks/library/annotation-rail.d.ts +12 -3
  53. package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
  54. package/dist/client/blocks/library/annotation-rail.js +29 -3
  55. package/dist/client/blocks/library/annotation-rail.js.map +1 -1
  56. package/dist/client/blocks/library/html.d.ts.map +1 -1
  57. package/dist/client/blocks/library/html.js +3 -1
  58. package/dist/client/blocks/library/html.js.map +1 -1
  59. package/dist/client/blocks/library/question-form.d.ts.map +1 -1
  60. package/dist/client/blocks/library/question-form.js +4 -1
  61. package/dist/client/blocks/library/question-form.js.map +1 -1
  62. package/dist/db/migrations.d.ts.map +1 -1
  63. package/dist/db/migrations.js +2 -1
  64. package/dist/db/migrations.js.map +1 -1
  65. package/dist/extensions/routes.d.ts +18 -0
  66. package/dist/extensions/routes.d.ts.map +1 -1
  67. package/dist/extensions/routes.js +30 -8
  68. package/dist/extensions/routes.js.map +1 -1
  69. package/dist/oauth-tokens/store.d.ts.map +1 -1
  70. package/dist/oauth-tokens/store.js +42 -5
  71. package/dist/oauth-tokens/store.js.map +1 -1
  72. package/dist/scripts/db/index.d.ts.map +1 -1
  73. package/dist/scripts/db/index.js +1 -0
  74. package/dist/scripts/db/index.js.map +1 -1
  75. package/dist/scripts/db/migrate-encrypt-oauth-tokens.d.ts +28 -0
  76. package/dist/scripts/db/migrate-encrypt-oauth-tokens.d.ts.map +1 -0
  77. package/dist/scripts/db/migrate-encrypt-oauth-tokens.js +164 -0
  78. package/dist/scripts/db/migrate-encrypt-oauth-tokens.js.map +1 -0
  79. package/dist/scripts/db/scoping.d.ts.map +1 -1
  80. package/dist/scripts/db/scoping.js +7 -5
  81. package/dist/scripts/db/scoping.js.map +1 -1
  82. package/dist/secrets/index.d.ts +1 -0
  83. package/dist/secrets/index.d.ts.map +1 -1
  84. package/dist/secrets/index.js +4 -0
  85. package/dist/secrets/index.js.map +1 -1
  86. package/dist/sharing/actions/set-resource-visibility.d.ts.map +1 -1
  87. package/dist/sharing/actions/set-resource-visibility.js +4 -1
  88. package/dist/sharing/actions/set-resource-visibility.js.map +1 -1
  89. package/docs/content/plan-plugin.md +21 -6
  90. package/docs/content/pr-visual-recap.md +52 -3
  91. package/docs/content/skills-guide.md +13 -0
  92. package/docs/content/template-plan.md +18 -7
  93. package/package.json +5 -1
package/dist/cli/recap.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `agent-native recap <scan|build-prompt|shot|comment>` — the helper surface
3
- * used by the PR Visual Recap GitHub Action.
2
+ * `agent-native recap` — the helper surface used by the PR Visual Recap GitHub
3
+ * Action. Run `agent-native recap help` for the full subcommand list.
4
4
  *
5
5
  * The action no longer generates the recap deterministically. Instead a coding
6
6
  * agent (Claude Code or Codex) RUNS THE REPO'S visual-recap skill against the
@@ -17,10 +17,15 @@
17
17
  * mcp-config Write the plan MCP client config for the chosen backend
18
18
  * (Claude Code JSON or Codex config.toml).
19
19
  * scan Refuse to hand a secret-leaking diff to the agent.
20
- * build-prompt Assemble the agent prompt = repo SKILL.md + a task wrapper.
20
+ * build-prompt Assemble the agent prompt = latest visual-recap skill bundle
21
+ * + a task wrapper (or repo-pinned skill with --skill-source).
21
22
  * shot Screenshot the published plan and upload it to the plan app's
22
23
  * signed public image route (for an inline PR-comment image).
24
+ * usage Parse and emit agent token-usage/cost from stdout.
23
25
  * comment Find the previous plan id / upsert the sticky PR comment.
26
+ * check Evaluate the recap result and set a GitHub commit status.
27
+ * setup Install the PR Visual Recap GitHub Action workflow.
28
+ * doctor Diagnose missing secrets / misconfigured workflow.
24
29
  *
25
30
  * Promoting these to the published CLI means an installed repo's workflow calls
26
31
  * `agent-native recap …` instead of copying helper scripts into the repo.
@@ -31,7 +36,9 @@ import { execFileSync } from "node:child_process";
31
36
  import fs from "node:fs";
32
37
  import os from "node:os";
33
38
  import path from "node:path";
39
+ import { readPlanPublishAuth } from "./plan-publish-store.js";
34
40
  import { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js";
41
+ import { BUILT_IN_APP_SKILLS, VISUAL_RECAP_SKILL_MD } from "./skills.js";
35
42
  /* -------------------------------------------------------------------------- */
36
43
  /* Arg parsing */
37
44
  /* -------------------------------------------------------------------------- */
@@ -74,6 +81,7 @@ export const PR_VISUAL_RECAP_SETUP = [
74
81
  "Optional (only if you change defaults):",
75
82
  " OPENAI_API_KEY (secret) + VISUAL_RECAP_AGENT=codex (variable) — use Codex instead of Claude",
76
83
  " VISUAL_RECAP_MODEL / VISUAL_RECAP_REASONING (variables) — pin the model (e.g. gpt-5.5) and reasoning depth (none|minimal|low|medium|high|xhigh; Codex only)",
84
+ " VISUAL_RECAP_SKILL_SOURCE=repo (variable) — pin CI to the repo-local visual-recap skill instead of latest bundled guidance",
77
85
  " PLAN_RECAP_APP_URL (secret) — only when self-hosting the plan app (defaults to https://plan.agent-native.com)",
78
86
  ];
79
87
  /** Write .github/workflows/pr-visual-recap.yml into a repo. */
@@ -85,6 +93,335 @@ export function writePrVisualRecapWorkflow(baseDir) {
85
93
  fs.writeFileSync(file, PR_VISUAL_RECAP_WORKFLOW_YML);
86
94
  return { path: path.relative(baseDir, file), existed };
87
95
  }
96
+ const DEFAULT_RECAP_APP_URL = "https://plan.agent-native.com";
97
+ export function normalizeRecapAgent(value) {
98
+ const agent = (value || "claude").toLowerCase();
99
+ if (agent === "codex")
100
+ return "codex";
101
+ if (agent === "claude")
102
+ return "claude";
103
+ throw new Error(`Unsupported recap agent "${value}" (expected "claude" or "codex").`);
104
+ }
105
+ export function recapRequiredSecrets(agent) {
106
+ return [
107
+ "PLAN_RECAP_TOKEN",
108
+ agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY",
109
+ ];
110
+ }
111
+ function recapWorkflowFile(baseDir) {
112
+ return path.join(baseDir, ".github", "workflows", "pr-visual-recap.yml");
113
+ }
114
+ function stripTrailingSlash(url) {
115
+ return url.replace(/\/+$/, "");
116
+ }
117
+ function sameRecapOrigin(a, b) {
118
+ try {
119
+ return new URL(a).origin === new URL(b).origin;
120
+ }
121
+ catch {
122
+ return stripTrailingSlash(a) === stripTrailingSlash(b);
123
+ }
124
+ }
125
+ function planTokenFromLocalStore(appUrl) {
126
+ const auth = readPlanPublishAuth();
127
+ if (!auth)
128
+ return undefined;
129
+ return sameRecapOrigin(auth.url, appUrl) ? auth.token : undefined;
130
+ }
131
+ function envValue(env, key) {
132
+ const value = env[key]?.trim();
133
+ return value || undefined;
134
+ }
135
+ function commandForMissingSecret(name, repo) {
136
+ return `gh secret set ${name}${repo ? ` --repo ${repo}` : ""}`;
137
+ }
138
+ function commandForMissingVariable(name, value, repo) {
139
+ return `gh variable set ${name} --body ${JSON.stringify(value)}${repo ? ` --repo ${repo}` : ""}`;
140
+ }
141
+ function gh(args, input) {
142
+ try {
143
+ const stdout = execFileSync("gh", args, {
144
+ encoding: "utf8",
145
+ input,
146
+ stdio: input === undefined
147
+ ? ["ignore", "pipe", "pipe"]
148
+ : ["pipe", "pipe", "pipe"],
149
+ });
150
+ return { ok: true, stdout };
151
+ }
152
+ catch {
153
+ return { ok: false, stdout: "" };
154
+ }
155
+ }
156
+ function resolveGithubRepo(explicit) {
157
+ if (explicit)
158
+ return explicit;
159
+ const result = gh([
160
+ "repo",
161
+ "view",
162
+ "--json",
163
+ "nameWithOwner",
164
+ "--jq",
165
+ ".nameWithOwner",
166
+ ]);
167
+ const repo = result.stdout.trim();
168
+ return result.ok && repo ? repo : undefined;
169
+ }
170
+ function listGithubNames(kind, repo) {
171
+ const args = kind === "secret"
172
+ ? ["secret", "list", "--json", "name"]
173
+ : ["variable", "list", "--json", "name,value"];
174
+ if (repo)
175
+ args.push("--repo", repo);
176
+ const result = gh(args);
177
+ if (!result.ok)
178
+ return null;
179
+ try {
180
+ const parsed = JSON.parse(result.stdout);
181
+ if (!Array.isArray(parsed))
182
+ return null;
183
+ return new Set(parsed
184
+ .map((entry) => entry && typeof entry === "object"
185
+ ? entry.name
186
+ : undefined)
187
+ .filter((name) => typeof name === "string"));
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ function listGithubVariables(repo) {
194
+ const args = ["variable", "list", "--json", "name,value"];
195
+ if (repo)
196
+ args.push("--repo", repo);
197
+ const result = gh(args);
198
+ if (!result.ok)
199
+ return null;
200
+ try {
201
+ const parsed = JSON.parse(result.stdout);
202
+ if (!Array.isArray(parsed))
203
+ return null;
204
+ const out = new Map();
205
+ for (const entry of parsed) {
206
+ if (!entry || typeof entry !== "object")
207
+ continue;
208
+ const record = entry;
209
+ if (typeof record.name !== "string")
210
+ continue;
211
+ out.set(record.name, typeof record.value === "string" ? record.value : "");
212
+ }
213
+ return out;
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ }
219
+ function setGithubSecret(name, value, repo, dryRun) {
220
+ if (!value)
221
+ return "missing";
222
+ if (dryRun)
223
+ return "dry-run";
224
+ const args = ["secret", "set", name];
225
+ if (repo)
226
+ args.push("--repo", repo);
227
+ return gh(args, `${value}\n`).ok ? "set" : "failed";
228
+ }
229
+ function setGithubVariable(name, value, repo, dryRun) {
230
+ if (!value)
231
+ return "skipped";
232
+ if (dryRun)
233
+ return "dry-run";
234
+ const args = ["variable", "set", name, "--body", value];
235
+ if (repo)
236
+ args.push("--repo", repo);
237
+ return gh(args).ok ? "set" : "failed";
238
+ }
239
+ export function buildRecapSetupPlan(input) {
240
+ const env = input.env ?? process.env;
241
+ const appUrl = stripTrailingSlash(input.appUrl || env.PLAN_RECAP_APP_URL || DEFAULT_RECAP_APP_URL);
242
+ const agent = normalizeRecapAgent(input.agent || env.VISUAL_RECAP_AGENT);
243
+ const requiredSecrets = recapRequiredSecrets(agent);
244
+ const planToken = envValue(env, "PLAN_RECAP_TOKEN") ?? planTokenFromLocalStore(appUrl);
245
+ const llmSecretName = agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
246
+ const variableValues = {};
247
+ if (agent !== "claude")
248
+ variableValues.VISUAL_RECAP_AGENT = agent;
249
+ for (const key of [
250
+ "VISUAL_RECAP_MODEL",
251
+ "VISUAL_RECAP_REASONING",
252
+ "VISUAL_RECAP_SKILL_SOURCE",
253
+ ]) {
254
+ const value = envValue(env, key);
255
+ if (value)
256
+ variableValues[key] = value;
257
+ }
258
+ return {
259
+ agent,
260
+ appUrl,
261
+ repo: input.repo,
262
+ workflowPath: path.relative(input.baseDir, recapWorkflowFile(input.baseDir)),
263
+ workflowExists: fs.existsSync(recapWorkflowFile(input.baseDir)),
264
+ requiredSecrets,
265
+ variableValues,
266
+ secretValues: {
267
+ PLAN_RECAP_TOKEN: planToken,
268
+ [llmSecretName]: envValue(env, llmSecretName),
269
+ PLAN_RECAP_APP_URL: appUrl === DEFAULT_RECAP_APP_URL ? undefined : appUrl,
270
+ },
271
+ };
272
+ }
273
+ function flagArg(args, key) {
274
+ return args[key] === true || args[key] === "true";
275
+ }
276
+ function runSetup(args) {
277
+ const baseDir = process.cwd();
278
+ const dryRun = flagArg(args, "dry-run");
279
+ const skipSecrets = flagArg(args, "skip-secrets");
280
+ const repo = resolveGithubRepo(optionalArg(args, "repo"));
281
+ const plan = buildRecapSetupPlan({
282
+ baseDir,
283
+ appUrl: optionalArg(args, "app-url"),
284
+ agent: optionalArg(args, "agent"),
285
+ repo,
286
+ });
287
+ const lines = ["PR Visual Recap setup", ""];
288
+ if (dryRun) {
289
+ lines.push(`Workflow: would write ${plan.workflowPath}.`);
290
+ }
291
+ else {
292
+ const written = writePrVisualRecapWorkflow(baseDir);
293
+ lines.push(`Workflow: ${written.existed ? "refreshed" : "wrote"} ${written.path}.`);
294
+ }
295
+ lines.push(`Plan app: ${plan.appUrl}.`);
296
+ lines.push(`Backend: ${plan.agent}.`);
297
+ lines.push(repo
298
+ ? `GitHub repo: ${repo}.`
299
+ : "GitHub repo: not detected; pass --repo owner/name or run from a GitHub checkout.");
300
+ if (skipSecrets) {
301
+ lines.push("");
302
+ lines.push("GitHub secrets/variables: skipped.");
303
+ }
304
+ else {
305
+ lines.push("");
306
+ lines.push("GitHub secrets/variables:");
307
+ const secretNames = [
308
+ ...plan.requiredSecrets,
309
+ ...(plan.secretValues.PLAN_RECAP_APP_URL ? ["PLAN_RECAP_APP_URL"] : []),
310
+ ];
311
+ for (const name of secretNames) {
312
+ const status = setGithubSecret(name, plan.secretValues[name], repo, dryRun);
313
+ if (status === "set") {
314
+ lines.push(` ${name}: set.`);
315
+ }
316
+ else if (status === "dry-run") {
317
+ lines.push(` ${name}: would set.`);
318
+ }
319
+ else if (status === "missing") {
320
+ lines.push(` ${name}: missing value.`);
321
+ if (name === "PLAN_RECAP_TOKEN") {
322
+ lines.push(` Run agent-native connect ${plan.appUrl} --client codex, then rerun this setup.`);
323
+ }
324
+ lines.push(` Or set manually: ${commandForMissingSecret(name, repo)}`);
325
+ }
326
+ else {
327
+ lines.push(` ${name}: could not set with gh.`);
328
+ lines.push(` Set manually: ${commandForMissingSecret(name, repo)}`);
329
+ }
330
+ }
331
+ for (const [name, value] of Object.entries(plan.variableValues)) {
332
+ const status = setGithubVariable(name, value, repo, dryRun);
333
+ if (status === "set") {
334
+ lines.push(` ${name}: set to ${value}.`);
335
+ }
336
+ else if (status === "dry-run") {
337
+ lines.push(` ${name}: would set to ${value}.`);
338
+ }
339
+ else if (status === "failed") {
340
+ lines.push(` ${name}: could not set with gh.`);
341
+ lines.push(` Set manually: ${commandForMissingVariable(name, value, repo)}`);
342
+ }
343
+ }
344
+ }
345
+ lines.push("");
346
+ lines.push(`Next: commit ${plan.workflowPath}, then run agent-native recap doctor.`);
347
+ process.stdout.write(`${lines.join("\n")}\n`);
348
+ }
349
+ function runDoctor(args) {
350
+ const baseDir = process.cwd();
351
+ const repo = resolveGithubRepo(optionalArg(args, "repo"));
352
+ const variables = listGithubVariables(repo);
353
+ const agent = normalizeRecapAgent(optionalArg(args, "agent") ??
354
+ variables?.get("VISUAL_RECAP_AGENT") ??
355
+ process.env.VISUAL_RECAP_AGENT);
356
+ const plan = buildRecapSetupPlan({
357
+ baseDir,
358
+ appUrl: optionalArg(args, "app-url"),
359
+ agent,
360
+ repo,
361
+ });
362
+ const lines = ["PR Visual Recap doctor", ""];
363
+ let ok = true;
364
+ const workflowFile = recapWorkflowFile(baseDir);
365
+ if (!fs.existsSync(workflowFile)) {
366
+ ok = false;
367
+ lines.push(`[missing] Workflow missing: ${plan.workflowPath}.`);
368
+ lines.push(" Run agent-native skills add visual-plan --with-github-action.");
369
+ }
370
+ else {
371
+ const current = fs.readFileSync(workflowFile, "utf-8");
372
+ if (current === PR_VISUAL_RECAP_WORKFLOW_YML) {
373
+ lines.push(`[ok] Workflow installed: ${plan.workflowPath}.`);
374
+ }
375
+ else {
376
+ ok = false;
377
+ lines.push(`[missing] Workflow differs from the bundled template: ${plan.workflowPath}.`);
378
+ lines.push(" Run agent-native recap setup to refresh it.");
379
+ }
380
+ }
381
+ if (plan.secretValues.PLAN_RECAP_TOKEN) {
382
+ lines.push("[ok] Local Plans publish token found.");
383
+ }
384
+ else {
385
+ lines.push("[warn] Local Plans publish token not found.");
386
+ lines.push(` Run agent-native connect ${plan.appUrl} --client codex to mint one.`);
387
+ }
388
+ if (repo) {
389
+ lines.push(`[ok] GitHub repo detected: ${repo}.`);
390
+ }
391
+ else {
392
+ ok = false;
393
+ lines.push("[missing] GitHub repo not detected.");
394
+ lines.push(" Pass --repo owner/name or run from a GitHub checkout with gh auth.");
395
+ }
396
+ const secretNames = listGithubNames("secret", repo);
397
+ if (!secretNames) {
398
+ ok = false;
399
+ lines.push("[missing] Could not read GitHub Actions secrets with gh.");
400
+ lines.push(" Run gh auth status, or pass --repo owner/name.");
401
+ }
402
+ else {
403
+ for (const name of plan.requiredSecrets) {
404
+ if (secretNames.has(name)) {
405
+ lines.push(`[ok] GitHub secret configured: ${name}.`);
406
+ }
407
+ else {
408
+ ok = false;
409
+ lines.push(`[missing] GitHub secret missing: ${name}.`);
410
+ lines.push(` Set it with: ${commandForMissingSecret(name, repo)}`);
411
+ }
412
+ }
413
+ }
414
+ if (!variables) {
415
+ lines.push("[warn] Could not read GitHub Actions variables with gh.");
416
+ }
417
+ else {
418
+ const configuredAgent = variables.get("VISUAL_RECAP_AGENT") || "claude";
419
+ lines.push(`[ok] Recap backend variable: ${configuredAgent}.`);
420
+ }
421
+ process.stdout.write(`${lines.join("\n")}\n`);
422
+ if (!ok)
423
+ process.exitCode = 1;
424
+ }
88
425
  /* -------------------------------------------------------------------------- */
89
426
  /* Secret scan — defense-in-depth before any LLM sees the diff */
90
427
  /* -------------------------------------------------------------------------- */
@@ -180,22 +517,33 @@ export function truncateDiffAtLineBoundary(text) {
180
517
  const body = lastNewline >= 0 ? capped.slice(0, lastNewline) : "";
181
518
  return body + RECAP_DIFF_TRUNCATED_FOOTER;
182
519
  }
183
- /** Count lines that begin with `+` or `-` (added/removed diff lines). */
520
+ /**
521
+ * Count lines that begin with `+` or `-` (added/removed diff lines), excluding
522
+ * the `+++ b/file` / `--- a/file` unified-diff header lines. Without this
523
+ * exclusion a single-file change loses ~2 "real" lines from the 8-line tiny
524
+ * threshold, incorrectly classifying a small-but-meaningful change as tiny.
525
+ */
184
526
  export function countDiffLines(diffText) {
185
527
  let count = 0;
186
528
  for (const line of diffText.split("\n")) {
529
+ if (line.startsWith("+++") || line.startsWith("---"))
530
+ continue;
187
531
  if (line.startsWith("+") || line.startsWith("-"))
188
532
  count += 1;
189
533
  }
190
534
  return count;
191
535
  }
192
536
  /**
193
- * Run `git diff <base>...<head> -- <pathspecs>` and return its stdout. Tolerates
194
- * a non-zero git exit (the original step used `|| true`) by capturing stdout
195
- * regardless. Array args NOT a shell string so the `:(exclude)` pathspecs
196
- * survive intact.
537
+ * Run `git diff <base>...<head> -- <pathspecs>` and return its stdout plus a
538
+ * `failed` flag. A non-zero exit that still produces stdout is treated as a
539
+ * partial result (same as the original `... || true`). A non-zero exit with
540
+ * empty stdout is a genuine failure (broken ref, missing object, etc.) and
541
+ * sets `failed: true` so `runCollectDiff` can exit with a distinct error
542
+ * instead of silently classifying the empty output as a tiny diff.
543
+ *
544
+ * Array args — NOT a shell string — so the `:(exclude)` pathspecs survive.
197
545
  */
198
- function gitDiff(base, head, extraArgs) {
546
+ function gitDiffRaw(base, head, extraArgs) {
199
547
  const args = [
200
548
  "diff",
201
549
  "--no-color",
@@ -205,19 +553,22 @@ function gitDiff(base, head, extraArgs) {
205
553
  ...RECAP_DIFF_PATHSPECS,
206
554
  ];
207
555
  try {
208
- return execFileSync("git", args, {
556
+ const stdout = execFileSync("git", args, {
209
557
  encoding: "utf8",
210
558
  maxBuffer: 256 * 1024 * 1024,
211
559
  });
560
+ return { stdout, failed: false };
212
561
  }
213
562
  catch (err) {
214
- // Tolerate a non-zero exit (e.g. missing object) but still use whatever git
215
- // wrote to stdout, exactly like the original `... > recap.diff || true`.
216
- if (err && typeof err.stdout === "string")
217
- return err.stdout;
218
- if (err && Buffer.isBuffer(err.stdout))
219
- return err.stdout.toString("utf8");
220
- return "";
563
+ // Recover whatever stdout git wrote before failing.
564
+ const raw = err && typeof err.stdout === "string"
565
+ ? err.stdout
566
+ : err && Buffer.isBuffer(err.stdout)
567
+ ? err.stdout.toString("utf8")
568
+ : "";
569
+ // An empty stdout from a non-zero exit means a broken ref / missing
570
+ // object — not a legitimate empty diff. Signal failure.
571
+ return { stdout: raw, failed: raw.trim() === "" };
221
572
  }
222
573
  }
223
574
  /**
@@ -226,6 +577,10 @@ function gitDiff(base, head, extraArgs) {
226
577
  * emits the same `bytes/changed/huge/tiny` outputs the workflow expects:
227
578
  * appended to $GITHUB_OUTPUT when set, AND printed as JSON to stdout (so it runs
228
579
  * and is testable outside GitHub Actions).
580
+ *
581
+ * Exits non-zero when git itself fails (broken SHA / missing object) so the
582
+ * CI workflow treats it as a real failure instead of silently classifying an
583
+ * empty diff as "tiny" and skipping the recap with no diagnostic.
229
584
  */
230
585
  function runCollectDiff(args) {
231
586
  const base = stringArg(args, "base");
@@ -233,14 +588,22 @@ function runCollectDiff(args) {
233
588
  const outPath = optionalArg(args, "out") ?? "recap.diff";
234
589
  const statPath = optionalArg(args, "stat") ?? "recap.stat";
235
590
  // The unified diff and the --stat summary (both excluding lockfiles/noise).
236
- let diff = gitDiff(base, head, []);
237
- const stat = gitDiff(base, head, ["--stat"]);
591
+ const diffResult = gitDiffRaw(base, head, []);
592
+ if (diffResult.failed) {
593
+ process.stderr.write(`recap collect-diff: git diff failed for ${base}...${head} — ` +
594
+ `the SHAs may be missing (shallow clone?) or invalid.\n` +
595
+ `Make sure the workflow checks out with fetch-depth: 0 or at least ` +
596
+ `enough history to resolve both refs.\n`);
597
+ process.exit(1);
598
+ }
599
+ let diff = diffResult.stdout;
600
+ const stat = gitDiffRaw(base, head, ["--stat"]).stdout;
238
601
  fs.writeFileSync(path.resolve(statPath), stat);
239
602
  // ORIGINAL line count — captured BEFORE any byte-cap truncation so a large
240
603
  // diff is never misclassified as tiny after truncation.
241
604
  const originalLines = countDiffLines(diff);
242
605
  // Changed-file count from `--name-only` over the same excludes.
243
- const names = gitDiff(base, head, ["--name-only"]);
606
+ const names = gitDiffRaw(base, head, ["--name-only"]).stdout;
244
607
  const changed = names.split("\n").filter((line) => line.length > 0).length;
245
608
  // Write the (possibly truncated) diff and compute the on-disk byte length.
246
609
  const bytesBefore = Buffer.byteLength(diff, "utf8");
@@ -300,17 +663,72 @@ export function buildRecapCodexMcpConfig(appUrl) {
300
663
  function runMcpConfig(args) {
301
664
  const agent = stringArg(args, "agent").toLowerCase();
302
665
  const appUrl = stringArg(args, "app-url");
666
+ const force = Boolean(args["force"]);
303
667
  if (agent === "claude") {
668
+ const token = process.env.PLAN_RECAP_TOKEN;
669
+ if (!token) {
670
+ process.stderr.write(`recap mcp-config: PLAN_RECAP_TOKEN is not set.\n` +
671
+ `Set it in the workflow environment before running this step.\n`);
672
+ process.exit(1);
673
+ }
304
674
  const out = stringArg(args, "out");
305
- fs.writeFileSync(path.resolve(out), buildRecapClaudeMcpConfig(appUrl, process.env.PLAN_RECAP_TOKEN));
675
+ fs.writeFileSync(path.resolve(out), buildRecapClaudeMcpConfig(appUrl, token));
306
676
  process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
307
677
  return;
308
678
  }
309
679
  if (agent === "codex") {
310
680
  const out = optionalArg(args, "out") ??
311
681
  path.join(os.homedir(), ".codex", "config.toml");
312
- fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
313
- fs.writeFileSync(path.resolve(out), buildRecapCodexMcpConfig(appUrl));
682
+ const absOut = path.resolve(out);
683
+ fs.mkdirSync(path.dirname(absOut), { recursive: true });
684
+ const newEntry = buildRecapCodexMcpConfig(appUrl);
685
+ const SECTION_MARKER = "[mcp_servers.plan]";
686
+ // If the file already exists and is non-empty, merge rather than overwrite.
687
+ let existing = "";
688
+ try {
689
+ const raw = fs.readFileSync(absOut, "utf8");
690
+ if (raw.trim())
691
+ existing = raw;
692
+ }
693
+ catch {
694
+ /* file absent — write fresh */
695
+ }
696
+ if (existing) {
697
+ if (existing.includes(SECTION_MARKER)) {
698
+ // Section already present — skip unless --force was passed.
699
+ if (!force) {
700
+ process.stdout.write(`${JSON.stringify({ ok: true, agent, out, skipped: true, reason: "plan entry already present; pass --force to overwrite" })}\n`);
701
+ return;
702
+ }
703
+ // --force: replace the existing [mcp_servers.plan] block.
704
+ // Remove lines from the section header until the next `[` header or EOF.
705
+ const lines = existing.split("\n");
706
+ const startIdx = lines.findIndex((l) => l.trim() === SECTION_MARKER);
707
+ let endIdx = lines.length;
708
+ for (let i = startIdx + 1; i < lines.length; i++) {
709
+ if (lines[i].trimStart().startsWith("[")) {
710
+ endIdx = i;
711
+ break;
712
+ }
713
+ }
714
+ const without = [
715
+ ...lines.slice(0, startIdx),
716
+ ...lines.slice(endIdx),
717
+ ].join("\n");
718
+ const merged = (without.trimEnd() ? without.trimEnd() + "\n\n" : "") + newEntry;
719
+ fs.writeFileSync(absOut, merged, { mode: 0o600 });
720
+ }
721
+ else {
722
+ // Append the new section to the existing config.
723
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
724
+ fs.writeFileSync(absOut, existing + separator + newEntry, {
725
+ mode: 0o600,
726
+ });
727
+ }
728
+ }
729
+ else {
730
+ fs.writeFileSync(absOut, newEntry);
731
+ }
314
732
  process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
315
733
  return;
316
734
  }
@@ -339,6 +757,62 @@ export function readRepoSkillMd(cwd = process.cwd()) {
339
757
  }
340
758
  throw new Error("Could not find visual-recap/SKILL.md. Run `agent-native skills add visual-plan` first.");
341
759
  }
760
+ function listRecapSkillReferenceFiles(skillDir) {
761
+ const out = {};
762
+ const walk = (current, prefix = "") => {
763
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
764
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
765
+ const abs = path.join(current, entry.name);
766
+ if (entry.isDirectory()) {
767
+ walk(abs, rel);
768
+ continue;
769
+ }
770
+ if (!entry.isFile() || rel === "SKILL.md")
771
+ continue;
772
+ if (rel === "agent-native-skill.json")
773
+ continue;
774
+ out[rel] = fs.readFileSync(abs, "utf8");
775
+ }
776
+ };
777
+ if (fs.existsSync(skillDir))
778
+ walk(skillDir);
779
+ return out;
780
+ }
781
+ function recapSkillBundleText(skillMd, referenceFiles) {
782
+ const refs = Object.keys(referenceFiles).sort();
783
+ if (refs.length === 0)
784
+ return skillMd;
785
+ const lines = [skillMd.trim(), "", "# Bundled visual-recap reference files"];
786
+ lines.push("These files live next to visual-recap/SKILL.md in a normal install. Treat them as part of the skill instructions.");
787
+ for (const rel of refs) {
788
+ lines.push("", `## ${rel}`, "", referenceFiles[rel].trim());
789
+ }
790
+ return lines.join("\n");
791
+ }
792
+ function readRepoSkillBundle(cwd = process.cwd()) {
793
+ const skill = readRepoSkillMd(cwd);
794
+ const skillDir = path.dirname(path.resolve(cwd, skill.source));
795
+ return {
796
+ text: recapSkillBundleText(skill.text, listRecapSkillReferenceFiles(skillDir)),
797
+ source: skill.source,
798
+ };
799
+ }
800
+ function latestVisualRecapSkillBundle() {
801
+ const planSkill = BUILT_IN_APP_SKILLS["visual-plans"];
802
+ const references = "extraFiles" in planSkill
803
+ ? (planSkill.extraFiles?.["visual-recap"] ?? {})
804
+ : {};
805
+ return {
806
+ text: recapSkillBundleText(VISUAL_RECAP_SKILL_MD, references),
807
+ source: "bundled:@agent-native/core/visual-recap",
808
+ };
809
+ }
810
+ export function readVisualRecapSkillBundle(cwd = process.cwd(), mode = "auto") {
811
+ if (mode === "latest" || mode === "auto") {
812
+ return latestVisualRecapSkillBundle();
813
+ }
814
+ return readRepoSkillBundle(cwd);
815
+ }
342
816
  export function buildRecapPrompt(input) {
343
817
  const appUrl = input.appUrl.replace(/\/$/, "");
344
818
  const localDir = input.localDir ?? path.join("plans", `pr-${input.pr}-visual-recap`);
@@ -375,16 +849,21 @@ export function buildRecapPrompt(input) {
375
849
  }
376
850
  else {
377
851
  lines.push("## Publish (this is the only way to produce output)");
378
- lines.push(`The \`plan\` MCP server is configured for you. Call its tools by name (your host may expose them as \`create-visual-recap\` or \`mcp__plan__create-visual-recap\` — same tool).`);
379
- lines.push(`1. Call the **create-visual-recap** tool on the \`plan\` MCP server with grounded MDX derived ONLY from the real diff${input.prevPlanId
380
- ? `, passing \`planId: "${input.prevPlanId}"\` so this REPLACES the existing recap plan`
852
+ lines.push(`The \`plan\` MCP server is configured for you. Call its tools by name (your host may expose them as \`get-plan-blocks\` / \`create-visual-recap\` or \`mcp__plan__get-plan-blocks\` / \`mcp__plan__create-visual-recap\` — same tools).`);
853
+ lines.push("First call `get-plan-blocks`, then call `create-visual-recap`. If `create-visual-recap` is available but `get-plan-blocks` is not, the Plan MCP is connected but the workflow/tool allowlist is stale. Report that `.github/workflows/pr-visual-recap.yml` must allow `mcp__plan__get-plan-blocks`; do not describe that case as a disconnected Plan MCP.");
854
+ lines.push(`1. Call the **create-visual-recap** tool on the \`plan\` MCP server with grounded MDX derived ONLY from the real diff, passing \`visibility: "org"\` so the recap is published org-scoped (never public) server-side${input.prevPlanId
855
+ ? `, and also passing \`planId: "${input.prevPlanId}"\` so this REPLACES the existing recap plan`
381
856
  : ""}.`);
382
- lines.push(`2. Call the **set-resource-visibility** tool on the \`plan\` MCP server with \`{ resourceType: "plan", resourceId: <the returned plan id>, visibility: "org" }\` so the recap is login-gated to the org, never public.`);
383
- lines.push(`3. Write the plan URL to a file named \`recap-url.txt\` at the repo root, containing exactly one line: \`${appUrl}/recaps/<the returned plan id>\`. This file is the workflow's only hand-off do not print anything else as the deliverable.`);
857
+ lines.push(`2. Write the plan URL to a file named \`recap-url.txt\` at the repo root, containing exactly one line: \`${appUrl}/recaps/<the returned plan id>\`. This file is the workflow's only hand-off do not print anything else as the deliverable.`);
858
+ lines.push(`3. (Fallback only skip if step 1 succeeded) If \`create-visual-recap\` does not accept a \`visibility\` parameter (older server), call the **set-resource-visibility** tool with \`{ resourceType: "plan", resourceId: <the returned plan id>, visibility: "org" }\` after publishing.`);
384
859
  }
385
860
  lines.push("");
386
861
  lines.push("Do not invent file names, schema fields, or endpoints. Redact anything that looks like a secret. If the diff has no reviewable substance, still publish a minimal recap and write recap-url.txt.");
387
862
  lines.push("");
863
+ lines.push("## Depth preflight");
864
+ lines.push("Before authoring the recap, read the diff/stat and make a quick private inventory of changed files, routes/actions, rendered UI surfaces, popovers/dialogs, role/access states, empty/error states, and shared abstractions. The published recap must cover each meaningful item with a structured block or intentionally omit it because it is tiny, redundant, or not user-visible.");
865
+ lines.push("For UI PRs, do not stop at one before/after. Show the entry point, the changed interaction surface, and the resulting/destination state; add role/access or empty/error states when the diff implements them. Then include the key file-tree and key-change diff tabs.");
866
+ lines.push("");
388
867
  lines.push("---");
389
868
  lines.push("");
390
869
  lines.push("# visual-recap skill (follow this exactly)");
@@ -397,6 +876,7 @@ export function buildRecapPrompt(input) {
397
876
  /* GitHub comment helpers */
398
877
  /* -------------------------------------------------------------------------- */
399
878
  const MARKER = "<!-- pr-visual-recap -->";
879
+ const RECAP_IMAGE_URL_PATH_PATTERN = /\/_agent-native\/recap-image\/[0-9a-f]{32,128}\.png$/;
400
880
  function repoParts(repoFullName) {
401
881
  const [owner, repo] = repoFullName.split("/");
402
882
  if (!owner || !repo)
@@ -484,8 +964,15 @@ function originOf(url) {
484
964
  }
485
965
  /** Build the sticky comment body from the workflow's environment. */
486
966
  export function buildCommentBody(env = process.env) {
487
- const headShort = (env.HEAD_SHA || "").slice(0, 7);
488
967
  const lines = [MARKER];
968
+ // Short head SHA for the "as of" freshness line.
969
+ const headSha = (env.HEAD_SHA || "").trim();
970
+ const headShort = headSha ? headSha.slice(0, 7) : "";
971
+ // Last-known plan id threaded from the previous run (supplied via PREV_PLAN_ID
972
+ // when the comment is rebuilt from scratch, or parsed from the env on upsert).
973
+ // We always emit the plan-id marker when any plan id is known so that a
974
+ // transient failure does not orphan the plan.
975
+ const prevPlanId = (env.PREV_PLAN_ID || "").trim() || null;
489
976
  if (env.SUPPRESSED === "true") {
490
977
  let reason = "potential secret in diff";
491
978
  try {
@@ -500,7 +987,11 @@ export function buildCommentBody(env = process.env) {
500
987
  lines.push("");
501
988
  lines.push("The recap was **suppressed** because the diff matched a secret/credential pattern. No plan was published.");
502
989
  lines.push("");
503
- lines.push(`Reason: \`${reason}\`. Updated for \`${headShort}\`.`);
990
+ lines.push(`Reason: \`${reason}\`.`);
991
+ if (headShort)
992
+ lines.push("", `_As of \`${headShort}\`_`);
993
+ if (prevPlanId)
994
+ lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
504
995
  return lines.join("\n");
505
996
  }
506
997
  // Tiny diffs aren't worth a recap. Refresh an existing sticky comment to this
@@ -510,8 +1001,10 @@ export function buildCommentBody(env = process.env) {
510
1001
  lines.push("### Visual recap — skipped (diff too small)");
511
1002
  lines.push("");
512
1003
  lines.push("The change in this push is too small to be worth a visual recap. This is informational only and does **not** block the PR.");
513
- lines.push("");
514
- lines.push(`Updated for \`${headShort}\`.`);
1004
+ if (headShort)
1005
+ lines.push("", `_As of \`${headShort}\`_`);
1006
+ if (prevPlanId)
1007
+ lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
515
1008
  return lines.join("\n");
516
1009
  }
517
1010
  const planUrl = (env.PLAN_URL || "").trim();
@@ -526,12 +1019,25 @@ export function buildCommentBody(env = process.env) {
526
1019
  const sameOriginOk = appUrl === "" || sameOrigin(planUrl, appUrl);
527
1020
  const base = (appUrl || originOf(planUrl)).replace(/\/$/, "");
528
1021
  const safeUrl = planId && base && sameOriginOk ? `${base}/recaps/${planId}` : "";
1022
+ // The plan id to embed in the marker — prefer the freshly-published one when
1023
+ // the origin is trusted, fall back to the previous run's id so the next push
1024
+ // can still replace in-place. Never use a plan id extracted from a bad-origin
1025
+ // URL as the marker (it would mask the last-good known id).
1026
+ const trustedPlanId = planId && sameOriginOk ? planId : null;
1027
+ const markerPlanId = trustedPlanId ?? prevPlanId;
529
1028
  if (!safeUrl) {
530
1029
  lines.push("### Visual recap — generation failed");
531
1030
  lines.push("");
532
1031
  lines.push("The visual recap could not be generated for this push. This is informational only and does **not** block the PR.");
533
- lines.push("");
534
- lines.push(`Updated for \`${headShort}\`.`);
1032
+ if (headShort)
1033
+ lines.push("", `_As of \`${headShort}\`_`);
1034
+ // Keep a link to the last-good recap so reviewers are not left in the dark.
1035
+ if (prevPlanId && base) {
1036
+ const prevSafeUrl = `${base}/recaps/${prevPlanId}`;
1037
+ lines.push("", `Previous recap (from an earlier push): [Open recap](${prevSafeUrl})`);
1038
+ }
1039
+ if (markerPlanId)
1040
+ lines.push("", `<!-- plan-id: ${markerPlanId} -->`);
535
1041
  return lines.join("\n");
536
1042
  }
537
1043
  // The image URL is produced by our own recap-image route, but validate it is
@@ -540,10 +1046,10 @@ export function buildCommentBody(env = process.env) {
540
1046
  const imageUrlRaw = (env.RECAP_IMAGE_URL || "").trim();
541
1047
  const imageUrl = imageUrlRaw &&
542
1048
  sameOrigin(imageUrlRaw, base) &&
543
- /\/_agent-native\/recap-image\/[0-9a-f]+\.png$/.test(imageUrlRaw)
1049
+ RECAP_IMAGE_URL_PATH_PATTERN.test(imageUrlRaw)
544
1050
  ? imageUrlRaw
545
1051
  : "";
546
- lines.push("### Visual recap — review at a higher altitude");
1052
+ lines.push("### Visual recap");
547
1053
  lines.push("");
548
1054
  if (imageUrl) {
549
1055
  lines.push(`[![Visual recap](${imageUrl})](${safeUrl})`);
@@ -554,10 +1060,9 @@ export function buildCommentBody(env = process.env) {
554
1060
  lines.push("");
555
1061
  lines.push("> Large diff — this recap is a **summarized** view (top files + schema/API deltas).");
556
1062
  }
557
- lines.push("");
558
- lines.push(`Updated for \`${headShort}\`.`);
559
- lines.push("");
560
- lines.push(`<!-- plan-id: ${planId} -->`);
1063
+ if (headShort)
1064
+ lines.push("", `_As of \`${headShort}\`_`);
1065
+ lines.push("", `<!-- plan-id: ${planId} -->`);
561
1066
  return lines.join("\n");
562
1067
  }
563
1068
  /* -------------------------------------------------------------------------- */
@@ -574,7 +1079,15 @@ function runScan(args) {
574
1079
  }
575
1080
  }
576
1081
  function runBuildPrompt(args) {
577
- const skill = readRepoSkillMd();
1082
+ const skillSource = optionalArg(args, "skill-source") ??
1083
+ process.env.VISUAL_RECAP_SKILL_SOURCE ??
1084
+ "auto";
1085
+ if (skillSource !== "auto" &&
1086
+ skillSource !== "latest" &&
1087
+ skillSource !== "repo") {
1088
+ throw new Error("--skill-source must be auto, latest, or repo.");
1089
+ }
1090
+ const skill = readVisualRecapSkillBundle(process.cwd(), skillSource);
578
1091
  const prompt = buildRecapPrompt({
579
1092
  skillMd: skill.text,
580
1093
  pr: stringArg(args, "pr"),
@@ -592,6 +1105,49 @@ function runBuildPrompt(args) {
592
1105
  fs.writeFileSync(path.resolve(out), prompt);
593
1106
  process.stdout.write(`${JSON.stringify({ ok: true, out, skillSource: skill.source, bytes: prompt.length })}\n`);
594
1107
  }
1108
+ function delay(ms) {
1109
+ return ms > 0
1110
+ ? new Promise((resolve) => setTimeout(resolve, ms))
1111
+ : Promise.resolve();
1112
+ }
1113
+ /**
1114
+ * Confirm GitHub can fetch the uploaded image anonymously before we embed it.
1115
+ *
1116
+ * Default budget: 8 attempts with capped exponential backoff (1s, 2s, 3s, …
1117
+ * capped at 4s) → ~20s total. This is enough to survive a cold-start CDN
1118
+ * propagation delay that would otherwise cause `uploadRecapImage` to return a
1119
+ * URL that the GitHub PR comment can't display.
1120
+ *
1121
+ * The `attempts` and `delayMs` overrides remain for unit tests and for callers
1122
+ * that need a tighter or looser budget.
1123
+ */
1124
+ export async function waitForPublicRecapImage(input) {
1125
+ const attempts = Math.max(1, input.attempts ?? 8);
1126
+ const delayMs = Math.max(0, input.delayMs ?? 1000);
1127
+ const fetchFn = input.fetchFn ?? fetch;
1128
+ const MAX_DELAY_MS = 4000;
1129
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
1130
+ try {
1131
+ const res = await fetchFn(input.imageUrl, {
1132
+ method: "GET",
1133
+ headers: { accept: "image/png" },
1134
+ redirect: "follow",
1135
+ });
1136
+ const contentType = res.headers.get("content-type")?.toLowerCase() ?? "";
1137
+ if (res.ok && contentType.split(";")[0]?.trim() === "image/png") {
1138
+ const bytes = await res.arrayBuffer().catch(() => new ArrayBuffer(0));
1139
+ if (bytes.byteLength > 0)
1140
+ return true;
1141
+ }
1142
+ }
1143
+ catch {
1144
+ /* retry below */
1145
+ }
1146
+ if (attempt < attempts)
1147
+ await delay(Math.min(delayMs * attempt, MAX_DELAY_MS));
1148
+ }
1149
+ return false;
1150
+ }
595
1151
  /** Upload a PNG to the plan app's signed public image route; returns its URL. */
596
1152
  async function uploadRecapImage(input) {
597
1153
  try {
@@ -618,6 +1174,13 @@ async function uploadRecapImage(input) {
618
1174
  process.stderr.write(`[recap shot] image upload returned no imageUrl (status ${res.status})\n`);
619
1175
  return null;
620
1176
  }
1177
+ const publiclyReadable = await waitForPublicRecapImage({
1178
+ imageUrl: json.imageUrl,
1179
+ });
1180
+ if (!publiclyReadable) {
1181
+ process.stderr.write(`[recap shot] uploaded image was not publicly readable as image/png: ${json.imageUrl}\n`);
1182
+ return null;
1183
+ }
621
1184
  return json.imageUrl;
622
1185
  }
623
1186
  catch (err) {
@@ -723,7 +1286,7 @@ async function runShot(args) {
723
1286
  // Zoom out slightly so more content fits. Keep the plan title (h1) in frame:
724
1287
  // the recap reads better led by its own title than cropped to the body.
725
1288
  await page.evaluate(() => {
726
- document.documentElement.style.zoom = "80%";
1289
+ document.documentElement.style.zoom = "90%";
727
1290
  });
728
1291
  await page.screenshot({ path: out });
729
1292
  captured = true;
@@ -781,15 +1344,29 @@ async function runComment(args, sub) {
781
1344
  * recap job runs (the workflow itself, the skill, the local CLI, or any agent
782
1345
  * config the runner loads) — so the whole job is skipped, not just the agent
783
1346
  * step, to keep untrusted PR code away from the publish/API secrets.
1347
+ *
1348
+ * The `packages/core/**` rule is scoped to the BuilderIO/agent-native monorepo
1349
+ * (where packages/core IS the recap CLI source) so that consumer repos with an
1350
+ * unrelated `packages/core/` directory are not silently gated. Pass the
1351
+ * `repository` ("owner/name") to apply that scoping; omit it to match the old
1352
+ * unconditional behaviour (safe for the gate's self-test).
784
1353
  */
785
- export function isRecapSensitivePath(p) {
786
- return (p === ".github/workflows/pr-visual-recap.yml" ||
1354
+ export function isRecapSensitivePath(p, repository) {
1355
+ if (p === ".github/workflows/pr-visual-recap.yml" ||
787
1356
  /(^|\/)skills\/visual-(recap|plan|plans)\//.test(p) ||
788
1357
  /(^|\/)\.claude\//.test(p) ||
789
1358
  /(^|\/)CLAUDE\.md$/.test(p) ||
790
1359
  /(^|\/)AGENTS\.md$/.test(p) ||
791
- /(^|\/)\.mcp\.json$/.test(p) ||
792
- /(^|\/)packages\/core\//.test(p));
1360
+ /(^|\/)\.mcp\.json$/.test(p)) {
1361
+ return true;
1362
+ }
1363
+ // packages/core is the recap-CLI source only in the agent-native monorepo.
1364
+ // In consumer repos an unrelated packages/core/ must not gate recaps.
1365
+ const isAgentNativeMonorepo = !repository || repository === "BuilderIO/agent-native";
1366
+ if (isAgentNativeMonorepo && /(^|\/)packages\/core\//.test(p)) {
1367
+ return true;
1368
+ }
1369
+ return false;
793
1370
  }
794
1371
  /**
795
1372
  * The pure gate decision: given the PR payload, secret-presence flags, the
@@ -853,7 +1430,7 @@ export function evaluateRecapGate(input) {
853
1430
  // config the runner would load (.claude/**, CLAUDE.md, .mcp.json), skip the
854
1431
  // ENTIRE job — not just the agent — so a PR can never rewrite what runs
855
1432
  // (skill, hooks, settings, CLI) and exfiltrate the publish/API secrets.
856
- const hits = input.changedFiles.filter(isRecapSensitivePath);
1433
+ const hits = input.changedFiles.filter((p) => isRecapSensitivePath(p, input.repository));
857
1434
  if (hits.length) {
858
1435
  reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(", ")}${hits.length > 3 ? ", …" : ""}) — skipping so untrusted PR code never runs with secrets`);
859
1436
  }
@@ -1338,10 +1915,12 @@ async function runUsage(args) {
1338
1915
  const HELP = `agent-native recap — PR visual recap helpers (used by the GitHub Action)
1339
1916
 
1340
1917
  Usage:
1918
+ agent-native recap setup [--repo owner/name] [--agent claude|codex] [--app-url <url>] [--skip-secrets] [--dry-run]
1919
+ agent-native recap doctor [--repo owner/name] [--agent claude|codex] [--app-url <url>]
1341
1920
  agent-native recap collect-diff --base <baseSha> --head <headSha> [--out recap.diff] [--stat recap.stat]
1342
1921
  agent-native recap mcp-config --agent claude|codex --app-url <url> [--out <path>]
1343
1922
  agent-native recap scan --diff <path>
1344
- agent-native recap build-prompt --pr <n> [--repo owner/name] [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--prev-plan-id <id>] [--huge] [--local-files] [--local-dir <folder>] [--out <path>]
1923
+ agent-native recap build-prompt --pr <n> [--repo owner/name] [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--prev-plan-id <id>] [--huge] [--local-files] [--local-dir <folder>] [--skill-source auto|latest|repo] [--out <path>]
1345
1924
  agent-native recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png]
1346
1925
  agent-native recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex] [--model <id>]
1347
1926
  agent-native recap comment <find-plan-id|upsert> --repo owner/name --issue <n> --token <github-token>
@@ -1368,11 +1947,25 @@ Usage:
1368
1947
  packages/core, .claude/**, CLAUDE.md, AGENTS.md, .mcp.json) — failing CLOSED
1369
1948
  on any file-list error. Writes run=<true|false> and agent=<claude|codex> to
1370
1949
  $GITHUB_OUTPUT.
1950
+ agent-native recap setup
1951
+ Write/refresh .github/workflows/pr-visual-recap.yml, then configure GitHub
1952
+ Actions secrets and variables with gh when values are available from env or
1953
+ the local Plans publish-token store. Missing values are printed as exact next
1954
+ commands; secret values are sent to gh through stdin, never argv.
1955
+ agent-native recap doctor
1956
+ Check workflow presence/drift, local Plans publish-token availability, gh
1957
+ repo access, and required GitHub Actions secrets for the selected backend.
1371
1958
  `;
1372
1959
  export async function runRecap(argv) {
1373
1960
  const [sub, ...rest] = argv;
1374
1961
  const args = parseArgs(rest);
1375
1962
  switch (sub) {
1963
+ case "setup":
1964
+ runSetup(args);
1965
+ return;
1966
+ case "doctor":
1967
+ runDoctor(args);
1968
+ return;
1376
1969
  case "collect-diff":
1377
1970
  runCollectDiff(args);
1378
1971
  return;