@agent-native/recap-cli 0.5.44 → 0.5.46-nightly-20260927013707

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.
package/dist/recap.js CHANGED
@@ -1,38 +1,3 @@
1
- /**
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
- *
5
- * The action no longer generates the recap deterministically. Instead a coding
6
- * agent (Claude Code or Codex) RUNS THE REPO'S visual-recap skill against the
7
- * diff and publishes the plan via the plan MCP tools. These subcommands are the
8
- * thin, deterministic glue around that:
9
- *
10
- * gate The security boundary: decide whether the recap runs at all
11
- * (skipping drafts, forks without secret access, bots, missing
12
- * secrets, an invalid agent/model, and untrusted PRs that touch
13
- * recap-control files) and which 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
- * scan Refuse to hand a secret-leaking diff to the agent.
18
- * block-reference
19
- * Fetch the live get-plan-blocks reference for the target app.
20
- * build-prompt Assemble the agent prompt = latest visual-recap skill bundle
21
- * + a task wrapper (or repo-pinned skill with --skill-source).
22
- * publish Publish the agent-authored recap-source.json over HTTP.
23
- * shot Screenshot the published plan and upload it to the plan app's
24
- * signed public image route (for an inline PR-comment image).
25
- * usage Parse and emit agent token-usage/cost from stdout.
26
- * comment Find the previous plan id / upsert the sticky PR comment.
27
- * check Evaluate the recap result and set a GitHub commit status.
28
- * setup Install the PR Visual Recap GitHub Action workflow.
29
- * doctor Diagnose missing secrets / misconfigured workflow.
30
- *
31
- * Promoting these to the published CLI means an installed repo's workflow calls
32
- * `agent-native recap …` instead of copying helper scripts into the repo.
33
- *
34
- * Node built-ins only (plus an optional dynamic `playwright` import for `shot`).
35
- */
36
1
  import { execFileSync } from "node:child_process";
37
2
  import { createHash } from "node:crypto";
38
3
  import fs from "node:fs";
@@ -43,9 +8,6 @@ import { DEFAULT_PLAN_APP_URL, fetchPlanBlockCatalog, planActionEndpoint, } from
43
8
  import { readPlanPublishAuth } from "./plan-publish-store.js";
44
9
  import { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js";
45
10
  import { RECAP_REFERENCE_FILES, VISUAL_RECAP_SKILL_MD, } from "./skill-content.js";
46
- /* -------------------------------------------------------------------------- */
47
- /* Arg parsing */
48
- /* -------------------------------------------------------------------------- */
49
11
  function parseArgs(argv) {
50
12
  const out = {};
51
13
  for (let i = 0; i < argv.length; i += 1) {
@@ -74,10 +36,6 @@ function optionalArg(args, key) {
74
36
  const value = args[key];
75
37
  return typeof value === "string" && value.length > 0 ? value : undefined;
76
38
  }
77
- /* -------------------------------------------------------------------------- */
78
- /* GitHub Action install (used by `skills add … --with-github-action`) */
79
- /* -------------------------------------------------------------------------- */
80
- /** GitHub secrets the installed PR Visual Recap workflow needs. */
81
39
  export const PR_VISUAL_RECAP_SETUP = [
82
40
  "Required secrets:",
83
41
  " PLAN_RECAP_TOKEN — bearer token from `npx @agent-native/core@latest connect`",
@@ -94,7 +52,6 @@ export const PR_VISUAL_RECAP_SETUP = [
94
52
  " VISUAL_RECAP_SECRET_SCAN=off|high-confidence|strict (variable) — default high-confidence; strict restores generic TOKEN/SECRET assignment suppression",
95
53
  " PLAN_RECAP_APP_URL (secret) — only when self-hosting the plan app (defaults to https://plan.agent-native.com)",
96
54
  ];
97
- /** Write .github/workflows/pr-visual-recap.yml into a repo. */
98
55
  export function writePrVisualRecapWorkflow(baseDir, options = {}) {
99
56
  const dir = path.resolve(baseDir, ".github", "workflows");
100
57
  fs.mkdirSync(dir, { recursive: true });
@@ -118,22 +75,6 @@ export function writePrVisualRecapWorkflow(baseDir, options = {}) {
118
75
  fs.writeFileSync(file, PR_VISUAL_RECAP_WORKFLOW_YML);
119
76
  return { status: "written", path: rel, existed: false };
120
77
  }
121
- /* -------------------------------------------------------------------------- */
122
- /* Reusable-workflow installer */
123
- /* -------------------------------------------------------------------------- */
124
- /**
125
- * The thin caller workflow that consumers paste into their repo when using the
126
- * reusable variant. It references the canonical reusable workflow in the
127
- * BuilderIO/agent-native repo rather than carrying a full copy.
128
- *
129
- * Callers must trigger on the same `pull_request` event types so that
130
- * `github.event.pull_request.*` expressions in the reusable workflow resolve
131
- * correctly (workflow_call inherits the caller's event context). The `labeled`
132
- * event lets required-label configurations run as soon as a maintainer opts in.
133
- *
134
- * @param options.cliVersion Semver or tag to pin (default "main" / latest).
135
- * @param options.ref Git ref to pin the reusable workflow to (default "@main").
136
- */
137
78
  export function buildReusableCallerWorkflow(options = {}) {
138
79
  const ref = (options.ref ?? "main").replace(/^@/, "");
139
80
  const agentValue = options.agent ?? "${{ vars.VISUAL_RECAP_AGENT || 'claude' }}";
@@ -194,9 +135,7 @@ export function buildReusableCallerWorkflow(options = {}) {
194
135
  ` # Pin recap-cli and core-cli independently when using the openai-compatible backend.\n` +
195
136
  ``);
196
137
  }
197
- /** File name for the reusable caller workflow. */
198
138
  const REUSABLE_CALLER_WORKFLOW_FILE = "pr-visual-recap.yml";
199
- /** Write the thin caller workflow that references the reusable workflow. */
200
139
  export function writePrVisualRecapReusableCallerWorkflow(baseDir, options = {}) {
201
140
  const dir = path.resolve(baseDir, ".github", "workflows");
202
141
  fs.mkdirSync(dir, { recursive: true });
@@ -482,7 +421,6 @@ const RECAP_GATE_RUNS_ON_REQUIREMENT = {
482
421
  name: "VISUAL_RECAP_GATE_RUNS_ON",
483
422
  example: "visual-recap-gate",
484
423
  };
485
- /** Parse the JSON consumed by GitHub Actions `fromJSON(...)` for `runs-on`. */
486
424
  export function parseRecapRunsOn(value) {
487
425
  let parsed;
488
426
  try {
@@ -519,7 +457,6 @@ export function parseRecapRunsOn(value) {
519
457
  }
520
458
  return { json: JSON.stringify(labels), labels, selfHosted: true };
521
459
  }
522
- /** Validate the plain label used directly by the gate job's `runs-on`. */
523
460
  export function parseRecapGateRunsOn(value) {
524
461
  const label = value.trim();
525
462
  if (!/^[A-Za-z0-9._-]{1,100}$/.test(label)) {
@@ -667,7 +604,6 @@ function runSetup(args) {
667
604
  const dryRun = flagArg(args, "dry-run");
668
605
  const force = flagArg(args, "force");
669
606
  const skipSecrets = flagArg(args, "skip-secrets");
670
- // --reusable writes the thin caller workflow instead of the full copy.
671
607
  const reusable = flagArg(args, "reusable");
672
608
  const repo = resolveGithubRepo(optionalArg(args, "repo"));
673
609
  const plan = buildRecapSetupPlan({
@@ -972,7 +908,6 @@ function runDoctor(args) {
972
908
  * contains harmless variable references like `var.webhook_token`.
973
909
  */
974
910
  const HIGH_CONFIDENCE_SECRET_PATTERNS = [
975
- // Common provider key prefixes.
976
911
  /\bsk-(?:proj-)?[A-Za-z0-9_-]{24,}\b/,
977
912
  /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/,
978
913
  /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/,
@@ -983,16 +918,11 @@ const HIGH_CONFIDENCE_SECRET_PATTERNS = [
983
918
  /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
984
919
  /\bAKIA[0-9A-Z]{16}\b/,
985
920
  /\bAIza[0-9A-Za-z_-]{20,}\b/,
986
- // Bearer / Authorization header values with an actual token.
987
921
  /authorization\s*[:=]\s*['"]?bearer\s+[A-Za-z0-9._-]{20,}/i,
988
- // Private key blocks.
989
922
  /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/,
990
923
  ];
991
924
  const STRICT_SECRET_PATTERNS = [
992
925
  ...HIGH_CONFIDENCE_SECRET_PATTERNS,
993
- // Strict mode only: `KEY=...`, `TOKEN=...`, `SECRET=...`, `PASSWORD=...`
994
- // assigned a real-looking value. This is intentionally not the default; it
995
- // has produced too many false positives on variable names and CLI flags.
996
926
  /\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*\s*[:=]\s*['"]?(?!.*(?:your|example|placeholder|changeme|xxxx|\*\*\*|<|\$\{|process\.env|env\.|REDACTED))[A-Za-z0-9/_+=.-]{16,}/i,
997
927
  ];
998
928
  export function normalizeRecapSecretScanMode(value) {
@@ -1013,14 +943,6 @@ function secretPatternsForMode(mode) {
1013
943
  export function lineLooksSecret(line, mode = "high-confidence") {
1014
944
  return secretPatternsForMode(mode).some((re) => re.test(line));
1015
945
  }
1016
- /**
1017
- * Parse a `.github/recap-scan-allowlist` file into a list of matchers.
1018
- * Each non-blank, non-comment line is either:
1019
- * - a `/regex/` literal (JS regex syntax) — matched against the full line
1020
- * - a plain literal string — checked with String.includes()
1021
- *
1022
- * Returns an empty array when the file is absent or empty.
1023
- */
1024
946
  export function parseRecapScanAllowlist(allowlistPath) {
1025
947
  let text;
1026
948
  try {
@@ -1042,7 +964,6 @@ export function parseRecapScanAllowlist(allowlistPath) {
1042
964
  matchers.push(new RegExp(pattern, flags));
1043
965
  }
1044
966
  catch {
1045
- // Malformed regex — treat as a literal string for safety.
1046
967
  matchers.push(line);
1047
968
  }
1048
969
  }
@@ -1052,10 +973,6 @@ export function parseRecapScanAllowlist(allowlistPath) {
1052
973
  }
1053
974
  return matchers;
1054
975
  }
1055
- /**
1056
- * Return true when `line` matches ANY entry in the allowlist (i.e., the
1057
- * finding should be ignored).
1058
- */
1059
976
  export function lineMatchesAllowlist(line, allowlist) {
1060
977
  for (const entry of allowlist) {
1061
978
  if (typeof entry === "string") {
@@ -1288,72 +1205,34 @@ export function summarizeLocalAgentFailure(input = {}) {
1288
1205
  }
1289
1206
  return "";
1290
1207
  }
1291
- /* -------------------------------------------------------------------------- */
1292
- /* Bounded diff collection — was the workflow's "Collect bounded diff" step */
1293
- /* -------------------------------------------------------------------------- */
1294
- /** ~600KB byte cap for the diff handed to the recap agent. */
1295
1208
  export const RECAP_DIFF_BYTE_CAP = 614400;
1296
- /** The footer appended when a diff is truncated at the byte cap. */
1297
1209
  export const RECAP_DIFF_TRUNCATED_FOOTER = "\n\n[diff truncated at 600KB for the recap agent]\n";
1298
- /**
1299
- * The pathspecs the bounded diff excludes — lockfiles, build output, and
1300
- * snapshots are noise for a visual recap. Kept as array args (not a shell
1301
- * string) so the `:(exclude)` pathspecs are never mangled by a shell.
1302
- */
1303
1210
  const RECAP_DIFF_PATHSPECS = [
1304
1211
  ".",
1305
1212
  ":(exclude)pnpm-lock.yaml",
1306
1213
  ":(exclude)**/dist/**",
1307
1214
  ":(exclude)**/*.snap",
1308
1215
  ":(exclude)**/*.lock",
1309
- // Common non-pnpm lockfiles (bun.lock covered by *.lock above; bun.lockb is
1310
- // binary and not glob-catchable by the *.lock pattern).
1311
1216
  ":(exclude)**/package-lock.json",
1312
1217
  ":(exclude)**/bun.lockb",
1313
- // Generated build output dirs that are sometimes checked in.
1314
1218
  ":(exclude)**/.next/**",
1315
- // Minified and source-map files — unhelpful noise in any diff.
1316
1219
  ":(exclude)**/*.min.js",
1317
1220
  ":(exclude)**/*.min.css",
1318
1221
  ":(exclude)**/*.map",
1319
1222
  ];
1320
- /**
1321
- * Classify a bounded diff into the `huge` / `tiny` flags the workflow consumes.
1322
- *
1323
- * - huge: BYTES over the ~600KB cap. The agent is told to summarize AND the
1324
- * diff file is physically truncated so it can't overflow the prompt budget.
1325
- * - tiny: <= 1 changed file AND <= 8 changed lines. Uses ORIGINAL line count
1326
- * (captured before any truncation) so a large diff is never misclassified as
1327
- * tiny after the byte cap drops most of its lines.
1328
- *
1329
- * Pure (no I/O) so the classification can be unit-tested without invoking git.
1330
- */
1331
1223
  export function classifyDiff(input) {
1332
1224
  return {
1333
1225
  huge: input.bytes > RECAP_DIFF_BYTE_CAP,
1334
1226
  tiny: input.changed <= 1 && input.originalLines <= 8,
1335
1227
  };
1336
1228
  }
1337
- /**
1338
- * Reorder a unified diff's per-file segments so likely-noise paths (paths whose
1339
- * first component starts with `.`, e.g. `.changeset/`, `.github/`) sort LAST,
1340
- * and all other paths keep their original git order. This ensures that when
1341
- * `truncateDiffAtLineBoundary` drops the tail to stay under the byte cap, source
1342
- * files survive and dotfile dirs are sacrificed instead.
1343
- *
1344
- * Pure (string in → string out) for unit testing. The initial preamble (lines
1345
- * before the first `diff --git` header) is preserved unchanged.
1346
- */
1347
1229
  export function sortDiffSourceFirst(text) {
1348
- // Split into segments on "diff --git …" headers.
1349
1230
  const HEADER = /^diff --git /m;
1350
1231
  const firstHeader = text.search(HEADER);
1351
1232
  if (firstHeader < 0)
1352
- return text; // no file segments — unchanged
1233
+ return text;
1353
1234
  const preamble = text.slice(0, firstHeader);
1354
1235
  const body = text.slice(firstHeader);
1355
- // Split into chunks: each chunk starts with "diff --git …" and ends just
1356
- // before the next "diff --git …" or at EOF.
1357
1236
  const chunks = [];
1358
1237
  let remaining = body;
1359
1238
  while (remaining.length > 0) {
@@ -1365,8 +1244,6 @@ export function sortDiffSourceFirst(text) {
1365
1244
  chunks.push(remaining.slice(0, next + 1));
1366
1245
  remaining = remaining.slice(next + 1);
1367
1246
  }
1368
- // Determine whether a chunk's path is "dotfile-prefixed" (first component
1369
- // starts with "."). Extract the path from the diff --git header line.
1370
1247
  function isDotfilePrefixed(chunk) {
1371
1248
  const m = chunk.match(/^diff --git a\/([^\s]+)/);
1372
1249
  if (!m)
@@ -1386,30 +1263,14 @@ export function sortDiffSourceFirst(text) {
1386
1263
  }
1387
1264
  return preamble + [...source, ...dotfile].join("");
1388
1265
  }
1389
- /**
1390
- * Truncate a diff to the ~600KB byte cap at a COMPLETE LINE boundary, then
1391
- * append the truncated footer. Dropping the last (possibly-partial) line is the
1392
- * equivalent of the original `head -c 614400 | sed '$d'`: it guarantees the cap
1393
- * never cuts a multi-byte UTF-8 char or a diff line mid-way and corrupts the
1394
- * agent's input. Pure (string in, string out) so it can be unit-tested.
1395
- */
1396
1266
  export function truncateDiffAtLineBoundary(text) {
1397
1267
  const capped = Buffer.from(text, "utf8")
1398
1268
  .subarray(0, RECAP_DIFF_BYTE_CAP)
1399
1269
  .toString("utf8");
1400
1270
  const lastNewline = capped.lastIndexOf("\n");
1401
- // Drop everything after the last newline (the last, possibly-partial line),
1402
- // mirroring `sed '$d'`. If there is no newline at all, drop the whole partial
1403
- // line (empty body) — the footer still makes the truncation explicit.
1404
1271
  const body = lastNewline >= 0 ? capped.slice(0, lastNewline) : "";
1405
1272
  return body + RECAP_DIFF_TRUNCATED_FOOTER;
1406
1273
  }
1407
- /**
1408
- * Count lines that begin with `+` or `-` (added/removed diff lines), excluding
1409
- * the `+++ b/file` / `--- a/file` unified-diff header lines. Without this
1410
- * exclusion a single-file change loses ~2 "real" lines from the 8-line tiny
1411
- * threshold, incorrectly classifying a small-but-meaningful change as tiny.
1412
- */
1413
1274
  export function countDiffLines(diffText) {
1414
1275
  let count = 0;
1415
1276
  for (const line of diffText.split("\n")) {
@@ -1420,16 +1281,6 @@ export function countDiffLines(diffText) {
1420
1281
  }
1421
1282
  return count;
1422
1283
  }
1423
- /**
1424
- * Run `git diff <base>...<head> -- <pathspecs>` and return its stdout plus a
1425
- * `failed` flag. A non-zero exit that still produces stdout is treated as a
1426
- * partial result (same as the original `... || true`). A non-zero exit with
1427
- * empty stdout is a genuine failure (broken ref, missing object, etc.) and
1428
- * sets `failed: true` so `runCollectDiff` can exit with a distinct error
1429
- * instead of silently classifying the empty output as a tiny diff.
1430
- *
1431
- * Array args — NOT a shell string — so the `:(exclude)` pathspecs survive.
1432
- */
1433
1284
  function gitDiffRaw(base, head, extraArgs) {
1434
1285
  const args = [
1435
1286
  "diff",
@@ -1447,34 +1298,19 @@ function gitDiffRaw(base, head, extraArgs) {
1447
1298
  return { stdout, failed: false };
1448
1299
  }
1449
1300
  catch (err) {
1450
- // Recover whatever stdout git wrote before failing.
1451
1301
  const raw = err && typeof err.stdout === "string"
1452
1302
  ? err.stdout
1453
1303
  : err && Buffer.isBuffer(err.stdout)
1454
1304
  ? err.stdout.toString("utf8")
1455
1305
  : "";
1456
- // An empty stdout from a non-zero exit means a broken ref / missing
1457
- // object — not a legitimate empty diff. Signal failure.
1458
1306
  return { stdout: raw, failed: raw.trim() === "" };
1459
1307
  }
1460
1308
  }
1461
- /**
1462
- * `recap collect-diff` — the bounded-diff collection that used to be ~60 lines
1463
- * of inline bash. Writes recap.diff + recap.stat, classifies huge/tiny, and
1464
- * emits the same `bytes/changed/huge/tiny` outputs the workflow expects:
1465
- * appended to $GITHUB_OUTPUT when set, AND printed as JSON to stdout (so it runs
1466
- * and is testable outside GitHub Actions).
1467
- *
1468
- * Exits non-zero when git itself fails (broken SHA / missing object) so the
1469
- * CI workflow treats it as a real failure instead of silently classifying an
1470
- * empty diff as "tiny" and skipping the recap with no diagnostic.
1471
- */
1472
1309
  function runCollectDiff(args) {
1473
1310
  const base = stringArg(args, "base");
1474
1311
  const head = stringArg(args, "head");
1475
1312
  const outPath = optionalArg(args, "out") ?? "recap.diff";
1476
1313
  const statPath = optionalArg(args, "stat") ?? "recap.stat";
1477
- // The unified diff and the --stat summary (both excluding lockfiles/noise).
1478
1314
  const diffResult = gitDiffRaw(base, head, []);
1479
1315
  if (diffResult.failed) {
1480
1316
  process.stderr.write(`recap collect-diff: git diff failed for ${base}...${head} — ` +
@@ -1486,39 +1322,23 @@ function runCollectDiff(args) {
1486
1322
  let diff = diffResult.stdout;
1487
1323
  const stat = gitDiffRaw(base, head, ["--stat"]).stdout;
1488
1324
  fs.writeFileSync(path.resolve(statPath), stat);
1489
- // ORIGINAL line count — captured BEFORE any byte-cap truncation so a large
1490
- // diff is never misclassified as tiny after truncation.
1491
1325
  const originalLines = countDiffLines(diff);
1492
- // Changed-file count from `--name-only` over the same excludes.
1493
1326
  const names = gitDiffRaw(base, head, ["--name-only"]).stdout;
1494
1327
  const changed = names.split("\n").filter((line) => line.length > 0).length;
1495
- // Write the (possibly truncated) diff and compute the on-disk byte length.
1496
1328
  const bytesBefore = Buffer.byteLength(diff, "utf8");
1497
1329
  const { huge } = classifyDiff({ bytes: bytesBefore, changed, originalLines });
1498
1330
  if (huge) {
1499
- // Reorder file segments so source dirs come before dotfile dirs, then
1500
- // truncate. This ensures the cap sacrifices .changeset/.github noise rather
1501
- // than src/templates files.
1502
1331
  diff = truncateDiffAtLineBoundary(sortDiffSourceFirst(diff));
1503
1332
  }
1504
1333
  fs.writeFileSync(path.resolve(outPath), diff);
1505
1334
  const bytes = fs.statSync(path.resolve(outPath)).size;
1506
1335
  const { tiny } = classifyDiff({ bytes: bytesBefore, changed, originalLines });
1507
- // Preserve the existing steps.diff.outputs.{bytes,changed,huge,tiny} contract.
1508
1336
  const githubOutput = process.env.GITHUB_OUTPUT;
1509
1337
  if (githubOutput) {
1510
1338
  fs.appendFileSync(githubOutput, `bytes=${bytes}\nchanged=${changed}\nhuge=${huge}\ntiny=${tiny}\n`);
1511
1339
  }
1512
1340
  process.stdout.write(`${JSON.stringify({ bytes, changed, huge, tiny })}\n`);
1513
1341
  }
1514
- /* -------------------------------------------------------------------------- */
1515
- /* Prompt builder — repo SKILL.md + task wrapper */
1516
- /* -------------------------------------------------------------------------- */
1517
- /**
1518
- * Locate the repo's visual-recap SKILL.md, preferring the host-agent install
1519
- * locations so a user's `agent-native skills add` copy wins, then falling back
1520
- * to the framework's own source locations.
1521
- */
1522
1342
  export function readRepoSkillMd(cwd = process.cwd()) {
1523
1343
  const candidates = [
1524
1344
  ".claude/skills/visual-recap/SKILL.md",
@@ -1589,8 +1409,6 @@ export function readVisualRecapSkillBundle(cwd = process.cwd(), mode = "auto") {
1589
1409
  }
1590
1410
  export function buildRecapPrompt(input) {
1591
1411
  const localDir = input.localDir ?? path.join("plans", `pr-${input.pr}-visual-recap`);
1592
- // Deterministically derive the PR back-link URL so the agent doesn't have to
1593
- // guess it. Use an explicit override when provided, else build from repo+pr.
1594
1412
  const lines = [];
1595
1413
  lines.push(input.localFiles
1596
1414
  ? "# Task: create a DB-free local Visual Recap of this pull request"
@@ -1664,9 +1482,6 @@ export function buildRecapPrompt(input) {
1664
1482
  lines.push("");
1665
1483
  return lines.join("\n");
1666
1484
  }
1667
- /* -------------------------------------------------------------------------- */
1668
- /* GitHub comment helpers */
1669
- /* -------------------------------------------------------------------------- */
1670
1485
  const MARKER = "<!-- pr-visual-recap -->";
1671
1486
  const RECAP_IMAGE_URL_PATH_PATTERN = /\/_agent-native\/recap-image\/[0-9a-f]{32,128}\.png$/;
1672
1487
  const RECAP_IMAGE_CACHE_QUERY_PARAM = "v";
@@ -1780,8 +1595,6 @@ export async function upsertComment(input) {
1780
1595
  : `${MARKER}\n${input.body}`;
1781
1596
  const existing = await findExistingComment({ ...input, fetchFn: fn });
1782
1597
  if (!existing && input.updateOnly) {
1783
- // Nothing to refresh and we were told not to create — e.g. a tiny diff with
1784
- // no prior recap. Stay silent rather than posting a "skipped" comment.
1785
1598
  return { action: "skipped", id: 0 };
1786
1599
  }
1787
1600
  if (existing) {
@@ -1800,12 +1613,9 @@ export async function upsertComment(input) {
1800
1613
  return { action: "created", id: created.id, html_url: created.html_url };
1801
1614
  }
1802
1615
  function planIdFromUrl(url) {
1803
- // Accept both /recaps/<id> (the canonical recap route the agent now writes)
1804
- // and /plans/<id> (legacy URLs) so the sticky-comment rebuild keeps working.
1805
1616
  const match = url.match(/\/(?:recaps|plans)\/([A-Za-z0-9_-]+)/);
1806
1617
  return match ? match[1] : null;
1807
1618
  }
1808
- /** True when both URLs parse and share an origin. */
1809
1619
  function sameOrigin(a, b) {
1810
1620
  try {
1811
1621
  return new URL(a).origin === new URL(b).origin;
@@ -1814,7 +1624,6 @@ function sameOrigin(a, b) {
1814
1624
  return false;
1815
1625
  }
1816
1626
  }
1817
- /** The origin of a URL, or "" if it doesn't parse. */
1818
1627
  function originOf(url) {
1819
1628
  try {
1820
1629
  return new URL(url).origin;
@@ -1874,17 +1683,12 @@ function trustedRecapImageUrl(raw, base) {
1874
1683
  return "";
1875
1684
  }
1876
1685
  }
1877
- /** Build the sticky comment body from the workflow's environment. */
1878
1686
  export function buildCommentBody(env = process.env) {
1879
1687
  const lines = [MARKER];
1880
1688
  const headSha = (env.HEAD_SHA || "").trim();
1881
1689
  const headMarker = /^[a-f0-9]{7,64}$/i.test(headSha)
1882
1690
  ? `<!-- head-sha: ${headSha} -->`
1883
1691
  : "";
1884
- // Last-known plan id threaded from the previous run (supplied via PREV_PLAN_ID
1885
- // when the comment is rebuilt from scratch, or parsed from the env on upsert).
1886
- // We always emit the plan-id marker when any plan id is known so that a
1887
- // transient failure does not orphan the plan.
1888
1692
  const prevPlanId = (env.PREV_PLAN_ID || "").trim() || null;
1889
1693
  if (env.SUPPRESSED === "true") {
1890
1694
  let reason = "high-confidence secret in diff";
@@ -1909,20 +1713,10 @@ export function buildCommentBody(env = process.env) {
1909
1713
  }
1910
1714
  const planUrl = (env.PLAN_URL || "").trim();
1911
1715
  const appUrl = (env.PLAN_RECAP_APP_URL || "").trim();
1912
- // recap-url.txt is agent-written → untrusted. Rebuild a canonical link from a
1913
- // TRUSTED base (the configured PLAN_RECAP_APP_URL when set, else the parsed
1914
- // origin of the plan URL) plus a strictly-validated plan id, instead of
1915
- // embedding the raw URL. That both enforces the app origin and prevents
1916
- // markdown injection — a same-origin URL with a crafted path/query could
1917
- // otherwise break out of the markdown link.
1918
1716
  const planId = planUrl ? planIdFromUrl(planUrl) : null;
1919
1717
  const sameOriginOk = appUrl === "" || sameOrigin(planUrl, appUrl);
1920
1718
  const base = (appUrl || originOf(planUrl)).replace(/\/$/, "");
1921
1719
  const safeUrl = planId && base && sameOriginOk ? `${base}/recaps/${planId}` : "";
1922
- // The plan id to embed in the marker — prefer the freshly-published one when
1923
- // the origin is trusted, fall back to the previous run's id so the next push
1924
- // can still replace in-place. Never use a plan id extracted from a bad-origin
1925
- // URL as the marker (it would mask the last-good known id).
1926
1720
  const trustedPlanId = planId && sameOriginOk ? planId : null;
1927
1721
  const markerPlanId = trustedPlanId ?? prevPlanId;
1928
1722
  if (!safeUrl) {
@@ -2000,14 +1794,10 @@ export function buildCommentBody(env = process.env) {
2000
1794
  lines.push("", headMarker);
2001
1795
  return lines.join("\n");
2002
1796
  }
2003
- /* -------------------------------------------------------------------------- */
2004
- /* Subcommands */
2005
- /* -------------------------------------------------------------------------- */
2006
1797
  function runScan(args) {
2007
1798
  const diffPath = stringArg(args, "diff");
2008
1799
  const diffText = fs.readFileSync(path.resolve(diffPath), "utf8");
2009
1800
  const mode = normalizeRecapSecretScanMode(optionalArg(args, "mode") ?? process.env.VISUAL_RECAP_SECRET_SCAN);
2010
- // Load the optional consumer-repo allowlist to suppress known false positives.
2011
1801
  const allowlistPath = optionalArg(args, "allowlist") ??
2012
1802
  path.join(process.cwd(), ".github", "recap-scan-allowlist");
2013
1803
  const allowlist = parseRecapScanAllowlist(allowlistPath);
@@ -2032,9 +1822,6 @@ function runBuildPrompt(args) {
2032
1822
  }
2033
1823
  const skill = readVisualRecapSkillBundle(process.cwd(), skillSource);
2034
1824
  const diffPath = optionalArg(args, "diff") ?? "recap.diff";
2035
- // Read the on-disk diff so we can compute byte/line counts for the consumption
2036
- // instruction. Best-effort — if the file is absent (e.g. local-files mode
2037
- // without a pre-collected diff) we skip the size instruction.
2038
1825
  let diffBytes;
2039
1826
  let diffLines;
2040
1827
  try {
@@ -2312,14 +2099,7 @@ function recapUrlFromPublishResult(result, appUrl) {
2312
2099
  return "";
2313
2100
  }
2314
2101
  function shouldRetryRecapPublish(status) {
2315
- return (
2316
- // The create-visual-recap route can transiently 404 during a plan-app
2317
- // deploy: the recap CLI ships to npm independently of the plan server, so a
2318
- // recap can run after the new CLI is live but before the matching action
2319
- // route has fully propagated to every (cold-start) server instance. A
2320
- // bounded retry rides through that propagation window instead of failing
2321
- // the whole recap.
2322
- status === 404 ||
2102
+ return (status === 404 ||
2323
2103
  status === 408 ||
2324
2104
  status === 409 ||
2325
2105
  status === 425 ||
@@ -2575,17 +2355,6 @@ function delay(ms) {
2575
2355
  ? new Promise((resolve) => setTimeout(resolve, ms))
2576
2356
  : Promise.resolve();
2577
2357
  }
2578
- /**
2579
- * Confirm GitHub can fetch the uploaded image anonymously before we embed it.
2580
- *
2581
- * Default budget: 8 attempts with capped exponential backoff (1s, 2s, 3s, …
2582
- * capped at 4s) → ~20s total. This is enough to survive a cold-start CDN
2583
- * propagation delay that would otherwise cause `uploadRecapImage` to return a
2584
- * URL that the GitHub PR comment can't display.
2585
- *
2586
- * The `attempts` and `delayMs` overrides remain for unit tests and for callers
2587
- * that need a tighter or looser budget.
2588
- */
2589
2358
  export async function waitForPublicRecapImage(input) {
2590
2359
  const attempts = Math.max(1, input.attempts ?? 8);
2591
2360
  const delayMs = Math.max(0, input.delayMs ?? 1000);
@@ -2613,7 +2382,6 @@ export async function waitForPublicRecapImage(input) {
2613
2382
  }
2614
2383
  return false;
2615
2384
  }
2616
- /** Upload a PNG to the plan app's signed public image route; returns its URL. */
2617
2385
  export async function uploadRecapImage(input) {
2618
2386
  const fetchFn = input.fetchFn ?? fetch;
2619
2387
  const waitFn = input.waitFn ?? waitForPublicRecapImage;
@@ -2628,9 +2396,6 @@ export async function uploadRecapImage(input) {
2628
2396
  },
2629
2397
  body: bytes,
2630
2398
  });
2631
- // Surface failures on stderr — stdout carries the machine-readable JSON the
2632
- // workflow parses, so it must stay clean. A silent null here is exactly what
2633
- // made the missing-inline-thumbnail failure undebuggable from CI logs.
2634
2399
  if (!res.ok) {
2635
2400
  const detail = await res.text().catch(() => "");
2636
2401
  process.stderr.write(`[recap shot] image upload failed: ${res.status} ${res.statusText} ${detail.slice(0, 300)}\n`);
@@ -2656,7 +2421,6 @@ export async function uploadRecapImage(input) {
2656
2421
  return null;
2657
2422
  }
2658
2423
  }
2659
- /** Mirrors RECAP_IMAGE_MAX_BYTES on the server — the route rejects larger PNGs. */
2660
2424
  const RECAP_SHOT_MAX_BYTES = 5 * 1024 * 1024;
2661
2425
  const RECAP_SHOT_WIDTH = 950;
2662
2426
  const RECAP_SHOT_MAX_HEIGHT = 2000;
@@ -2669,32 +2433,12 @@ const RECAP_DOCUMENT_SELECTOR = "[data-plan-document]";
2669
2433
  const RECAP_DOCUMENT_WAIT_TIMEOUT = 30_000;
2670
2434
  const RECAP_DOCUMENT_LOAD_ATTEMPTS = 2;
2671
2435
  const RECAP_SHOT_HARD_TIMEOUT = RECAP_DOCUMENT_WAIT_TIMEOUT * RECAP_DOCUMENT_LOAD_ATTEMPTS + 30_000;
2672
- /**
2673
- * Identity shim for esbuild's `__name` helper, injected into the browser before
2674
- * any screenshot init script or `page.evaluate` payload.
2675
- *
2676
- * esbuild/tsx `keepNames` (on by default) rewrites a named inner function — e.g.
2677
- * `const readHeights = (…) => {…}` inside a `page.evaluate` callback — into
2678
- * `__name(() => {…}, "readHeights")`. Playwright serializes that callback with
2679
- * `Function.prototype.toString` and runs it in the page, where `__name` does not
2680
- * exist, throwing `ReferenceError: __name is not defined` and silently dropping
2681
- * the recap's inline PR-comment screenshot. CI's trusted-workspace path runs this
2682
- * CLI through `tsx` (esbuild), so it fires there even though the published
2683
- * published package never emits `__name`. Defining `__name` as an identity
2684
- * function (esbuild's helper returns the target unchanged) makes every main-world
2685
- * payload safe regardless of how the CLI was transpiled. Kept as a raw string so
2686
- * esbuild can't rewrite the shim itself.
2687
- */
2688
2436
  const RECAP_SHOT_NAME_SHIM = "globalThis.__name = globalThis.__name || function (value) { return value; };";
2689
2437
  async function defaultImportPlaywright() {
2690
2438
  try {
2691
2439
  return (await import("playwright"));
2692
2440
  }
2693
2441
  catch (err) {
2694
- // `@playwright/test` is an undeclared courtesy fallback for consumers that
2695
- // only have the test runner. Rethrow the `playwright` failure when it also
2696
- // misses, so a broken-but-present `playwright` is never reported as a
2697
- // missing `@playwright/test`.
2698
2442
  try {
2699
2443
  return (await import("@playwright/test"));
2700
2444
  }
@@ -2837,9 +2581,6 @@ importPlaywright = defaultImportPlaywright) {
2837
2581
  deviceScaleFactor: RECAP_SHOT_DEVICE_SCALE_FACTOR,
2838
2582
  ...(theme ? { colorScheme: theme } : {}),
2839
2583
  });
2840
- // Must run before the theme init script and every page.evaluate below so
2841
- // esbuild/tsx `keepNames` wrappers don't throw `__name is not defined` in
2842
- // the browser (see RECAP_SHOT_NAME_SHIM).
2843
2584
  await context.addInitScript(RECAP_SHOT_NAME_SHIM);
2844
2585
  if (theme) {
2845
2586
  await context.addInitScript(({ background, nextTheme }) => {
@@ -2867,10 +2608,6 @@ importPlaywright = defaultImportPlaywright) {
2867
2608
  }, { background: recapScreenshotBackground(theme), nextTheme: theme });
2868
2609
  }
2869
2610
  if (attachToken) {
2870
- // Attach the bearer ONLY to same-origin requests. Context-wide
2871
- // extraHTTPHeaders would also send it to every cross-origin subresource
2872
- // the plan page loads (CDN images/fonts/scripts), leaking the publish
2873
- // token; routing scopes it to the trusted app origin.
2874
2611
  const appOrigin = new URL(appUrl).origin;
2875
2612
  await context.route("**/*", async (route) => {
2876
2613
  const request = route.request();
@@ -2905,9 +2642,6 @@ importPlaywright = defaultImportPlaywright) {
2905
2642
  // The selectors below are the real readiness signal for screenshots.
2906
2643
  // Some recap pages keep long-lived/background requests open.
2907
2644
  });
2908
- // The app shell renders <main> and the loading skeleton before the plan
2909
- // query resolves. Waiting for the actual document root prevents a
2910
- // successful-looking screenshot from capturing that transient skeleton.
2911
2645
  await page.waitForSelector(RECAP_DOCUMENT_SELECTOR, {
2912
2646
  timeout: RECAP_DOCUMENT_WAIT_TIMEOUT,
2913
2647
  state: "visible",
@@ -2978,9 +2712,6 @@ importPlaywright = defaultImportPlaywright) {
2978
2712
  });
2979
2713
  await page.waitForTimeout(250);
2980
2714
  await page.screenshot({ path: out });
2981
- // If the captured PNG is over the upload cap, retry at CSS-pixel scale
2982
- // before giving up. The server route rejects oversized files, and the
2983
- // GitHub comment can only embed an image after a successful upload.
2984
2715
  const firstSize = fs.existsSync(out) ? fs.statSync(out).size : 0;
2985
2716
  if (firstSize > RECAP_SHOT_MAX_BYTES) {
2986
2717
  process.stderr.write(`[recap shot] PNG is ${firstSize} bytes (cap ${RECAP_SHOT_MAX_BYTES}) — retrying at CSS-pixel scale\n`);
@@ -3036,15 +2767,11 @@ async function runComment(args, sub) {
3036
2767
  const body = existing?.body ?? "";
3037
2768
  const match = body.match(/<!--\s*plan-id:\s*([^\s]+)\s*-->/);
3038
2769
  const rawId = match ? match[1] : "";
3039
- // Validate: require the safe-id character set (mirrors canonicalRecapUrl).
3040
- // Any bot comment could inject junk here; non-matching ids are treated as absent.
3041
2770
  const safeId = rawId && /^[A-Za-z0-9_-]{1,64}$/.test(rawId) ? rawId : "";
3042
2771
  process.stdout.write(safeId);
3043
2772
  return;
3044
2773
  }
3045
2774
  if (sub === "upsert") {
3046
- // Tiny diffs are intentionally silent. In particular, do not refresh an
3047
- // existing recap comment into a visible "skipped" state.
3048
2775
  if (process.env.DIFF_TINY === "true") {
3049
2776
  process.stdout.write(`${JSON.stringify({ action: "skipped", id: 0, reason: "tiny diff" })}\n`);
3050
2777
  return;
@@ -3104,13 +2831,6 @@ function recoverRecapFailureEnv(env = process.env) {
3104
2831
  }
3105
2832
  return recovered;
3106
2833
  }
3107
- /**
3108
- * Files that, if an untrusted PR touches them, would let that PR rewrite
3109
- * repo-pinned skill instructions or root agent config the trusted recap job
3110
- * loads. The workflow runs the recap CLI from trusted base-branch source (or an
3111
- * installed package), so normal package code, template-local AGENTS.md files,
3112
- * and recap workflow YAML can be recapped without executing PR-modified CLI code.
3113
- */
3114
2834
  function normalizeRecapSkillSourceMode(value) {
3115
2835
  return (value || "auto").toLowerCase();
3116
2836
  }
@@ -3143,13 +2863,6 @@ export function isRecapSensitivePath(p, options = {}) {
3143
2863
  }
3144
2864
  return false;
3145
2865
  }
3146
- /**
3147
- * The pure gate decision: given the PR payload, secret-presence flags, the
3148
- * configured backend/model, and the PR's changed files, decide whether the
3149
- * visual recap should run, which (normalized) agent to use, and — when skipped —
3150
- * the human-readable reasons. This is the security boundary; it replicates the
3151
- * inline github-script gate bit-for-bit. No I/O so it can be unit-tested.
3152
- */
3153
2866
  export function evaluateRecapGate(input) {
3154
2867
  const { pr } = input;
3155
2868
  const reasons = [];
@@ -3180,7 +2893,6 @@ export function evaluateRecapGate(input) {
3180
2893
  if (isFork && !input.hasPlan) {
3181
2894
  reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);
3182
2895
  }
3183
- // Skip noisy automated authors.
3184
2896
  const login = ((pr && pr.user && pr.user.login) || "").toLowerCase();
3185
2897
  const botAuthors = [
3186
2898
  "dependabot[bot]",
@@ -3197,9 +2909,6 @@ export function evaluateRecapGate(input) {
3197
2909
  // hint above instead of this generic one.
3198
2910
  if (!isFork && !input.hasPlan)
3199
2911
  reasons.push("PLAN_RECAP_TOKEN not configured");
3200
- // The chosen backend's API key must be present. Normalize the agent value once
3201
- // here and validate it: an unknown or mis-cased value (e.g. "Claude", "gpt")
3202
- // must NOT silently pass the gate and then match neither agent step.
3203
2912
  const rawAgent = (input.agentRaw || "claude").toLowerCase();
3204
2913
  const agent = ["deepseek", "kimi", "moonshot", "custom"].includes(rawAgent)
3205
2914
  ? "openai-compatible"
@@ -3223,8 +2932,6 @@ export function evaluateRecapGate(input) {
3223
2932
  model: input.model,
3224
2933
  }).map((problem) => problem.reason));
3225
2934
  }
3226
- // Validate VISUAL_RECAP_MODEL if set — an unchecked value could be injected by
3227
- // a repo settings writer and passed straight to the agent CLI.
3228
2935
  const model = input.model || "";
3229
2936
  if (agent !== "openai-compatible" &&
3230
2937
  model &&
@@ -3235,16 +2942,6 @@ export function evaluateRecapGate(input) {
3235
2942
  if (skillSource && !["auto", "latest", "repo"].includes(skillSource)) {
3236
2943
  reasons.push('invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")');
3237
2944
  }
3238
- // Self-modifying guard: if an untrusted PR changes the visual-recap/visual-plan
3239
- // skill when CI is explicitly pinned to repo-local skill instructions, or root
3240
- // agent config the runner would load (.claude/**, CLAUDE.md, AGENTS.md,
3241
- // .mcp.json), skip the ENTIRE job — not just the agent — so a PR can never
3242
- // rewrite what the agent loads (skill, hooks, settings) and exfiltrate the
3243
- // publish/API secrets. In the default auto/latest modes the recap prompt comes
3244
- // from the trusted bundled skill, so visual skill and recap workflow files are
3245
- // ordinary reviewed content and may be recapped. Trusted write actors may edit
3246
- // recap-control files as reviewable content; running the recap is useful signal
3247
- // for those changes.
3248
2945
  const shouldApplySensitivePathGuard = Boolean(pr) && !isTrustedAuthor && (isFork || !isPrivate);
3249
2946
  const hits = shouldApplySensitivePathGuard
3250
2947
  ? input.changedFiles.filter((p) => isRecapSensitivePath(p, { skillSource }))
@@ -3254,14 +2951,6 @@ export function evaluateRecapGate(input) {
3254
2951
  }
3255
2952
  return { run: reasons.length === 0, agent, reasons };
3256
2953
  }
3257
- /**
3258
- * Page through `GET /repos/{owner}/{repo}/pulls/{n}/files`, following the
3259
- * `Link` rel="next" header, and return every changed filename. Uses the same
3260
- * api.github.com base + auth headers as `githubRequest`; reads the `Link`
3261
- * header (which `githubRequest` discards) so it can paginate. Throws on any
3262
- * non-2xx so the caller can fail CLOSED — exactly like the inline gate did when
3263
- * `github.paginate(listFiles)` rejected.
3264
- */
3265
2954
  async function listPullRequestFiles(input) {
3266
2955
  const filenames = [];
3267
2956
  let url = `https://api.github.com/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/pulls/${input.pull}/files?per_page=100`;
@@ -3282,7 +2971,6 @@ async function listPullRequestFiles(input) {
3282
2971
  if (typeof f.filename === "string")
3283
2972
  filenames.push(f.filename);
3284
2973
  }
3285
- // Follow Link rel="next" for the next page; absent => done.
3286
2974
  const link = res.headers.get("link") || "";
3287
2975
  const next = link.match(/<([^>]+)>\s*;\s*rel="next"/);
3288
2976
  url = next ? next[1] : null;
@@ -3299,8 +2987,6 @@ async function listPullRequestFiles(input) {
3299
2987
  */
3300
2988
  async function runGate() {
3301
2989
  const repository = process.env.GITHUB_REPOSITORY;
3302
- // Read the pull_request object out of the event payload, tolerating a
3303
- // missing/unreadable file (degrades to the "no pull_request payload" reason).
3304
2990
  let pr = null;
3305
2991
  let repositoryPrivate = false;
3306
2992
  const eventPath = process.env.GITHUB_EVENT_PATH;
@@ -3315,9 +3001,6 @@ async function runGate() {
3315
3001
  repositoryPrivate = false;
3316
3002
  }
3317
3003
  }
3318
- // Fetch the PR's changed files for the self-modifying guard. Any error here is
3319
- // turned into a skip reason (fail-closed), mirroring the inline gate's
3320
- // try/catch around github.paginate(listFiles).
3321
3004
  const changedFiles = [];
3322
3005
  let fileListError = null;
3323
3006
  if (pr && typeof pr.number === "number" && repository) {
@@ -3351,16 +3034,12 @@ async function runGate() {
3351
3034
  requiredLabels: process.env.VISUAL_RECAP_REQUIRED_LABELS,
3352
3035
  changedFiles,
3353
3036
  });
3354
- // If listing PR files failed, append the same fail-closed reason the inline
3355
- // gate used and force run=false.
3356
3037
  let { run } = decision;
3357
3038
  const reasons = [...decision.reasons];
3358
3039
  if (fileListError !== null) {
3359
3040
  reasons.push(`could not list PR files for the self-modifying guard (${fileListError}); skipping to be safe`);
3360
3041
  run = false;
3361
3042
  }
3362
- // Preserve the github-script contract: write `run` + the NORMALIZED agent to
3363
- // $GITHUB_OUTPUT so the recap job's step conditions match case-insensitively.
3364
3043
  const githubOutput = process.env.GITHUB_OUTPUT;
3365
3044
  if (githubOutput) {
3366
3045
  fs.appendFileSync(githubOutput, `run=${run ? "true" : "false"}\nagent=${decision.agent}\n`);
@@ -3370,20 +3049,6 @@ async function runGate() {
3370
3049
  ? `Visual recap will run (${decision.agent}).`
3371
3050
  : `Visual recap skipped: ${reasons.join("; ")}`);
3372
3051
  }
3373
- /* -------------------------------------------------------------------------- */
3374
- /* Check run — the "Visual Recap" GitHub check (was two inline github-script */
3375
- /* steps in the workflow's recap job). */
3376
- /* -------------------------------------------------------------------------- */
3377
- /**
3378
- * Canonicalize the agent-written plan URL into a trusted recap URL, or "".
3379
- *
3380
- * recap-url.txt is produced by the (LLM) agent, so the raw URL is untrusted.
3381
- * This rebuilds a canonical `${origin}${base}/recaps/<id>` link from the TRUSTED
3382
- * app URL plus a strictly-validated plan id, enforcing the app origin and
3383
- * honoring a path-prefixed mount (e.g. https://host/agent-native). Returns ""
3384
- * for a wrong origin or an unrecognized path. Pure so it can be unit-tested —
3385
- * SAME impl as the workflow's previous inline `canonicalRecapUrl`.
3386
- */
3387
3052
  export function canonicalRecapUrl(rawUrl, appUrl) {
3388
3053
  try {
3389
3054
  const trusted = new URL(appUrl || "https://plan.agent-native.com");
@@ -3392,8 +3057,6 @@ export function canonicalRecapUrl(rawUrl, appUrl) {
3392
3057
  : new URL(rawUrl, trusted);
3393
3058
  if (parsed.origin !== trusted.origin)
3394
3059
  return "";
3395
- // Honor a path-prefixed mount (e.g. https://host/agent-native): strip the
3396
- // trusted base path before matching /plans|recaps/<id>.
3397
3060
  const base = trusted.pathname.replace(/\/$/, "");
3398
3061
  let rest = parsed.pathname;
3399
3062
  if (base && rest.startsWith(base))
@@ -3447,18 +3110,6 @@ export function buildRecapFailureDiagnostic(input) {
3447
3110
  parts.push(`Agent output: ${failureSummary}`);
3448
3111
  return parts.join("\n\n");
3449
3112
  }
3450
- /**
3451
- * Map the workflow's terminal recap state to the completed check's
3452
- * conclusion/title/summary/text/details_url. Pure so it can be unit-tested —
3453
- * reproduces the workflow's previous inline branch logic EXACTLY:
3454
- *
3455
- * - default → neutral "Visual recap not generated"
3456
- * - planOk + valid recapUrl → success "Visual recap ready" (huge → "summarized"
3457
- * summary), Open-recap link as text, details_url = recapUrl
3458
- * - planOk + invalid url → neutral "Visual recap published" (see the comment)
3459
- * - else tiny → skipped "Visual recap skipped"
3460
- * - else suppressed → skipped "Visual recap suppressed" (reason from scan JSON)
3461
- */
3462
3113
  export function recapCheckOutcome(input) {
3463
3114
  let conclusion = "neutral";
3464
3115
  let title = "Visual recap not generated";
@@ -3481,9 +3132,6 @@ export function recapCheckOutcome(input) {
3481
3132
  text = `**[Open visual recap](${recapUrl})**`;
3482
3133
  }
3483
3134
  else {
3484
- // Agent reported success but the URL didn't validate against the trusted
3485
- // plan origin — don't claim "not generated"; the recap is linked in the
3486
- // sticky comment.
3487
3135
  title = "Visual recap published";
3488
3136
  summary =
3489
3137
  "A recap was published; see the visual recap comment on this PR for the link.";
@@ -3519,12 +3167,6 @@ export function recapCheckOutcome(input) {
3519
3167
  function boolFlag(args, key) {
3520
3168
  return args[key] === true || args[key] === "true";
3521
3169
  }
3522
- /**
3523
- * `recap check start` — create the in-progress "Visual Recap" GitHub check run
3524
- * and write its id to $GITHUB_OUTPUT (check_run_id). Best-effort: on any API
3525
- * error, warn on stderr and exit 0 (don't fail the job) without emitting an id.
3526
- * Replaces the workflow's inline "Start visual recap check" github-script step.
3527
- */
3528
3170
  async function runCheckStart(args) {
3529
3171
  const repo = optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY ?? "";
3530
3172
  const sha = optionalArg(args, "sha") ?? process.env.HEAD_SHA ?? "";
@@ -3563,12 +3205,6 @@ async function runCheckStart(args) {
3563
3205
  // Best-effort: don't fail the job and don't emit a check_run_id.
3564
3206
  }
3565
3207
  }
3566
- /**
3567
- * `recap check complete` — PATCH the "Visual Recap" check run to completed with
3568
- * the computed conclusion/title/summary/text/details_url. Best-effort: on any
3569
- * API error, warn on stderr and exit 0. Replaces the workflow's inline
3570
- * "Complete visual recap check" github-script step.
3571
- */
3572
3208
  async function runCheckComplete(args) {
3573
3209
  const repo = optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY ?? "";
3574
3210
  const token = optionalArg(args, "token") ||
@@ -3634,7 +3270,6 @@ async function runCheckComplete(args) {
3634
3270
  // Best-effort: don't fail the job.
3635
3271
  }
3636
3272
  }
3637
- /** `recap check <start|complete>` dispatcher. */
3638
3273
  async function runCheck(args, sub) {
3639
3274
  if (sub === "start") {
3640
3275
  await runCheckStart(args);
@@ -3646,7 +3281,6 @@ async function runCheck(args, sub) {
3646
3281
  }
3647
3282
  throw new Error("Usage: npx @agent-native/recap-cli@latest recap check <start|complete> [flags] (see `recap help`)");
3648
3283
  }
3649
- /** Parse the last top-level JSON object from a possibly-noisy stdout dump. */
3650
3284
  function parseLastJsonObject(text) {
3651
3285
  const trimmed = text.trim();
3652
3286
  if (!trimmed)
@@ -3671,13 +3305,6 @@ function parseLastJsonObject(text) {
3671
3305
  }
3672
3306
  return null;
3673
3307
  }
3674
- /**
3675
- * Claude Code `-p --output-format json` prints one final result object with a
3676
- * `usage` block and `total_cost_usd`. Anthropic's `input_tokens` EXCLUDES cache
3677
- * tokens, so the cache counts are added back: `inputTokens` reports the whole
3678
- * prompt, with the cache counts a slice of it. That is the one convention
3679
- * `calculateCost` and the engine `usage` event share.
3680
- */
3681
3308
  export function parseClaudeUsage(stdout) {
3682
3309
  const obj = parseLastJsonObject(stdout);
3683
3310
  const u = obj?.usage;
@@ -3699,7 +3326,6 @@ export function parseClaudeUsage(stdout) {
3699
3326
  reportedCostUsd: typeof obj?.total_cost_usd === "number" ? obj.total_cost_usd : undefined,
3700
3327
  };
3701
3328
  }
3702
- /** Pull the last usage object out of a Codex `exec --json` JSONL stream. */
3703
3329
  function lastCodexUsage(jsonl) {
3704
3330
  let last;
3705
3331
  for (const line of jsonl.split("\n")) {
@@ -3713,8 +3339,6 @@ function lastCodexUsage(jsonl) {
3713
3339
  catch {
3714
3340
  continue;
3715
3341
  }
3716
- // turn.completed carries `usage`; token_count events nest it under
3717
- // `info.total_token_usage`. Accept whichever the pinned Codex emits.
3718
3342
  const u = obj?.usage ??
3719
3343
  obj?.turn?.usage ??
3720
3344
  obj?.msg?.usage ??
@@ -3725,17 +3349,6 @@ function lastCodexUsage(jsonl) {
3725
3349
  }
3726
3350
  return last;
3727
3351
  }
3728
- /**
3729
- * Codex `exec --json` reports `input_tokens` INCLUSIVE of `cached_input_tokens`
3730
- * (OpenAI counts cached as a subset of prompt tokens), which is already the
3731
- * convention `calculateCost` and the engine `usage` event share — so input
3732
- * passes through untouched and `calculateCost` subtracts to price each token
3733
- * once. This used to strip the cached tokens out here instead, back when
3734
- * pricing added the cache counts on top; doing both now bills them twice.
3735
- *
3736
- * `reasoning_output_tokens` is still folded into output — it is billed at the
3737
- * output rate and would otherwise be dropped.
3738
- */
3739
3352
  export function parseCodexUsage(jsonl) {
3740
3353
  const u = lastCodexUsage(jsonl);
3741
3354
  if (!u)
@@ -3748,7 +3361,6 @@ export function parseCodexUsage(jsonl) {
3748
3361
  model: typeof u.model === "string" ? u.model : undefined,
3749
3362
  };
3750
3363
  }
3751
- /** Parse the usage sidecar emitted by an Agent-Native Code run. */
3752
3364
  export function parseOpenAiCompatibleUsage(json) {
3753
3365
  const obj = parseLastJsonObject(json);
3754
3366
  const usage = obj?.usage ?? obj;
@@ -3822,8 +3434,6 @@ async function runUsage(args) {
3822
3434
  done({ ok: false, reason: "no usage found in agent output" });
3823
3435
  return;
3824
3436
  }
3825
- // The Claude result carries the model; Codex usually does not, so fall back to
3826
- // the pinned --model (VISUAL_RECAP_MODEL) and finally the documented default.
3827
3437
  const model = parsed.model ??
3828
3438
  optionalArg(args, "model") ??
3829
3439
  (agent === "codex"