@agent-native/core 0.44.4 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cli/connect.d.ts +2 -1
  2. package/dist/cli/connect.d.ts.map +1 -1
  3. package/dist/cli/connect.js +185 -5
  4. package/dist/cli/connect.js.map +1 -1
  5. package/dist/cli/index.js +27 -0
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/plan-local.d.ts +43 -0
  8. package/dist/cli/plan-local.d.ts.map +1 -0
  9. package/dist/cli/plan-local.js +477 -0
  10. package/dist/cli/plan-local.js.map +1 -0
  11. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  12. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  13. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  14. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  15. package/dist/cli/recap.d.ts +164 -0
  16. package/dist/cli/recap.d.ts.map +1 -1
  17. package/dist/cli/recap.js +657 -10
  18. package/dist/cli/recap.js.map +1 -1
  19. package/dist/cli/skills.d.ts +2 -2
  20. package/dist/cli/skills.d.ts.map +1 -1
  21. package/dist/cli/skills.js +160 -387
  22. package/dist/cli/skills.js.map +1 -1
  23. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  24. package/dist/client/blocks/library/AnnotatedCodeBlock.js +4 -1
  25. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  26. package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
  27. package/dist/client/blocks/library/DiffBlock.js +10 -11
  28. package/dist/client/blocks/library/DiffBlock.js.map +1 -1
  29. package/dist/client/blocks/library/annotation-rail.d.ts +15 -5
  30. package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
  31. package/dist/client/blocks/library/annotation-rail.js +35 -24
  32. package/dist/client/blocks/library/annotation-rail.js.map +1 -1
  33. package/dist/client/blocks/library/code-filename-label.d.ts +8 -0
  34. package/dist/client/blocks/library/code-filename-label.d.ts.map +1 -0
  35. package/dist/client/blocks/library/code-filename-label.js +15 -0
  36. package/dist/client/blocks/library/code-filename-label.js.map +1 -0
  37. package/dist/client/blocks/library/code.d.ts.map +1 -1
  38. package/dist/client/blocks/library/code.js +3 -2
  39. package/dist/client/blocks/library/code.js.map +1 -1
  40. package/dist/client/blocks/library/diff.config.d.ts +1 -1
  41. package/dist/client/blocks/library/diff.config.js.map +1 -1
  42. package/dist/client/blocks/library/narrow-container.d.ts +4 -4
  43. package/dist/client/blocks/library/narrow-container.d.ts.map +1 -1
  44. package/dist/client/blocks/library/narrow-container.js +10 -10
  45. package/dist/client/blocks/library/narrow-container.js.map +1 -1
  46. package/dist/client/blocks/library/tabs.d.ts.map +1 -1
  47. package/dist/client/blocks/library/tabs.js +7 -2
  48. package/dist/client/blocks/library/tabs.js.map +1 -1
  49. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  50. package/dist/client/composer/TiptapComposer.js +4 -1
  51. package/dist/client/composer/TiptapComposer.js.map +1 -1
  52. package/dist/client/db-admin/TableEditor.d.ts.map +1 -1
  53. package/dist/client/db-admin/TableEditor.js +3 -1
  54. package/dist/client/db-admin/TableEditor.js.map +1 -1
  55. package/dist/db/client.d.ts +8 -0
  56. package/dist/db/client.d.ts.map +1 -1
  57. package/dist/db/client.js +23 -2
  58. package/dist/db/client.js.map +1 -1
  59. package/dist/deploy/build.d.ts.map +1 -1
  60. package/dist/deploy/build.js +8 -0
  61. package/dist/deploy/build.js.map +1 -1
  62. package/dist/extensions/html-shell.js +1 -1
  63. package/dist/extensions/html-shell.js.map +1 -1
  64. package/dist/jobs/scheduler.d.ts.map +1 -1
  65. package/dist/jobs/scheduler.js +5 -1
  66. package/dist/jobs/scheduler.js.map +1 -1
  67. package/dist/mcp/build-server.d.ts +1 -0
  68. package/dist/mcp/build-server.d.ts.map +1 -1
  69. package/dist/mcp/build-server.js +7 -3
  70. package/dist/mcp/build-server.js.map +1 -1
  71. package/dist/mcp/oauth-route.d.ts.map +1 -1
  72. package/dist/mcp/oauth-route.js +56 -19
  73. package/dist/mcp/oauth-route.js.map +1 -1
  74. package/dist/mcp/oauth-store.d.ts +1 -0
  75. package/dist/mcp/oauth-store.d.ts.map +1 -1
  76. package/dist/mcp/oauth-store.js +9 -0
  77. package/dist/mcp/oauth-store.js.map +1 -1
  78. package/dist/mcp/server.d.ts.map +1 -1
  79. package/dist/mcp/server.js +9 -4
  80. package/dist/mcp/server.js.map +1 -1
  81. package/dist/mcp-client/errors.js +3 -3
  82. package/dist/mcp-client/errors.js.map +1 -1
  83. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  84. package/dist/server/agent-chat-plugin.js +3 -1
  85. package/dist/server/agent-chat-plugin.js.map +1 -1
  86. package/dist/server/agent-teams.d.ts.map +1 -1
  87. package/dist/server/agent-teams.js +10 -2
  88. package/dist/server/agent-teams.js.map +1 -1
  89. package/dist/server/auth.d.ts.map +1 -1
  90. package/dist/server/auth.js +7 -3
  91. package/dist/server/auth.js.map +1 -1
  92. package/dist/server/recap-image-route.d.ts.map +1 -1
  93. package/dist/server/recap-image-route.js +3 -6
  94. package/dist/server/recap-image-route.js.map +1 -1
  95. package/dist/server/sentry.d.ts.map +1 -1
  96. package/dist/server/sentry.js +12 -5
  97. package/dist/server/sentry.js.map +1 -1
  98. package/dist/server/social-og-image.d.ts.map +1 -1
  99. package/dist/server/social-og-image.js +3 -1
  100. package/dist/server/social-og-image.js.map +1 -1
  101. package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +22 -6
  102. package/docs/content/plan-plugin.md +18 -1
  103. package/docs/content/pr-visual-recap.md +37 -10
  104. package/docs/content/template-plan.md +45 -1
  105. package/package.json +1 -1
  106. package/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +22 -6
package/dist/cli/recap.js CHANGED
@@ -7,6 +7,15 @@
7
7
  * diff and publishes the plan via the plan MCP tools. These subcommands are the
8
8
  * thin, deterministic glue around that:
9
9
  *
10
+ * gate The security boundary: decide whether the recap runs at all
11
+ * (skipping drafts, forks, bots, missing secrets, an invalid
12
+ * agent/model, and PRs that touch recap-control files) and which
13
+ * normalized backend agent to use.
14
+ * collect-diff Collect the bounded base...head diff (excluding lockfiles,
15
+ * build output, snapshots), cap it at ~600KB, and classify the
16
+ * huge/tiny flags.
17
+ * mcp-config Write the plan MCP client config for the chosen backend
18
+ * (Claude Code JSON or Codex config.toml).
10
19
  * scan Refuse to hand a secret-leaking diff to the agent.
11
20
  * build-prompt Assemble the agent prompt = repo SKILL.md + a task wrapper.
12
21
  * shot Screenshot the published plan and upload it to the plan app's
@@ -18,7 +27,9 @@
18
27
  *
19
28
  * Node built-ins only (plus an optional dynamic `playwright` import for `shot`).
20
29
  */
30
+ import { execFileSync } from "node:child_process";
21
31
  import fs from "node:fs";
32
+ import os from "node:os";
22
33
  import path from "node:path";
23
34
  import { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js";
24
35
  /* -------------------------------------------------------------------------- */
@@ -116,6 +127,196 @@ export function diffContainsSecret(diffText) {
116
127
  return false;
117
128
  }
118
129
  /* -------------------------------------------------------------------------- */
130
+ /* Bounded diff collection — was the workflow's "Collect bounded diff" step */
131
+ /* -------------------------------------------------------------------------- */
132
+ /** ~600KB byte cap for the diff handed to the recap agent. */
133
+ export const RECAP_DIFF_BYTE_CAP = 614400;
134
+ /** The footer appended when a diff is truncated at the byte cap. */
135
+ export const RECAP_DIFF_TRUNCATED_FOOTER = "\n\n[diff truncated at 600KB for the recap agent]\n";
136
+ /**
137
+ * The pathspecs the bounded diff excludes — lockfiles, build output, and
138
+ * snapshots are noise for a visual recap. Kept as array args (not a shell
139
+ * string) so the `:(exclude)` pathspecs are never mangled by a shell.
140
+ */
141
+ const RECAP_DIFF_PATHSPECS = [
142
+ ".",
143
+ ":(exclude)pnpm-lock.yaml",
144
+ ":(exclude)**/dist/**",
145
+ ":(exclude)**/*.snap",
146
+ ":(exclude)**/*.lock",
147
+ ];
148
+ /**
149
+ * Classify a bounded diff into the `huge` / `tiny` flags the workflow consumes.
150
+ *
151
+ * - huge: BYTES over the ~600KB cap. The agent is told to summarize AND the
152
+ * diff file is physically truncated so it can't overflow the prompt budget.
153
+ * - tiny: <= 1 changed file AND <= 8 changed lines. Uses ORIGINAL line count
154
+ * (captured before any truncation) so a large diff is never misclassified as
155
+ * tiny after the byte cap drops most of its lines.
156
+ *
157
+ * Pure (no I/O) so the classification can be unit-tested without invoking git.
158
+ */
159
+ export function classifyDiff(input) {
160
+ return {
161
+ huge: input.bytes > RECAP_DIFF_BYTE_CAP,
162
+ tiny: input.changed <= 1 && input.originalLines <= 8,
163
+ };
164
+ }
165
+ /**
166
+ * Truncate a diff to the ~600KB byte cap at a COMPLETE LINE boundary, then
167
+ * append the truncated footer. Dropping the last (possibly-partial) line is the
168
+ * equivalent of the original `head -c 614400 | sed '$d'`: it guarantees the cap
169
+ * never cuts a multi-byte UTF-8 char or a diff line mid-way and corrupts the
170
+ * agent's input. Pure (string in, string out) so it can be unit-tested.
171
+ */
172
+ export function truncateDiffAtLineBoundary(text) {
173
+ const capped = Buffer.from(text, "utf8")
174
+ .subarray(0, RECAP_DIFF_BYTE_CAP)
175
+ .toString("utf8");
176
+ const lastNewline = capped.lastIndexOf("\n");
177
+ // Drop everything after the last newline (the last, possibly-partial line),
178
+ // mirroring `sed '$d'`. If there is no newline at all, drop the whole partial
179
+ // line (empty body) — the footer still makes the truncation explicit.
180
+ const body = lastNewline >= 0 ? capped.slice(0, lastNewline) : "";
181
+ return body + RECAP_DIFF_TRUNCATED_FOOTER;
182
+ }
183
+ /** Count lines that begin with `+` or `-` (added/removed diff lines). */
184
+ export function countDiffLines(diffText) {
185
+ let count = 0;
186
+ for (const line of diffText.split("\n")) {
187
+ if (line.startsWith("+") || line.startsWith("-"))
188
+ count += 1;
189
+ }
190
+ return count;
191
+ }
192
+ /**
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.
197
+ */
198
+ function gitDiff(base, head, extraArgs) {
199
+ const args = [
200
+ "diff",
201
+ "--no-color",
202
+ ...extraArgs,
203
+ `${base}...${head}`,
204
+ "--",
205
+ ...RECAP_DIFF_PATHSPECS,
206
+ ];
207
+ try {
208
+ return execFileSync("git", args, {
209
+ encoding: "utf8",
210
+ maxBuffer: 256 * 1024 * 1024,
211
+ });
212
+ }
213
+ 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 "";
221
+ }
222
+ }
223
+ /**
224
+ * `recap collect-diff` — the bounded-diff collection that used to be ~60 lines
225
+ * of inline bash. Writes recap.diff + recap.stat, classifies huge/tiny, and
226
+ * emits the same `bytes/changed/huge/tiny` outputs the workflow expects:
227
+ * appended to $GITHUB_OUTPUT when set, AND printed as JSON to stdout (so it runs
228
+ * and is testable outside GitHub Actions).
229
+ */
230
+ function runCollectDiff(args) {
231
+ const base = stringArg(args, "base");
232
+ const head = stringArg(args, "head");
233
+ const outPath = optionalArg(args, "out") ?? "recap.diff";
234
+ const statPath = optionalArg(args, "stat") ?? "recap.stat";
235
+ // The unified diff and the --stat summary (both excluding lockfiles/noise).
236
+ let diff = gitDiff(base, head, []);
237
+ const stat = gitDiff(base, head, ["--stat"]);
238
+ fs.writeFileSync(path.resolve(statPath), stat);
239
+ // ORIGINAL line count — captured BEFORE any byte-cap truncation so a large
240
+ // diff is never misclassified as tiny after truncation.
241
+ const originalLines = countDiffLines(diff);
242
+ // Changed-file count from `--name-only` over the same excludes.
243
+ const names = gitDiff(base, head, ["--name-only"]);
244
+ const changed = names.split("\n").filter((line) => line.length > 0).length;
245
+ // Write the (possibly truncated) diff and compute the on-disk byte length.
246
+ const bytesBefore = Buffer.byteLength(diff, "utf8");
247
+ const { huge } = classifyDiff({ bytes: bytesBefore, changed, originalLines });
248
+ if (huge)
249
+ diff = truncateDiffAtLineBoundary(diff);
250
+ fs.writeFileSync(path.resolve(outPath), diff);
251
+ const bytes = fs.statSync(path.resolve(outPath)).size;
252
+ const { tiny } = classifyDiff({ bytes: bytesBefore, changed, originalLines });
253
+ // Preserve the existing steps.diff.outputs.{bytes,changed,huge,tiny} contract.
254
+ const githubOutput = process.env.GITHUB_OUTPUT;
255
+ if (githubOutput) {
256
+ fs.appendFileSync(githubOutput, `bytes=${bytes}\nchanged=${changed}\nhuge=${huge}\ntiny=${tiny}\n`);
257
+ }
258
+ process.stdout.write(`${JSON.stringify({ bytes, changed, huge, tiny })}\n`);
259
+ }
260
+ /* -------------------------------------------------------------------------- */
261
+ /* MCP config writers — were the two `node -e` one-liners in the agent steps */
262
+ /* -------------------------------------------------------------------------- */
263
+ /**
264
+ * The Claude Code MCP config the recap agent loads: a single HTTP `plan` server
265
+ * pointing at the app's `/_agent-native/mcp` endpoint, authorized with the
266
+ * PLAN_RECAP_TOKEN. Pure (returns the JSON string) so it can be unit-tested.
267
+ */
268
+ export function buildRecapClaudeMcpConfig(appUrl, token) {
269
+ const url = appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
270
+ return JSON.stringify({
271
+ mcpServers: {
272
+ plan: {
273
+ type: "http",
274
+ url,
275
+ headers: { Authorization: "Bearer " + token },
276
+ },
277
+ },
278
+ });
279
+ }
280
+ /**
281
+ * The Codex `config.toml` the recap agent loads. JSON.stringify the URL value so
282
+ * a stray quote/newline in the app URL can't break out of the TOML basic string
283
+ * (TOML shares JSON's escaping); the key and env-var name stay literal. Pure so
284
+ * it can be unit-tested.
285
+ */
286
+ export function buildRecapCodexMcpConfig(appUrl) {
287
+ const url = appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
288
+ return ("[mcp_servers.plan]\n" +
289
+ "url = " +
290
+ JSON.stringify(url) +
291
+ "\n" +
292
+ 'bearer_token_env_var = "PLAN_RECAP_TOKEN"\n');
293
+ }
294
+ /**
295
+ * `recap mcp-config` — write the plan MCP client config for the chosen backend,
296
+ * replacing the two `node -e '...'` one-liners that previously lived inline in
297
+ * the agent steps. PLAN_RECAP_TOKEN is read from the environment (claude only),
298
+ * exactly as before.
299
+ */
300
+ function runMcpConfig(args) {
301
+ const agent = stringArg(args, "agent").toLowerCase();
302
+ const appUrl = stringArg(args, "app-url");
303
+ if (agent === "claude") {
304
+ const out = stringArg(args, "out");
305
+ fs.writeFileSync(path.resolve(out), buildRecapClaudeMcpConfig(appUrl, process.env.PLAN_RECAP_TOKEN));
306
+ process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
307
+ return;
308
+ }
309
+ if (agent === "codex") {
310
+ const out = optionalArg(args, "out") ??
311
+ 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));
314
+ process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
315
+ return;
316
+ }
317
+ throw new Error(`Unknown --agent "${agent}" (expected "claude" or "codex")`);
318
+ }
319
+ /* -------------------------------------------------------------------------- */
119
320
  /* Prompt builder — repo SKILL.md + task wrapper */
120
321
  /* -------------------------------------------------------------------------- */
121
322
  /**
@@ -140,13 +341,22 @@ export function readRepoSkillMd(cwd = process.cwd()) {
140
341
  }
141
342
  export function buildRecapPrompt(input) {
142
343
  const appUrl = input.appUrl.replace(/\/$/, "");
344
+ const localDir = input.localDir ?? path.join("plans", `pr-${input.pr}-visual-recap`);
143
345
  const lines = [];
144
- lines.push("# Task: publish a Visual Recap of this pull request");
346
+ lines.push(input.localFiles
347
+ ? "# Task: create a DB-free local Visual Recap of this pull request"
348
+ : "# Task: publish a Visual Recap of this pull request");
145
349
  lines.push("");
146
- lines.push(`You are running non-interactively in CI. Follow the **visual-recap skill** included verbatim below to turn this PR's diff into a grounded Agent-Native Plan, then publish it.`);
350
+ lines.push(input.localFiles
351
+ ? `You are running non-interactively in local-files privacy mode. Follow the **visual-recap skill** included verbatim below to turn this PR's diff into a grounded Agent-Native Plan MDX folder, but do not publish it or call any Plan MCP/action write tool.`
352
+ : `You are running non-interactively in CI. Follow the **visual-recap skill** included verbatim below to turn this PR's diff into a grounded Agent-Native Plan, then publish it.`);
147
353
  lines.push("");
148
354
  lines.push("## Inputs (read them from disk with your Read tool)");
149
355
  lines.push(`- PR number: **#${input.pr}**`);
356
+ if (input.repo) {
357
+ lines.push(`- Repository: **${input.repo}**`);
358
+ lines.push(`- Pull request URL: https://github.com/${input.repo}/pull/${input.pr}`);
359
+ }
150
360
  if (input.head)
151
361
  lines.push(`- Head commit: \`${input.head}\``);
152
362
  lines.push(`- Unified diff: \`${input.diffPath}\` (read this file)`);
@@ -156,13 +366,22 @@ export function buildRecapPrompt(input) {
156
366
  lines.push(`- The diff is LARGE — produce a **summarized** recap (top files + schema/API deltas), not an exhaustive one.`);
157
367
  }
158
368
  lines.push("");
159
- lines.push("## Publish (this is the only way to produce output)");
160
- 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).`);
161
- 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
162
- ? `, passing \`planId: "${input.prevPlanId}"\` so this REPLACES the existing recap plan`
163
- : ""}.`);
164
- 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.`);
165
- 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.`);
369
+ if (input.localFiles) {
370
+ lines.push("## Local-Files Output (this is the only way to produce output)");
371
+ lines.push("Do NOT call the `plan` MCP server, `create-visual-recap`, `import-visual-plan-source`, `update-visual-plan`, `export-visual-plan`, or any hosted Plan action. This mode exists so the recap data never goes to a Plan app database.");
372
+ lines.push(`1. Create or replace the local MDX folder \`${localDir}\` with \`plan.mdx\` and optional \`canvas.mdx\`, \`prototype.mdx\`, and \`.plan-state.json\` derived ONLY from the real diff. Set \`kind: "recap"\` and \`localOnly: true\` in source metadata/state.`);
373
+ lines.push(`2. Run \`agent-native plan local preview --dir ${JSON.stringify(localDir)} --kind recap --out ${JSON.stringify(path.join(localDir, "preview.html"))}\` to validate the folder and generate the local preview.`);
374
+ lines.push("3. Write the returned `url` from that command to `recap-url.txt` at the repo root, containing exactly one line. This file is the workflow's only hand-off.");
375
+ }
376
+ else {
377
+ 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`
381
+ : ""}.`);
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.`);
384
+ }
166
385
  lines.push("");
167
386
  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.");
168
387
  lines.push("");
@@ -359,12 +578,15 @@ function runBuildPrompt(args) {
359
578
  const prompt = buildRecapPrompt({
360
579
  skillMd: skill.text,
361
580
  pr: stringArg(args, "pr"),
581
+ repo: optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY,
362
582
  head: optionalArg(args, "head"),
363
583
  appUrl: optionalArg(args, "app-url") ?? "https://plan.agent-native.com",
364
584
  diffPath: optionalArg(args, "diff") ?? "recap.diff",
365
585
  statPath: optionalArg(args, "stat"),
366
586
  prevPlanId: optionalArg(args, "prev-plan-id"),
367
587
  huge: args.huge === true || args.huge === "true",
588
+ localFiles: args["local-files"] === true || args["local-files"] === "true",
589
+ localDir: optionalArg(args, "local-dir"),
368
590
  });
369
591
  const out = optionalArg(args, "out") ?? "recap-prompt.md";
370
592
  fs.writeFileSync(path.resolve(out), prompt);
@@ -554,6 +776,394 @@ async function runComment(args, sub) {
554
776
  }
555
777
  throw new Error("Usage: agent-native recap comment <find-plan-id|upsert> --repo owner/name --issue n --token token");
556
778
  }
779
+ /**
780
+ * Files that, if a PR touches them, would let that PR rewrite what the trusted
781
+ * recap job runs (the workflow itself, the skill, the local CLI, or any agent
782
+ * config the runner loads) — so the whole job is skipped, not just the agent
783
+ * step, to keep untrusted PR code away from the publish/API secrets.
784
+ */
785
+ export function isRecapSensitivePath(p) {
786
+ return (p === ".github/workflows/pr-visual-recap.yml" ||
787
+ /(^|\/)skills\/visual-(recap|plan|plans)\//.test(p) ||
788
+ /(^|\/)\.claude\//.test(p) ||
789
+ /(^|\/)CLAUDE\.md$/.test(p) ||
790
+ /(^|\/)AGENTS\.md$/.test(p) ||
791
+ /(^|\/)\.mcp\.json$/.test(p) ||
792
+ /(^|\/)packages\/core\//.test(p));
793
+ }
794
+ /**
795
+ * The pure gate decision: given the PR payload, secret-presence flags, the
796
+ * configured backend/model, and the PR's changed files, decide whether the
797
+ * visual recap should run, which (normalized) agent to use, and — when skipped —
798
+ * the human-readable reasons. This is the security boundary; it replicates the
799
+ * inline github-script gate bit-for-bit. No I/O so it can be unit-tested.
800
+ */
801
+ export function evaluateRecapGate(input) {
802
+ const { pr } = input;
803
+ const reasons = [];
804
+ if (!pr)
805
+ reasons.push("no pull_request payload");
806
+ if (pr && pr.draft)
807
+ reasons.push("draft PR");
808
+ // Fork PRs: head repo differs from this repo. Plain pull_request runs fork
809
+ // code with NO secrets, so publishing would fail anyway — skip.
810
+ const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;
811
+ if (pr && headRepo && headRepo !== input.repository) {
812
+ reasons.push(`fork PR (${headRepo})`);
813
+ }
814
+ // Skip noisy automated authors.
815
+ const login = ((pr && pr.user && pr.user.login) || "").toLowerCase();
816
+ const botAuthors = [
817
+ "dependabot[bot]",
818
+ "dependabot",
819
+ "renovate[bot]",
820
+ "renovate",
821
+ ];
822
+ if (botAuthors.includes(login))
823
+ reasons.push(`bot author (${login})`);
824
+ if (pr && pr.user && pr.user.type === "Bot")
825
+ reasons.push("bot author (type=Bot)");
826
+ // Publish secret must be configured — otherwise this is a no-op so the
827
+ // workflow can be merged before secrets exist.
828
+ if (!input.hasPlan)
829
+ reasons.push("PLAN_RECAP_TOKEN not configured");
830
+ // The chosen backend's API key must be present. Normalize the agent value once
831
+ // here and validate it: an unknown or mis-cased value (e.g. "Claude", "gpt")
832
+ // must NOT silently pass the gate and then match neither agent step.
833
+ const agent = (input.agentRaw || "claude").toLowerCase();
834
+ if (agent !== "claude" && agent !== "codex") {
835
+ reasons.push(`unsupported VISUAL_RECAP_AGENT "${input.agentRaw}" (expected "claude" or "codex")`);
836
+ }
837
+ else if (agent === "codex") {
838
+ if (!input.hasOpenai)
839
+ reasons.push("OPENAI_API_KEY not configured (codex backend)");
840
+ }
841
+ else {
842
+ if (!input.hasAnthropic)
843
+ reasons.push("ANTHROPIC_API_KEY not configured (claude backend)");
844
+ }
845
+ // Validate VISUAL_RECAP_MODEL if set — an unchecked value could be injected by
846
+ // a repo settings writer and passed straight to the agent CLI.
847
+ const model = input.model || "";
848
+ if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {
849
+ reasons.push("invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})");
850
+ }
851
+ // Self-modifying guard: if this PR changes the workflow, the
852
+ // visual-recap/visual-plan skill, the local CLI (packages/core), or any agent
853
+ // config the runner would load (.claude/**, CLAUDE.md, .mcp.json), skip the
854
+ // ENTIRE job — not just the agent — so a PR can never rewrite what runs
855
+ // (skill, hooks, settings, CLI) and exfiltrate the publish/API secrets.
856
+ const hits = input.changedFiles.filter(isRecapSensitivePath);
857
+ if (hits.length) {
858
+ 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
+ }
860
+ return { run: reasons.length === 0, agent, reasons };
861
+ }
862
+ /**
863
+ * Page through `GET /repos/{owner}/{repo}/pulls/{n}/files`, following the
864
+ * `Link` rel="next" header, and return every changed filename. Uses the same
865
+ * api.github.com base + auth headers as `githubRequest`; reads the `Link`
866
+ * header (which `githubRequest` discards) so it can paginate. Throws on any
867
+ * non-2xx so the caller can fail CLOSED — exactly like the inline gate did when
868
+ * `github.paginate(listFiles)` rejected.
869
+ */
870
+ async function listPullRequestFiles(input) {
871
+ const filenames = [];
872
+ let url = `https://api.github.com/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/pulls/${input.pull}/files?per_page=100`;
873
+ while (url) {
874
+ const res = await fetch(url, {
875
+ headers: {
876
+ accept: "application/vnd.github+json",
877
+ authorization: `Bearer ${input.token}`,
878
+ "x-github-api-version": "2022-11-28",
879
+ },
880
+ });
881
+ if (!res.ok) {
882
+ const detail = await res.text().catch(() => "");
883
+ throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${detail.slice(0, 500)}`);
884
+ }
885
+ const page = (await res.json());
886
+ for (const f of page) {
887
+ if (typeof f.filename === "string")
888
+ filenames.push(f.filename);
889
+ }
890
+ // Follow Link rel="next" for the next page; absent => done.
891
+ const link = res.headers.get("link") || "";
892
+ const next = link.match(/<([^>]+)>\s*;\s*rel="next"/);
893
+ url = next ? next[1] : null;
894
+ }
895
+ return filenames;
896
+ }
897
+ /**
898
+ * `recap gate` — the I/O wrapper around `evaluateRecapGate`. Reads the PR
899
+ * payload from GITHUB_EVENT_PATH, the secret-presence/agent/model signals from
900
+ * the environment, and the PR's changed files from the GitHub REST API (paged,
901
+ * with GH_TOKEN/GITHUB_TOKEN). Writes `run` + the normalized `agent` to
902
+ * $GITHUB_OUTPUT and logs the run/skip summary. Fails CLOSED on any file-list
903
+ * error so an untrusted PR can never run the agent with secrets.
904
+ */
905
+ async function runGate() {
906
+ const repository = process.env.GITHUB_REPOSITORY;
907
+ // Read the pull_request object out of the event payload, tolerating a
908
+ // missing/unreadable file (degrades to the "no pull_request payload" reason).
909
+ let pr = null;
910
+ const eventPath = process.env.GITHUB_EVENT_PATH;
911
+ if (eventPath) {
912
+ try {
913
+ const payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
914
+ pr = payload && payload.pull_request ? payload.pull_request : null;
915
+ }
916
+ catch {
917
+ pr = null;
918
+ }
919
+ }
920
+ // Fetch the PR's changed files for the self-modifying guard. Any error here is
921
+ // turned into a skip reason (fail-closed), mirroring the inline gate's
922
+ // try/catch around github.paginate(listFiles).
923
+ const changedFiles = [];
924
+ let fileListError = null;
925
+ if (pr && typeof pr.number === "number" && repository) {
926
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "";
927
+ try {
928
+ const { owner, repo } = repoParts(repository);
929
+ const files = await listPullRequestFiles({
930
+ token,
931
+ owner,
932
+ repo,
933
+ pull: pr.number,
934
+ });
935
+ changedFiles.push(...files);
936
+ }
937
+ catch (e) {
938
+ fileListError = e instanceof Error ? e.message : String(e);
939
+ }
940
+ }
941
+ const decision = evaluateRecapGate({
942
+ pr,
943
+ repository,
944
+ hasPlan: process.env.HAS_PLAN === "true",
945
+ hasAnthropic: process.env.HAS_ANTHROPIC === "true",
946
+ hasOpenai: process.env.HAS_OPENAI === "true",
947
+ agentRaw: process.env.AGENT,
948
+ model: process.env.VISUAL_RECAP_MODEL,
949
+ changedFiles,
950
+ });
951
+ // If listing PR files failed, append the same fail-closed reason the inline
952
+ // gate used and force run=false.
953
+ let { run } = decision;
954
+ const reasons = [...decision.reasons];
955
+ if (fileListError !== null) {
956
+ reasons.push(`could not list PR files for the self-modifying guard (${fileListError}); skipping to be safe`);
957
+ run = false;
958
+ }
959
+ // Preserve the github-script contract: write `run` + the NORMALIZED agent to
960
+ // $GITHUB_OUTPUT so the recap job's step conditions match case-insensitively.
961
+ const githubOutput = process.env.GITHUB_OUTPUT;
962
+ if (githubOutput) {
963
+ fs.appendFileSync(githubOutput, `run=${run ? "true" : "false"}\nagent=${decision.agent}\n`);
964
+ }
965
+ // eslint-disable-next-line no-console
966
+ console.log(run
967
+ ? `Visual recap will run (${decision.agent}).`
968
+ : `Visual recap skipped: ${reasons.join("; ")}`);
969
+ }
970
+ /* -------------------------------------------------------------------------- */
971
+ /* Check run — the "Visual Recap" GitHub check (was two inline github-script */
972
+ /* steps in the workflow's recap job). */
973
+ /* -------------------------------------------------------------------------- */
974
+ /**
975
+ * Canonicalize the agent-written plan URL into a trusted recap URL, or "".
976
+ *
977
+ * recap-url.txt is produced by the (LLM) agent, so the raw URL is untrusted.
978
+ * This rebuilds a canonical `${origin}${base}/recaps/<id>` link from the TRUSTED
979
+ * app URL plus a strictly-validated plan id, enforcing the app origin and
980
+ * honoring a path-prefixed mount (e.g. https://host/agent-native). Returns ""
981
+ * for a wrong origin or an unrecognized path. Pure so it can be unit-tested —
982
+ * SAME impl as the workflow's previous inline `canonicalRecapUrl`.
983
+ */
984
+ export function canonicalRecapUrl(rawUrl, appUrl) {
985
+ try {
986
+ const parsed = new URL(rawUrl);
987
+ const trusted = new URL(appUrl || "https://plan.agent-native.com");
988
+ if (parsed.origin !== trusted.origin)
989
+ return "";
990
+ // Honor a path-prefixed mount (e.g. https://host/agent-native): strip the
991
+ // trusted base path before matching /plans|recaps/<id>.
992
+ const base = trusted.pathname.replace(/\/$/, "");
993
+ let rest = parsed.pathname;
994
+ if (base && rest.startsWith(base))
995
+ rest = rest.slice(base.length);
996
+ const match = rest.match(/^\/(?:plans|recaps)\/([A-Za-z0-9_-]+)\/?$/);
997
+ return match ? `${trusted.origin}${base}/recaps/${match[1]}` : "";
998
+ }
999
+ catch {
1000
+ return "";
1001
+ }
1002
+ }
1003
+ /**
1004
+ * Map the workflow's terminal recap state to the completed check's
1005
+ * conclusion/title/summary/text/details_url. Pure so it can be unit-tested —
1006
+ * reproduces the workflow's previous inline branch logic EXACTLY:
1007
+ *
1008
+ * - default → neutral "Visual recap not generated"
1009
+ * - planOk + valid recapUrl → success "Visual recap ready" (huge → "summarized"
1010
+ * summary), Open-recap link as text, details_url = recapUrl
1011
+ * - planOk + invalid url → neutral "Visual recap published" (see the comment)
1012
+ * - else tiny → skipped "Visual recap skipped"
1013
+ * - else suppressed → skipped "Visual recap suppressed" (reason from scan JSON)
1014
+ */
1015
+ export function recapCheckOutcome(input) {
1016
+ let conclusion = "neutral";
1017
+ let title = "Visual recap not generated";
1018
+ let summary = "The visual recap did not produce a plan URL. This is informational only and does not block the PR.";
1019
+ let text = "";
1020
+ let detailsUrl = input.workflowUrl;
1021
+ if (input.planOk) {
1022
+ const recapUrl = canonicalRecapUrl(input.planUrl, input.appUrl);
1023
+ if (recapUrl) {
1024
+ conclusion = "success";
1025
+ title = "Visual recap ready";
1026
+ summary = input.huge
1027
+ ? "A summarized visual recap was generated for this large PR."
1028
+ : "A visual code-review recap was generated for this PR.";
1029
+ detailsUrl = recapUrl;
1030
+ text = `**[Open visual recap](${recapUrl})**`;
1031
+ }
1032
+ else {
1033
+ // Agent reported success but the URL didn't validate against the trusted
1034
+ // plan origin — don't claim "not generated"; the recap is linked in the
1035
+ // sticky comment.
1036
+ title = "Visual recap published";
1037
+ summary =
1038
+ "A recap was published; see the visual recap comment on this PR for the link.";
1039
+ }
1040
+ }
1041
+ else if (input.tiny) {
1042
+ conclusion = "skipped";
1043
+ title = "Visual recap skipped";
1044
+ summary = "The diff is too small to need a visual recap.";
1045
+ }
1046
+ else if (input.suppressed) {
1047
+ let reason = "potential secret in diff";
1048
+ try {
1049
+ const parsed = JSON.parse(input.suppressedJson || "{}");
1050
+ if (parsed && typeof parsed.reason === "string")
1051
+ reason = parsed.reason;
1052
+ }
1053
+ catch {
1054
+ // Keep the default reason.
1055
+ }
1056
+ conclusion = "skipped";
1057
+ title = "Visual recap suppressed";
1058
+ summary = `No recap was published because ${reason}.`;
1059
+ }
1060
+ return { conclusion, title, summary, text, detailsUrl };
1061
+ }
1062
+ function boolFlag(args, key) {
1063
+ return args[key] === true || args[key] === "true";
1064
+ }
1065
+ /**
1066
+ * `recap check start` — create the in-progress "Visual Recap" GitHub check run
1067
+ * and write its id to $GITHUB_OUTPUT (check_run_id). Best-effort: on any API
1068
+ * error, warn on stderr and exit 0 (don't fail the job) without emitting an id.
1069
+ * Replaces the workflow's inline "Start visual recap check" github-script step.
1070
+ */
1071
+ async function runCheckStart(args) {
1072
+ const repo = optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY ?? "";
1073
+ const sha = optionalArg(args, "sha") ?? process.env.HEAD_SHA ?? "";
1074
+ const token = optionalArg(args, "token") ||
1075
+ process.env.GH_TOKEN ||
1076
+ process.env.GITHUB_TOKEN ||
1077
+ "";
1078
+ const workflowUrl = optionalArg(args, "workflow-url") ?? "";
1079
+ const emit = (id) => {
1080
+ const githubOutput = process.env.GITHUB_OUTPUT;
1081
+ if (githubOutput) {
1082
+ fs.appendFileSync(githubOutput, `check_run_id=${id}\n`);
1083
+ }
1084
+ };
1085
+ try {
1086
+ const { owner, repo: name } = repoParts(repo);
1087
+ const created = await githubRequest(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/check-runs`, {
1088
+ method: "POST",
1089
+ headers: { "content-type": "application/json" },
1090
+ body: JSON.stringify({
1091
+ name: "Visual Recap",
1092
+ head_sha: sha,
1093
+ status: "in_progress",
1094
+ started_at: new Date().toISOString(),
1095
+ details_url: workflowUrl,
1096
+ output: {
1097
+ title: "Visual recap in progress",
1098
+ summary: "Generating a visual code-review recap for this pull request.",
1099
+ },
1100
+ }),
1101
+ });
1102
+ emit(String(created.id));
1103
+ }
1104
+ catch (err) {
1105
+ process.stderr.write(`[recap check] could not create Visual Recap check run: ${String(err)}\n`);
1106
+ // Best-effort: don't fail the job and don't emit a check_run_id.
1107
+ }
1108
+ }
1109
+ /**
1110
+ * `recap check complete` — PATCH the "Visual Recap" check run to completed with
1111
+ * the computed conclusion/title/summary/text/details_url. Best-effort: on any
1112
+ * API error, warn on stderr and exit 0. Replaces the workflow's inline
1113
+ * "Complete visual recap check" github-script step.
1114
+ */
1115
+ async function runCheckComplete(args) {
1116
+ const repo = optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY ?? "";
1117
+ const token = optionalArg(args, "token") ||
1118
+ process.env.GH_TOKEN ||
1119
+ process.env.GITHUB_TOKEN ||
1120
+ "";
1121
+ const checkRunId = optionalArg(args, "check-run-id") ?? "";
1122
+ const outcome = recapCheckOutcome({
1123
+ planOk: boolFlag(args, "plan-ok"),
1124
+ planUrl: optionalArg(args, "plan-url") ?? "",
1125
+ appUrl: optionalArg(args, "app-url") ?? process.env.PLAN_RECAP_APP_URL ?? "",
1126
+ huge: boolFlag(args, "huge"),
1127
+ tiny: boolFlag(args, "tiny"),
1128
+ suppressed: boolFlag(args, "suppressed"),
1129
+ suppressedJson: optionalArg(args, "suppressed-json") ?? "",
1130
+ workflowUrl: optionalArg(args, "workflow-url") ?? "",
1131
+ });
1132
+ try {
1133
+ const { owner, repo: name } = repoParts(repo);
1134
+ await githubRequest(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/check-runs/${encodeURIComponent(checkRunId)}`, {
1135
+ method: "PATCH",
1136
+ headers: { "content-type": "application/json" },
1137
+ body: JSON.stringify({
1138
+ status: "completed",
1139
+ conclusion: outcome.conclusion,
1140
+ completed_at: new Date().toISOString(),
1141
+ details_url: outcome.detailsUrl,
1142
+ output: {
1143
+ title: outcome.title,
1144
+ summary: outcome.summary,
1145
+ text: outcome.text,
1146
+ },
1147
+ }),
1148
+ });
1149
+ }
1150
+ catch (err) {
1151
+ process.stderr.write(`[recap check] could not update Visual Recap check run: ${String(err)}\n`);
1152
+ // Best-effort: don't fail the job.
1153
+ }
1154
+ }
1155
+ /** `recap check <start|complete>` dispatcher. */
1156
+ async function runCheck(args, sub) {
1157
+ if (sub === "start") {
1158
+ await runCheckStart(args);
1159
+ return;
1160
+ }
1161
+ if (sub === "complete") {
1162
+ await runCheckComplete(args);
1163
+ return;
1164
+ }
1165
+ throw new Error("Usage: agent-native recap check <start|complete> [flags] (see `recap help`)");
1166
+ }
557
1167
  /** Parse the last top-level JSON object from a possibly-noisy stdout dump. */
558
1168
  function parseLastJsonObject(text) {
559
1169
  const trimmed = text.trim();
@@ -728,16 +1338,47 @@ async function runUsage(args) {
728
1338
  const HELP = `agent-native recap — PR visual recap helpers (used by the GitHub Action)
729
1339
 
730
1340
  Usage:
1341
+ agent-native recap collect-diff --base <baseSha> --head <headSha> [--out recap.diff] [--stat recap.stat]
1342
+ agent-native recap mcp-config --agent claude|codex --app-url <url> [--out <path>]
731
1343
  agent-native recap scan --diff <path>
732
- agent-native recap build-prompt --pr <n> [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--prev-plan-id <id>] [--huge] [--out <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>]
733
1345
  agent-native recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png]
734
1346
  agent-native recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex] [--model <id>]
735
1347
  agent-native recap comment <find-plan-id|upsert> --repo owner/name --issue <n> --token <github-token>
1348
+ agent-native recap check start [--repo owner/name] [--sha <headSha>] [--token <github-token>] [--workflow-url <url>]
1349
+ Create the in-progress "Visual Recap" GitHub check run and write its id to
1350
+ $GITHUB_OUTPUT (check_run_id). repo/sha/token default to GITHUB_REPOSITORY /
1351
+ HEAD_SHA / GH_TOKEN (or GITHUB_TOKEN). Best-effort: warns and exits 0 on any
1352
+ API error without emitting an id.
1353
+ agent-native recap check complete --check-run-id <id> [--repo owner/name] [--token <github-token>] [--plan-ok <bool>] [--plan-url <url>] [--app-url <url>] [--suppressed <bool>] [--suppressed-json <json>] [--huge <bool>] [--tiny <bool>] [--workflow-url <url>]
1354
+ Mark the "Visual Recap" check run completed with a computed
1355
+ conclusion/title/summary/text/details_url (success when the agent published a
1356
+ plan whose URL validates against --app-url; neutral/skipped otherwise).
1357
+ repo/token/app-url default to GITHUB_REPOSITORY / GH_TOKEN / PLAN_RECAP_APP_URL.
1358
+ Best-effort: warns and exits 0 on any API error.
1359
+ agent-native recap gate
1360
+ The PR Visual Recap security gate. Decides whether to run the recap at all
1361
+ and which (normalized) backend agent to use. Reads the pull_request payload
1362
+ from $GITHUB_EVENT_PATH, the secret-presence/agent/model signals from the
1363
+ environment (HAS_PLAN / HAS_ANTHROPIC / HAS_OPENAI === 'true', AGENT,
1364
+ VISUAL_RECAP_MODEL), the repo from $GITHUB_REPOSITORY, and the PR's changed
1365
+ files from the GitHub REST API (paged, with GH_TOKEN/GITHUB_TOKEN). Skips
1366
+ drafts, forks, bot authors, the missing-secret case, an invalid agent/model,
1367
+ and any PR that touches recap-control files (the workflow, the skill,
1368
+ packages/core, .claude/**, CLAUDE.md, AGENTS.md, .mcp.json) — failing CLOSED
1369
+ on any file-list error. Writes run=<true|false> and agent=<claude|codex> to
1370
+ $GITHUB_OUTPUT.
736
1371
  `;
737
1372
  export async function runRecap(argv) {
738
1373
  const [sub, ...rest] = argv;
739
1374
  const args = parseArgs(rest);
740
1375
  switch (sub) {
1376
+ case "collect-diff":
1377
+ runCollectDiff(args);
1378
+ return;
1379
+ case "mcp-config":
1380
+ runMcpConfig(args);
1381
+ return;
741
1382
  case "scan":
742
1383
  runScan(args);
743
1384
  return;
@@ -753,6 +1394,12 @@ export async function runRecap(argv) {
753
1394
  case "comment":
754
1395
  await runComment(parseArgs(rest.slice(1)), rest[0] ?? "");
755
1396
  return;
1397
+ case "check":
1398
+ await runCheck(parseArgs(rest.slice(1)), rest[0] ?? "");
1399
+ return;
1400
+ case "gate":
1401
+ await runGate();
1402
+ return;
756
1403
  case "help":
757
1404
  case "--help":
758
1405
  case "-h":