@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/plan-publish-store.d.ts +0 -29
- package/dist/plan-publish-store.d.ts.map +1 -1
- package/dist/plan-publish-store.js +0 -30
- package/dist/plan-publish-store.js.map +1 -1
- package/dist/pr-visual-recap-workflow.d.ts +0 -1
- package/dist/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/pr-visual-recap-workflow.js +0 -1
- package/dist/pr-visual-recap-workflow.js.map +1 -1
- package/dist/recap.d.ts +0 -216
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +2 -392
- package/dist/recap.js.map +1 -1
- package/dist/skill-content/wireframe.d.ts.map +1 -1
- package/dist/skill-content/wireframe.js +0 -25
- package/dist/skill-content/wireframe.js.map +1 -1
- package/dist/workflows/pr-visual-recap.yml +0 -26
- package/package.json +1 -1
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;
|
|
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"
|