@agent-native/core 0.49.22 → 0.49.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/agent/production-agent.d.ts +1 -0
  2. package/dist/agent/production-agent.d.ts.map +1 -1
  3. package/dist/agent/production-agent.js +15 -0
  4. package/dist/agent/production-agent.js.map +1 -1
  5. package/dist/agent/tool-search.d.ts.map +1 -1
  6. package/dist/agent/tool-search.js +32 -7
  7. package/dist/agent/tool-search.js.map +1 -1
  8. package/dist/cli/connect.d.ts +2 -3
  9. package/dist/cli/connect.d.ts.map +1 -1
  10. package/dist/cli/connect.js +60 -37
  11. package/dist/cli/connect.js.map +1 -1
  12. package/dist/cli/pr-visual-recap-workflow.d.ts +5 -7
  13. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  14. package/dist/cli/pr-visual-recap-workflow.js +5 -7
  15. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  16. package/dist/cli/recap.d.ts +44 -52
  17. package/dist/cli/recap.d.ts.map +1 -1
  18. package/dist/cli/recap.js +441 -414
  19. package/dist/cli/recap.js.map +1 -1
  20. package/dist/client/AssistantChat.d.ts +6 -3
  21. package/dist/client/AssistantChat.d.ts.map +1 -1
  22. package/dist/client/AssistantChat.js +1 -1
  23. package/dist/client/AssistantChat.js.map +1 -1
  24. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  25. package/dist/client/MultiTabAssistantChat.js +23 -3
  26. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  27. package/dist/client/agent-chat.d.ts +8 -0
  28. package/dist/client/agent-chat.d.ts.map +1 -1
  29. package/dist/client/agent-chat.js +43 -1
  30. package/dist/client/agent-chat.js.map +1 -1
  31. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  32. package/dist/client/blocks/library/AnnotatedCodeBlock.js +4 -1
  33. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  34. package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
  35. package/dist/client/blocks/library/DiffBlock.js +20 -7
  36. package/dist/client/blocks/library/DiffBlock.js.map +1 -1
  37. package/dist/client/blocks/library/annotation-rail.js +5 -5
  38. package/dist/client/blocks/library/annotation-rail.js.map +1 -1
  39. package/dist/client/builder-frame.d.ts +2 -0
  40. package/dist/client/builder-frame.d.ts.map +1 -1
  41. package/dist/client/builder-frame.js +2 -0
  42. package/dist/client/builder-frame.js.map +1 -1
  43. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  44. package/dist/client/composer/TiptapComposer.js +15 -2
  45. package/dist/client/composer/TiptapComposer.js.map +1 -1
  46. package/dist/client/mcp-app-host.d.ts +3 -0
  47. package/dist/client/mcp-app-host.d.ts.map +1 -1
  48. package/dist/client/mcp-app-host.js +13 -0
  49. package/dist/client/mcp-app-host.js.map +1 -1
  50. package/dist/coding-tools/run-code.d.ts.map +1 -1
  51. package/dist/coding-tools/run-code.js +69 -17
  52. package/dist/coding-tools/run-code.js.map +1 -1
  53. package/dist/integrations/plugin.d.ts.map +1 -1
  54. package/dist/integrations/plugin.js +2 -0
  55. package/dist/integrations/plugin.js.map +1 -1
  56. package/dist/mcp/build-server.d.ts +12 -10
  57. package/dist/mcp/build-server.d.ts.map +1 -1
  58. package/dist/mcp/build-server.js +53 -89
  59. package/dist/mcp/build-server.js.map +1 -1
  60. package/dist/mcp/connect-route.d.ts.map +1 -1
  61. package/dist/mcp/connect-route.js +5 -4
  62. package/dist/mcp/connect-route.js.map +1 -1
  63. package/dist/mcp/oauth-token.d.ts +6 -5
  64. package/dist/mcp/oauth-token.d.ts.map +1 -1
  65. package/dist/mcp/oauth-token.js.map +1 -1
  66. package/dist/mcp/stdio.d.ts.map +1 -1
  67. package/dist/mcp/stdio.js +9 -2
  68. package/dist/mcp/stdio.js.map +1 -1
  69. package/dist/provider-api/staging.d.ts.map +1 -1
  70. package/dist/provider-api/staging.js +6 -4
  71. package/dist/provider-api/staging.js.map +1 -1
  72. package/dist/server/agent-chat-plugin.d.ts +10 -7
  73. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  74. package/dist/server/agent-chat-plugin.js.map +1 -1
  75. package/docs/content/actions.md +1 -1
  76. package/docs/content/external-agents.md +53 -40
  77. package/docs/content/mcp-protocol.md +16 -11
  78. package/docs/content/pr-visual-recap.md +1 -1
  79. package/package.json +1 -1
package/dist/cli/recap.js CHANGED
@@ -14,13 +14,12 @@
14
14
  * collect-diff Collect the bounded base...head diff (excluding lockfiles,
15
15
  * build output, snapshots), cap it at ~600KB, and classify the
16
16
  * huge/tiny flags.
17
- * mcp-config Write the plan MCP client config for the chosen backend
18
- * (Claude Code JSON or Codex config.toml).
19
- * mcp-smoke Verify the configured Plan MCP endpoint exposes the publish
20
- * tools before spending runner time on Claude/Codex.
21
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.
22
20
  * build-prompt Assemble the agent prompt = latest visual-recap skill bundle
23
21
  * + a task wrapper (or repo-pinned skill with --skill-source).
22
+ * publish Publish the agent-authored recap-source.json over HTTP.
24
23
  * shot Screenshot the published plan and upload it to the plan app's
25
24
  * signed public image route (for an inline PR-comment image).
26
25
  * usage Parse and emit agent token-usage/cost from stdout.
@@ -35,8 +34,8 @@
35
34
  * Node built-ins only (plus an optional dynamic `playwright` import for `shot`).
36
35
  */
37
36
  import { execFileSync } from "node:child_process";
37
+ import { createHash } from "node:crypto";
38
38
  import fs from "node:fs";
39
- import os from "node:os";
40
39
  import path from "node:path";
41
40
  import { readPlanPublishAuth } from "./plan-publish-store.js";
42
41
  import { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js";
@@ -196,12 +195,6 @@ export function writePrVisualRecapReusableCallerWorkflow(baseDir, options = {})
196
195
  return { status: "written", path: rel, existed: false };
197
196
  }
198
197
  const DEFAULT_RECAP_APP_URL = "https://plan.agent-native.com";
199
- const RECAP_MCP_CLIENT_HEADER = "agent-native-pr-visual-recap";
200
- export const RECAP_MCP_REQUIRED_TOOLS = [
201
- "get-plan-blocks",
202
- "create-visual-recap",
203
- "set-resource-visibility",
204
- ];
205
198
  export function normalizeRecapAgent(value) {
206
199
  const agent = (value || "claude").toLowerCase();
207
200
  if (agent === "codex")
@@ -1109,371 +1102,6 @@ function runCollectDiff(args) {
1109
1102
  process.stdout.write(`${JSON.stringify({ bytes, changed, huge, tiny })}\n`);
1110
1103
  }
1111
1104
  /* -------------------------------------------------------------------------- */
1112
- /* MCP config writers — were the two `node -e` one-liners in the agent steps */
1113
- /* -------------------------------------------------------------------------- */
1114
- /**
1115
- * The Claude Code MCP config the recap agent loads: a single HTTP `plan` server
1116
- * pointing at the app's `/_agent-native/mcp` endpoint, authorized with the
1117
- * PLAN_RECAP_TOKEN. Pure (returns the JSON string) so it can be unit-tested.
1118
- */
1119
- export function buildRecapClaudeMcpConfig(appUrl, token) {
1120
- const url = appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
1121
- return JSON.stringify({
1122
- mcpServers: {
1123
- plan: {
1124
- type: "http",
1125
- url,
1126
- headers: {
1127
- Authorization: "Bearer " + token,
1128
- "X-Agent-Native-MCP-Client": RECAP_MCP_CLIENT_HEADER,
1129
- "X-Agent-Native-MCP-Full-Catalog": "1",
1130
- },
1131
- },
1132
- "agent-native-plans": {
1133
- type: "http",
1134
- url,
1135
- headers: {
1136
- Authorization: "Bearer " + token,
1137
- "X-Agent-Native-MCP-Client": RECAP_MCP_CLIENT_HEADER,
1138
- "X-Agent-Native-MCP-Full-Catalog": "1",
1139
- },
1140
- },
1141
- },
1142
- });
1143
- }
1144
- /**
1145
- * The Codex `config.toml` the recap agent loads. JSON.stringify the URL value so
1146
- * a stray quote/newline in the app URL can't break out of the TOML basic string
1147
- * (TOML shares JSON's escaping); the key and env-var name stay literal. Pure so
1148
- * it can be unit-tested.
1149
- */
1150
- export function buildRecapCodexMcpConfig(appUrl) {
1151
- const url = appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
1152
- return ("[mcp_servers.plan]\n" +
1153
- "url = " +
1154
- JSON.stringify(url) +
1155
- "\n" +
1156
- 'bearer_token_env_var = "PLAN_RECAP_TOKEN"\n' +
1157
- 'http_headers = { "X-Agent-Native-MCP-Client" = "agent-native-pr-visual-recap", "X-Agent-Native-MCP-Full-Catalog" = "1" }\n');
1158
- }
1159
- /**
1160
- * Per-attempt timeout for the non-mutating MCP smoke probe. Short enough that
1161
- * the workflow's 3-attempt retry loop can recover from a cold-start hang within
1162
- * the job budget, long enough not to flake on a warm-but-slow response. The
1163
- * plan app is serverless and intermittently 404s / stalls during a cold start
1164
- * or mid-deploy; without a bound, undici's multi-minute default header/body
1165
- * timeout would burn the whole job on a single stuck attempt.
1166
- */
1167
- const RECAP_MCP_SMOKE_TIMEOUT_MS = 15_000;
1168
- function recapMcpUrl(appUrl) {
1169
- return appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
1170
- }
1171
- function recapMcpSmokeHeaders(token) {
1172
- return {
1173
- authorization: `Bearer ${token}`,
1174
- accept: "application/json, text/event-stream",
1175
- "content-type": "application/json",
1176
- "mcp-protocol-version": "2025-06-18",
1177
- "x-agent-native-mcp-client": RECAP_MCP_CLIENT_HEADER,
1178
- "x-agent-native-mcp-full-catalog": "1",
1179
- };
1180
- }
1181
- function parseSseJsonPayload(raw) {
1182
- const dataLines = raw
1183
- .split(/\r?\n/)
1184
- .map((line) => line.trim())
1185
- .filter((line) => line.startsWith("data:"))
1186
- .map((line) => line.slice("data:".length).trim())
1187
- .filter((line) => line && line !== "[DONE]");
1188
- for (let i = dataLines.length - 1; i >= 0; i -= 1) {
1189
- try {
1190
- return JSON.parse(dataLines[i]);
1191
- }
1192
- catch {
1193
- // Keep looking for a parseable event payload.
1194
- }
1195
- }
1196
- return null;
1197
- }
1198
- function parseMcpJsonPayload(raw) {
1199
- const trimmed = raw.trim();
1200
- if (!trimmed)
1201
- return null;
1202
- try {
1203
- return JSON.parse(trimmed);
1204
- }
1205
- catch {
1206
- return parseSseJsonPayload(trimmed);
1207
- }
1208
- }
1209
- function mcpRpcErrorMessage(payload) {
1210
- if (!payload || typeof payload !== "object")
1211
- return null;
1212
- const error = payload.error;
1213
- if (!error || typeof error !== "object")
1214
- return null;
1215
- const message = error.message;
1216
- return typeof message === "string" && message.trim()
1217
- ? message.trim()
1218
- : JSON.stringify(error);
1219
- }
1220
- async function postRecapMcpRpc(input) {
1221
- const response = await input.fetchFn(input.mcpUrl, {
1222
- method: "POST",
1223
- headers: recapMcpSmokeHeaders(input.token),
1224
- body: JSON.stringify({
1225
- jsonrpc: "2.0",
1226
- id: input.id,
1227
- method: input.method,
1228
- params: input.params ?? {},
1229
- }),
1230
- // Bound each attempt so a cold-start hang fails fast and the workflow's
1231
- // smoke retry loop can re-probe a (by-then warm) endpoint, instead of
1232
- // blocking on undici's multi-minute default timeout.
1233
- signal: AbortSignal.timeout(RECAP_MCP_SMOKE_TIMEOUT_MS),
1234
- });
1235
- const raw = await response.text().catch((err) => String(err));
1236
- if (!response.ok) {
1237
- const detail = sanitizeAgentFailureSummary(raw, 300);
1238
- return {
1239
- ok: false,
1240
- status: response.status,
1241
- raw,
1242
- reason: `Plan MCP ${input.method} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
1243
- };
1244
- }
1245
- const payload = parseMcpJsonPayload(raw);
1246
- if (!payload) {
1247
- return {
1248
- ok: false,
1249
- raw,
1250
- reason: `Plan MCP ${input.method} returned an unreadable response`,
1251
- };
1252
- }
1253
- const rpcError = mcpRpcErrorMessage(payload);
1254
- if (rpcError) {
1255
- return {
1256
- ok: false,
1257
- raw,
1258
- reason: `Plan MCP ${input.method} failed: ${sanitizeAgentFailureSummary(rpcError, 300)}`,
1259
- };
1260
- }
1261
- return { ok: true, payload };
1262
- }
1263
- function extractMcpToolNames(payload) {
1264
- const result = payload && typeof payload === "object"
1265
- ? payload.result
1266
- : undefined;
1267
- const tools = result && typeof result === "object"
1268
- ? result.tools
1269
- : undefined;
1270
- if (!Array.isArray(tools))
1271
- return [];
1272
- return tools
1273
- .map((tool) => tool && typeof tool === "object"
1274
- ? tool.name
1275
- : undefined)
1276
- .filter((name) => typeof name === "string" && !!name)
1277
- .sort();
1278
- }
1279
- function recapMcpSmokeFailure(input) {
1280
- const tools = input.tools ?? [];
1281
- const reason = sanitizeAgentFailureSummary(input.reason, 500);
1282
- return {
1283
- ok: false,
1284
- appUrl: input.appUrl,
1285
- mcpUrl: input.mcpUrl,
1286
- toolCount: tools.length,
1287
- tools,
1288
- requiredTools: [...input.requiredTools],
1289
- reason,
1290
- summary: `Plan MCP smoke check failed: ${reason}`,
1291
- };
1292
- }
1293
- /**
1294
- * Non-mutating live contract check for PR Visual Recap publishing.
1295
- *
1296
- * The previous workflow discovered a broken Plan MCP catalog only after the
1297
- * agent tried to publish and failed to create `recap-url.txt`. This probes the
1298
- * same authenticated MCP endpoint first and requires the three tools that the
1299
- * visual-recap skill needs to publish a hosted recap.
1300
- */
1301
- export async function smokeRecapMcpTools(input) {
1302
- const appUrl = input.appUrl ?? DEFAULT_RECAP_APP_URL;
1303
- const mcpUrl = recapMcpUrl(appUrl);
1304
- const requiredTools = input.requiredTools ?? RECAP_MCP_REQUIRED_TOOLS;
1305
- const token = input.token?.trim();
1306
- if (!token) {
1307
- return recapMcpSmokeFailure({
1308
- appUrl,
1309
- mcpUrl,
1310
- requiredTools,
1311
- reason: "PLAN_RECAP_TOKEN is empty",
1312
- });
1313
- }
1314
- const fetchFn = input.fetchFn ?? fetch;
1315
- try {
1316
- const init = await postRecapMcpRpc({
1317
- fetchFn,
1318
- mcpUrl,
1319
- token,
1320
- method: "initialize",
1321
- id: 1,
1322
- params: {
1323
- protocolVersion: "2025-06-18",
1324
- capabilities: {},
1325
- clientInfo: {
1326
- name: RECAP_MCP_CLIENT_HEADER,
1327
- version: "1.0.0",
1328
- },
1329
- },
1330
- });
1331
- if (!init.ok) {
1332
- return recapMcpSmokeFailure({
1333
- appUrl,
1334
- mcpUrl,
1335
- requiredTools,
1336
- reason: init.reason,
1337
- });
1338
- }
1339
- const listed = await postRecapMcpRpc({
1340
- fetchFn,
1341
- mcpUrl,
1342
- token,
1343
- method: "tools/list",
1344
- id: 2,
1345
- });
1346
- if (!listed.ok) {
1347
- return recapMcpSmokeFailure({
1348
- appUrl,
1349
- mcpUrl,
1350
- requiredTools,
1351
- reason: listed.reason,
1352
- });
1353
- }
1354
- const tools = extractMcpToolNames(listed.payload);
1355
- const missing = requiredTools.filter((name) => !tools.includes(name));
1356
- if (missing.length > 0) {
1357
- return recapMcpSmokeFailure({
1358
- appUrl,
1359
- mcpUrl,
1360
- tools,
1361
- requiredTools,
1362
- reason: `Plan MCP tools/list returned ${tools.length} tools but is missing required publishing tools: ${missing.join(", ")}`,
1363
- });
1364
- }
1365
- return {
1366
- ok: true,
1367
- appUrl,
1368
- mcpUrl,
1369
- toolCount: tools.length,
1370
- tools,
1371
- requiredTools: [...requiredTools],
1372
- summary: `Plan MCP smoke check passed: tools/list exposes ${requiredTools.join(", ")}.`,
1373
- };
1374
- }
1375
- catch (err) {
1376
- return recapMcpSmokeFailure({
1377
- appUrl,
1378
- mcpUrl,
1379
- requiredTools,
1380
- reason: `Plan MCP smoke check could not reach ${mcpUrl}: ${String(err)}`,
1381
- });
1382
- }
1383
- }
1384
- async function runMcpSmoke(args) {
1385
- const appUrl = optionalArg(args, "app-url") ??
1386
- process.env.PLAN_RECAP_APP_URL ??
1387
- DEFAULT_RECAP_APP_URL;
1388
- const token = optionalArg(args, "token") ?? process.env.PLAN_RECAP_TOKEN;
1389
- const result = await smokeRecapMcpTools({ appUrl, token });
1390
- writeGitHubOutput("ok", result.ok ? "true" : "false");
1391
- writeGitHubOutput("summary", result.summary);
1392
- process.stdout.write(`${JSON.stringify(result)}\n`);
1393
- if (!result.ok)
1394
- process.exitCode = 1;
1395
- }
1396
- /**
1397
- * `recap mcp-config` — write the plan MCP client config for the chosen backend,
1398
- * replacing the two `node -e '...'` one-liners that previously lived inline in
1399
- * the agent steps. PLAN_RECAP_TOKEN is read from the environment (claude only),
1400
- * exactly as before.
1401
- */
1402
- function runMcpConfig(args) {
1403
- const agent = stringArg(args, "agent").toLowerCase();
1404
- const appUrl = stringArg(args, "app-url");
1405
- const force = Boolean(args["force"]);
1406
- if (agent === "claude") {
1407
- const token = process.env.PLAN_RECAP_TOKEN;
1408
- if (!token) {
1409
- process.stderr.write(`recap mcp-config: PLAN_RECAP_TOKEN is not set.\n` +
1410
- `Set it in the workflow environment before running this step.\n`);
1411
- process.exit(1);
1412
- }
1413
- const out = stringArg(args, "out");
1414
- fs.writeFileSync(path.resolve(out), buildRecapClaudeMcpConfig(appUrl, token));
1415
- process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
1416
- return;
1417
- }
1418
- if (agent === "codex") {
1419
- const out = optionalArg(args, "out") ??
1420
- path.join(os.homedir(), ".codex", "config.toml");
1421
- const absOut = path.resolve(out);
1422
- fs.mkdirSync(path.dirname(absOut), { recursive: true });
1423
- const newEntry = buildRecapCodexMcpConfig(appUrl);
1424
- const SECTION_MARKER = "[mcp_servers.plan]";
1425
- // If the file already exists and is non-empty, merge rather than overwrite.
1426
- let existing = "";
1427
- try {
1428
- const raw = fs.readFileSync(absOut, "utf8");
1429
- if (raw.trim())
1430
- existing = raw;
1431
- }
1432
- catch {
1433
- /* file absent — write fresh */
1434
- }
1435
- if (existing) {
1436
- if (existing.includes(SECTION_MARKER)) {
1437
- // Section already present — skip unless --force was passed.
1438
- if (!force) {
1439
- process.stdout.write(`${JSON.stringify({ ok: true, agent, out, skipped: true, reason: "plan entry already present; pass --force to overwrite" })}\n`);
1440
- return;
1441
- }
1442
- // --force: replace the existing [mcp_servers.plan] block.
1443
- // Remove lines from the section header until the next `[` header or EOF.
1444
- const lines = existing.split("\n");
1445
- const startIdx = lines.findIndex((l) => l.trim() === SECTION_MARKER);
1446
- let endIdx = lines.length;
1447
- for (let i = startIdx + 1; i < lines.length; i++) {
1448
- if (lines[i].trimStart().startsWith("[")) {
1449
- endIdx = i;
1450
- break;
1451
- }
1452
- }
1453
- const without = [
1454
- ...lines.slice(0, startIdx),
1455
- ...lines.slice(endIdx),
1456
- ].join("\n");
1457
- const merged = (without.trimEnd() ? without.trimEnd() + "\n\n" : "") + newEntry;
1458
- fs.writeFileSync(absOut, merged, { mode: 0o600 });
1459
- }
1460
- else {
1461
- // Append the new section to the existing config.
1462
- const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1463
- fs.writeFileSync(absOut, existing + separator + newEntry, {
1464
- mode: 0o600,
1465
- });
1466
- }
1467
- }
1468
- else {
1469
- fs.writeFileSync(absOut, newEntry);
1470
- }
1471
- process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
1472
- return;
1473
- }
1474
- throw new Error(`Unknown --agent "${agent}" (expected "claude" or "codex")`);
1475
- }
1476
- /* -------------------------------------------------------------------------- */
1477
1105
  /* Prompt builder — repo SKILL.md + task wrapper */
1478
1106
  /* -------------------------------------------------------------------------- */
1479
1107
  /**
@@ -1591,6 +1219,9 @@ export function buildRecapPrompt(input) {
1591
1219
  }
1592
1220
  if (input.statPath)
1593
1221
  lines.push(`- Diff stat: \`${input.statPath}\` (read this file)`);
1222
+ if (!input.localFiles) {
1223
+ lines.push(`- Live plan block reference: \`${input.blockReferencePath ?? "recap-blocks.md"}\` (read this before authoring; it is the workflow-fetched \`get-plan-blocks\` output for the target Plan app).`);
1224
+ }
1594
1225
  if (input.huge) {
1595
1226
  lines.push(`- The diff is LARGE — produce a **summarized** recap (top files + schema/API deltas), not an exhaustive one. The diff was truncated at the size cap — \`${input.statPath ?? "recap.stat"}\` contains the complete file list with per-file stats; for any file missing from \`${input.diffPath}\`, fetch it directly with \`git diff <base>...<head> -- <path>\`.`);
1596
1227
  }
@@ -1603,21 +1234,18 @@ export function buildRecapPrompt(input) {
1603
1234
  lines.push("3. Write the returned `url` from that command to `recap-url.txt` at the repo root, containing exactly one line. This file is the workflow's only hand-off.");
1604
1235
  }
1605
1236
  else {
1606
- lines.push("## Publish (this is the only way to produce output)");
1607
- lines.push(`The \`plan\` MCP server is configured for you, with \`agent-native-plans\` as a legacy alias. Call its tools by name (your host may expose them as \`get-plan-blocks\` / \`create-visual-recap\`, \`mcp__plan__get-plan-blocks\` / \`mcp__plan__create-visual-recap\`, or \`mcp__agent-native-plans__get-plan-blocks\` / \`mcp__agent-native-plans__create-visual-recap\` same tools).`);
1608
- lines.push("This is a one-shot GitHub Actions run. Do not wait, sleep, back off, schedule wakeups, reminders, follow-ups, or retries in another turn. Either publish the recap and write `recap-url.txt` in this process, or report the MCP/tool failure plainly.");
1609
- lines.push("First call `get-plan-blocks`, then call `create-visual-recap`. If `create-visual-recap` is available but `get-plan-blocks` is not, the Plan MCP is connected but the block-registry tool is not visible to this runner. Report that the runner must expose `get-plan-blocks` through the workflow/tool allowlist or compact MCP catalog; do not describe that case as a disconnected Plan MCP.");
1610
- lines.push(`1. Call the **create-visual-recap** tool on the \`plan\` MCP server with grounded MDX derived ONLY from the real diff, passing \`visibility: "org"\` so the recap is published org-scoped (never public) server-side${input.prevPlanId
1611
- ? `, and also passing \`planId: "${input.prevPlanId}"\` so this REPLACES the existing recap plan`
1612
- : ""}${prSourceUrl
1613
- ? `, and also passing \`sourceUrl: "${prSourceUrl}"\` so the hosted recap page can link back to the PR`
1614
- : ""}.`);
1615
- lines.push("If `create-visual-recap` returns validation feedback about empty or invalid wireframes, make one immediate correction pass in this same process: revise the named WireframeBlock/Artboard MDX so each frame has real visible product text/controls, then call `create-visual-recap` again. Do not write `recap-url.txt` until the tool succeeds.");
1616
- lines.push(`2. Write the plan URL to a file named \`recap-url.txt\` at the repo root, containing exactly one line: \`${appUrl}/recaps/<the returned plan id>\`. This file is the workflow's only hand-off — do not print anything else as the deliverable.`);
1617
- lines.push(`3. (Fallback only — skip if step 1 succeeded) If \`create-visual-recap\` does not accept a \`visibility\` parameter (older server), call the **set-resource-visibility** tool with \`{ resourceType: "plan", resourceId: <the returned plan id>, visibility: "org" }\` after publishing.`);
1237
+ lines.push("## Author Source (this is the only way to produce output)");
1238
+ lines.push(`The workflow has already fetched the live \`get-plan-blocks\` output into \`${input.blockReferencePath ?? "recap-blocks.md"}\`. Read that file and treat it as the authoritative block/tag/schema reference for this run.`);
1239
+ lines.push("Do NOT call the Plan MCP server and do NOT try to publish the recap yourself. CI publishes deterministically after you write the source file, which avoids host MCP registration flake.");
1240
+ lines.push("This is a one-shot GitHub Actions run. Do not wait, sleep, back off, schedule wakeups, reminders, follow-ups, or retries in another turn. Either write `recap-source.json` in this process, or report why source authoring failed plainly.");
1241
+ lines.push("1. Author grounded MDX recap source derived ONLY from the real diff. The final file must be valid JSON, not Markdown, not prose, and not a tool-call transcript.");
1242
+ lines.push('2. Write a file named `recap-source.json` at the repo root with exactly this shape: `{ "title": string, "brief": string, "mdx": { "plan.mdx": string, "canvas.mdx"?: string, "prototype.mdx"?: string, ".plan-state.json"?: string, "assets/"?: { [filename: string]: string } } }`.');
1243
+ lines.push("3. Do not write `recap-url.txt`; the deterministic CLI publisher writes that after it successfully POSTs your source to `create-visual-recap`.");
1618
1244
  }
1619
1245
  lines.push("");
1620
- lines.push("Do not invent file names, schema fields, or endpoints. Redact anything that looks like a secret. If the diff has no reviewable substance, still publish a minimal recap and write recap-url.txt. (CI already gated tiny diffs before invoking you — ignore the skill's advice to skip small diffs; always publish.)");
1246
+ lines.push(input.localFiles
1247
+ ? "Do not invent file names, schema fields, or endpoints. Redact anything that looks like a secret. If the diff has no reviewable substance, still create a minimal local recap and write recap-url.txt from the local preview command. (CI already gated tiny diffs before invoking you — ignore the skill's advice to skip small diffs; always produce output.)"
1248
+ : "Do not invent file names, schema fields, or endpoints. Redact anything that looks like a secret. If the diff has no reviewable substance, still write a minimal `recap-source.json`. (CI already gated tiny diffs before invoking you — ignore the skill's advice to skip small diffs; always produce output.)");
1621
1249
  lines.push("");
1622
1250
  lines.push("## Depth preflight");
1623
1251
  lines.push("Before authoring the recap, read the diff/stat and make a quick surface/state inventory of changed files, routes/actions, rendered UI surfaces, popovers/dialogs, role/access states, empty/error states, and shared abstractions. The published recap must cover each meaningful item with a structured block or intentionally omit it because it is tiny, redundant, or not user-visible.");
@@ -1625,7 +1253,9 @@ export function buildRecapPrompt(input) {
1625
1253
  lines.push("");
1626
1254
  lines.push("---");
1627
1255
  lines.push("");
1628
- lines.push("# visual-recap skill (follow this exactly)");
1256
+ lines.push("# visual-recap skill use for recap CONTENT and structure");
1257
+ lines.push("");
1258
+ lines.push("Follow the skill below for WHAT makes a good recap: which blocks to use, grounding, house style, and review depth. IGNORE its publishing and hand-off instructions — in this run you have NO Plan MCP tools and must NOT publish the recap yourself. Publishing is handled exactly as described above (write the source file; CI publishes it deterministically).");
1629
1259
  lines.push("");
1630
1260
  lines.push(input.skillMd.trim());
1631
1261
  lines.push("");
@@ -1664,6 +1294,20 @@ async function githubRequest(token, apiPath, init = {}, fetchFn = fetch) {
1664
1294
  return undefined;
1665
1295
  return (await res.json());
1666
1296
  }
1297
+ export async function isPullRequestHeadCurrent(input) {
1298
+ const expected = input.headSha.trim();
1299
+ if (!expected)
1300
+ return null;
1301
+ const fn = input.fetchFn ?? fetch;
1302
+ try {
1303
+ const pr = await githubRequest(input.token, `/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/pulls/${encodeURIComponent(input.issue)}`, {}, fn);
1304
+ const current = pr.head?.sha?.trim();
1305
+ return current ? current === expected : null;
1306
+ }
1307
+ catch {
1308
+ return null;
1309
+ }
1310
+ }
1667
1311
  export async function findExistingComment(input) {
1668
1312
  const fn = input.fetchFn ?? fetch;
1669
1313
  for (let page = 1;; page += 1) {
@@ -1738,6 +1382,10 @@ function trustedRecapImageUrl(raw, base) {
1738
1382
  /** Build the sticky comment body from the workflow's environment. */
1739
1383
  export function buildCommentBody(env = process.env) {
1740
1384
  const lines = [MARKER];
1385
+ const headSha = (env.HEAD_SHA || "").trim();
1386
+ const headMarker = /^[a-f0-9]{7,64}$/i.test(headSha)
1387
+ ? `<!-- head-sha: ${headSha} -->`
1388
+ : "";
1741
1389
  // Last-known plan id threaded from the previous run (supplied via PREV_PLAN_ID
1742
1390
  // when the comment is rebuilt from scratch, or parsed from the env on upsert).
1743
1391
  // We always emit the plan-id marker when any plan id is known so that a
@@ -1760,6 +1408,8 @@ export function buildCommentBody(env = process.env) {
1760
1408
  lines.push(`Reason: \`${reason}\`.`);
1761
1409
  if (prevPlanId)
1762
1410
  lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
1411
+ if (headMarker)
1412
+ lines.push("", headMarker);
1763
1413
  return lines.join("\n");
1764
1414
  }
1765
1415
  // Tiny diffs aren't worth a recap. The workflow upserts this state as a sticky
@@ -1771,6 +1421,8 @@ export function buildCommentBody(env = process.env) {
1771
1421
  lines.push("The change in this pull request is too small to be worth a visual recap. This is informational only and does **not** block the PR.");
1772
1422
  if (prevPlanId)
1773
1423
  lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
1424
+ if (headMarker)
1425
+ lines.push("", headMarker);
1774
1426
  return lines.join("\n");
1775
1427
  }
1776
1428
  const planUrl = (env.PLAN_URL || "").trim();
@@ -1813,6 +1465,8 @@ export function buildCommentBody(env = process.env) {
1813
1465
  }
1814
1466
  if (markerPlanId)
1815
1467
  lines.push("", `<!-- plan-id: ${markerPlanId} -->`);
1468
+ if (headMarker)
1469
+ lines.push("", headMarker);
1816
1470
  return lines.join("\n");
1817
1471
  }
1818
1472
  // Image URLs are produced by our own recap-image route, but validate each is
@@ -1840,6 +1494,8 @@ export function buildCommentBody(env = process.env) {
1840
1494
  lines.push("> Large diff — this recap is a **summarized** view (top files + schema/API deltas).");
1841
1495
  }
1842
1496
  lines.push("", `<!-- plan-id: ${planId} -->`);
1497
+ if (headMarker)
1498
+ lines.push("", headMarker);
1843
1499
  return lines.join("\n");
1844
1500
  }
1845
1501
  /* -------------------------------------------------------------------------- */
@@ -1898,6 +1554,7 @@ function runBuildPrompt(args) {
1898
1554
  appUrl: optionalArg(args, "app-url") ?? "https://plan.agent-native.com",
1899
1555
  diffPath,
1900
1556
  statPath: optionalArg(args, "stat"),
1557
+ blockReferencePath: optionalArg(args, "block-reference"),
1901
1558
  prevPlanId: optionalArg(args, "prev-plan-id"),
1902
1559
  huge: args.huge === true || args.huge === "true",
1903
1560
  localFiles: args["local-files"] === true || args["local-files"] === "true",
@@ -1910,6 +1567,298 @@ function runBuildPrompt(args) {
1910
1567
  fs.writeFileSync(path.resolve(out), prompt);
1911
1568
  process.stdout.write(`${JSON.stringify({ ok: true, out, skillSource: skill.source, bytes: prompt.length })}\n`);
1912
1569
  }
1570
+ const RECAP_SOURCE_FILENAME = "recap-source.json";
1571
+ const RECAP_URL_REASON_FILENAME = "recap-url-reason.txt";
1572
+ const RECAP_HTTP_TIMEOUT_MS = 45_000;
1573
+ function writeRecapUrlReason(reason, cwd = process.cwd()) {
1574
+ fs.writeFileSync(path.join(cwd, RECAP_URL_REASON_FILENAME), `${sanitizeAgentFailureSummary(reason, 1000)}\n`);
1575
+ }
1576
+ function readRecapUrlReason(cwd = process.cwd()) {
1577
+ return readTextIfExists(path.join(cwd, RECAP_URL_REASON_FILENAME));
1578
+ }
1579
+ function validateRecapSourcePayload(value) {
1580
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1581
+ throw new Error(`${RECAP_SOURCE_FILENAME} must contain a JSON object.`);
1582
+ }
1583
+ const obj = value;
1584
+ if (obj.title !== undefined && typeof obj.title !== "string") {
1585
+ throw new Error(`${RECAP_SOURCE_FILENAME} title must be a string.`);
1586
+ }
1587
+ if (obj.brief !== undefined && typeof obj.brief !== "string") {
1588
+ throw new Error(`${RECAP_SOURCE_FILENAME} brief must be a string.`);
1589
+ }
1590
+ if (!obj.mdx || typeof obj.mdx !== "object" || Array.isArray(obj.mdx)) {
1591
+ throw new Error(`${RECAP_SOURCE_FILENAME} must include an mdx object.`);
1592
+ }
1593
+ const mdx = obj.mdx;
1594
+ if (typeof mdx["plan.mdx"] !== "string" || !mdx["plan.mdx"].trim()) {
1595
+ throw new Error(`${RECAP_SOURCE_FILENAME} mdx["plan.mdx"] must be a non-empty string.`);
1596
+ }
1597
+ for (const key of ["canvas.mdx", "prototype.mdx", ".plan-state.json"]) {
1598
+ if (mdx[key] !== undefined && typeof mdx[key] !== "string") {
1599
+ throw new Error(`${RECAP_SOURCE_FILENAME} mdx["${key}"] must be a string when present.`);
1600
+ }
1601
+ }
1602
+ const assets = mdx["assets/"];
1603
+ if (assets !== undefined) {
1604
+ if (!assets || typeof assets !== "object" || Array.isArray(assets)) {
1605
+ throw new Error(`${RECAP_SOURCE_FILENAME} mdx["assets/"] must be an object when present.`);
1606
+ }
1607
+ for (const [name, body] of Object.entries(assets)) {
1608
+ if (typeof body !== "string") {
1609
+ throw new Error(`${RECAP_SOURCE_FILENAME} asset ${JSON.stringify(name)} must be a string.`);
1610
+ }
1611
+ }
1612
+ }
1613
+ return {
1614
+ ...(typeof obj.title === "string" ? { title: obj.title } : {}),
1615
+ ...(typeof obj.brief === "string" ? { brief: obj.brief } : {}),
1616
+ mdx,
1617
+ };
1618
+ }
1619
+ export function readRecapSourcePayload(filePath = RECAP_SOURCE_FILENAME) {
1620
+ const abs = path.resolve(filePath);
1621
+ let text;
1622
+ try {
1623
+ text = fs.readFileSync(abs, "utf8");
1624
+ }
1625
+ catch (err) {
1626
+ throw new Error(`${RECAP_SOURCE_FILENAME} was not created by the agent (${String(err)}).`);
1627
+ }
1628
+ let parsed;
1629
+ try {
1630
+ parsed = JSON.parse(text);
1631
+ }
1632
+ catch (err) {
1633
+ throw new Error(`${RECAP_SOURCE_FILENAME} was not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
1634
+ }
1635
+ return validateRecapSourcePayload(parsed);
1636
+ }
1637
+ function recapActionEndpoint(appUrl, action) {
1638
+ return `${appUrl.replace(/\/$/, "")}/_agent-native/actions/${action}`;
1639
+ }
1640
+ async function fetchJsonWithTimeout(url, init, fetchFn) {
1641
+ return await fetchFn(url, {
1642
+ ...init,
1643
+ signal: init.signal ?? AbortSignal.timeout(RECAP_HTTP_TIMEOUT_MS),
1644
+ });
1645
+ }
1646
+ export async function fetchRecapBlockReference(input) {
1647
+ const fetchFn = input.fetchFn ?? fetch;
1648
+ const out = input.out ?? "recap-blocks.md";
1649
+ const endpoint = new URL(recapActionEndpoint(input.appUrl, "get-plan-blocks"));
1650
+ endpoint.searchParams.set("format", "reference");
1651
+ const response = await fetchJsonWithTimeout(endpoint.toString(), { method: "GET", headers: { accept: "application/json" } }, fetchFn);
1652
+ if (!response.ok) {
1653
+ const detail = await response.text().catch(() => "");
1654
+ throw new Error(`get-plan-blocks failed ${response.status} ${response.statusText}: ${sanitizeAgentFailureSummary(detail, 500)}`);
1655
+ }
1656
+ const json = (await response.json().catch(() => null));
1657
+ if (!json?.reference) {
1658
+ throw new Error("get-plan-blocks returned no reference text.");
1659
+ }
1660
+ fs.writeFileSync(path.resolve(out), json.reference);
1661
+ return { ok: true, out, count: json.count };
1662
+ }
1663
+ function recapUrlFromPublishResult(result, appUrl) {
1664
+ const candidates = [];
1665
+ const ids = [];
1666
+ const visit = (value, depth = 0) => {
1667
+ if (!value || typeof value !== "object" || depth > 3)
1668
+ return;
1669
+ const obj = value;
1670
+ for (const key of ["webUrl", "url", "path", "href"]) {
1671
+ const candidate = obj[key];
1672
+ if (typeof candidate === "string")
1673
+ candidates.push(candidate);
1674
+ }
1675
+ for (const key of ["planId", "id"]) {
1676
+ const candidate = obj[key];
1677
+ if (typeof candidate === "string" &&
1678
+ /^[A-Za-z0-9_-]{1,80}$/.test(candidate)) {
1679
+ ids.push(candidate);
1680
+ }
1681
+ }
1682
+ for (const key of ["plan", "openLink", "link", "result"]) {
1683
+ visit(obj[key], depth + 1);
1684
+ }
1685
+ };
1686
+ visit(result);
1687
+ for (const candidate of candidates) {
1688
+ const canonical = canonicalRecapUrl(candidate, appUrl);
1689
+ if (canonical)
1690
+ return canonical;
1691
+ }
1692
+ for (const id of ids) {
1693
+ const canonical = canonicalRecapUrl(`/recaps/${id}`, appUrl);
1694
+ if (canonical)
1695
+ return canonical;
1696
+ }
1697
+ return "";
1698
+ }
1699
+ function shouldRetryRecapPublish(status) {
1700
+ return (status === 408 ||
1701
+ status === 409 ||
1702
+ status === 425 ||
1703
+ status === 429 ||
1704
+ status >= 500);
1705
+ }
1706
+ function recapPublishIdempotencyKey(input) {
1707
+ const identity = input.prevPlanId
1708
+ ? `plan:${input.prevPlanId}`
1709
+ : input.repo && input.pr
1710
+ ? `github-pr:${input.repo}:${input.pr}`
1711
+ : input.sourceUrl
1712
+ ? `source-url:${input.sourceUrl}`
1713
+ : `source-path:${path.resolve(input.sourcePath)}`;
1714
+ return `visual-recap-${createHash("sha256").update(identity).digest("hex")}`;
1715
+ }
1716
+ export async function publishRecapSource(input) {
1717
+ const cwd = input.cwd ?? process.cwd();
1718
+ const sourcePath = input.sourcePath ?? path.join(cwd, RECAP_SOURCE_FILENAME);
1719
+ const out = input.out ?? path.join(cwd, "recap-url.txt");
1720
+ const token = input.token.trim();
1721
+ if (!token)
1722
+ throw new Error("PLAN_RECAP_TOKEN is empty.");
1723
+ const source = readRecapSourcePayload(sourcePath);
1724
+ const sourceUrl = input.sourceUrl ??
1725
+ (input.repo && input.pr
1726
+ ? `https://github.com/${input.repo}/pull/${input.pr}`
1727
+ : undefined);
1728
+ const idempotencyKey = recapPublishIdempotencyKey({
1729
+ prevPlanId: input.prevPlanId,
1730
+ repo: input.repo,
1731
+ pr: input.pr,
1732
+ sourcePath,
1733
+ sourceUrl,
1734
+ });
1735
+ const body = {
1736
+ ...(input.prevPlanId ? { planId: input.prevPlanId } : {}),
1737
+ idempotencyKey,
1738
+ ...(source.title ? { title: source.title } : {}),
1739
+ ...(source.brief ? { brief: source.brief } : {}),
1740
+ visibility: "org",
1741
+ source: "imported",
1742
+ ...(input.repo ? { repoPath: input.repo } : {}),
1743
+ ...(sourceUrl ? { sourceUrl } : {}),
1744
+ currentFocus: "visual recap review",
1745
+ status: "review",
1746
+ mdx: source.mdx,
1747
+ };
1748
+ const endpoint = recapActionEndpoint(input.appUrl, "create-visual-recap");
1749
+ const fetchFn = input.fetchFn ?? fetch;
1750
+ let lastError = "";
1751
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1752
+ try {
1753
+ const response = await fetchJsonWithTimeout(endpoint, {
1754
+ method: "POST",
1755
+ headers: {
1756
+ accept: "application/json",
1757
+ "content-type": "application/json",
1758
+ authorization: `Bearer ${token}`,
1759
+ "Idempotency-Key": idempotencyKey,
1760
+ "X-Idempotency-Key": idempotencyKey,
1761
+ },
1762
+ body: JSON.stringify(body),
1763
+ }, fetchFn);
1764
+ const text = await response.text().catch((err) => String(err));
1765
+ if (!response.ok) {
1766
+ lastError = `create-visual-recap failed ${response.status} ${response.statusText}: ${sanitizeAgentFailureSummary(text, 800)}`;
1767
+ if (attempt < 3 && shouldRetryRecapPublish(response.status)) {
1768
+ await delay(attempt * 2000);
1769
+ continue;
1770
+ }
1771
+ throw new Error(lastError);
1772
+ }
1773
+ let result = null;
1774
+ try {
1775
+ result = text ? JSON.parse(text) : null;
1776
+ }
1777
+ catch {
1778
+ throw new Error("create-visual-recap returned non-JSON output.");
1779
+ }
1780
+ const url = recapUrlFromPublishResult(result, input.appUrl);
1781
+ if (!url) {
1782
+ throw new Error("create-visual-recap succeeded but did not return a usable /recaps/<id> URL or plan id.");
1783
+ }
1784
+ fs.writeFileSync(path.resolve(out), `${url}\n`);
1785
+ try {
1786
+ fs.rmSync(path.join(cwd, RECAP_URL_REASON_FILENAME), { force: true });
1787
+ }
1788
+ catch {
1789
+ /* ignore */
1790
+ }
1791
+ return { ok: true, url, out };
1792
+ }
1793
+ catch (err) {
1794
+ lastError = err instanceof Error ? err.message : String(err);
1795
+ if (attempt < 3 &&
1796
+ /fetch failed|network|timeout|timed out|ECONNRESET|ETIMEDOUT/i.test(lastError)) {
1797
+ await delay(attempt * 2000);
1798
+ continue;
1799
+ }
1800
+ throw new Error(lastError);
1801
+ }
1802
+ }
1803
+ throw new Error(lastError || "create-visual-recap failed.");
1804
+ }
1805
+ async function runBlockReference(args) {
1806
+ const appUrl = optionalArg(args, "app-url") ??
1807
+ process.env.PLAN_RECAP_APP_URL ??
1808
+ DEFAULT_RECAP_APP_URL;
1809
+ const out = optionalArg(args, "out") ?? "recap-blocks.md";
1810
+ try {
1811
+ const result = await fetchRecapBlockReference({ appUrl, out });
1812
+ writeGitHubOutput("ok", "true");
1813
+ writeGitHubOutput("out", result.out);
1814
+ writeGitHubOutput("reason", "");
1815
+ process.stdout.write(`${JSON.stringify(result)}\n`);
1816
+ }
1817
+ catch (err) {
1818
+ const reason = sanitizeAgentFailureSummary(err instanceof Error ? err.message : String(err), 1000);
1819
+ writeRecapUrlReason(reason);
1820
+ writeGitHubOutput("ok", "false");
1821
+ writeGitHubOutput("out", "");
1822
+ writeGitHubOutput("reason", reason);
1823
+ process.stdout.write(`${JSON.stringify({ ok: false, reason })}\n`);
1824
+ process.exitCode = 1;
1825
+ }
1826
+ }
1827
+ async function runPublish(args) {
1828
+ const appUrl = optionalArg(args, "app-url") ??
1829
+ process.env.PLAN_RECAP_APP_URL ??
1830
+ DEFAULT_RECAP_APP_URL;
1831
+ const token = optionalArg(args, "token") ?? process.env.PLAN_RECAP_TOKEN ?? "";
1832
+ const out = optionalArg(args, "out") ?? "recap-url.txt";
1833
+ const done = (obj) => {
1834
+ process.stdout.write(`${JSON.stringify(obj)}\n`);
1835
+ };
1836
+ try {
1837
+ const result = await publishRecapSource({
1838
+ appUrl,
1839
+ token,
1840
+ sourcePath: optionalArg(args, "source") ?? RECAP_SOURCE_FILENAME,
1841
+ out,
1842
+ prevPlanId: optionalArg(args, "prev-plan-id"),
1843
+ repo: optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY,
1844
+ pr: optionalArg(args, "pr") ?? process.env.PR_NUMBER,
1845
+ sourceUrl: optionalArg(args, "source-url"),
1846
+ });
1847
+ writeGitHubOutput("ok", "true");
1848
+ writeGitHubOutput("plan_url", result.url);
1849
+ writeGitHubOutput("reason", "");
1850
+ done(result);
1851
+ }
1852
+ catch (err) {
1853
+ const reason = sanitizeAgentFailureSummary(err instanceof Error ? err.message : String(err), 1000);
1854
+ writeRecapUrlReason(reason);
1855
+ writeGitHubOutput("ok", "false");
1856
+ writeGitHubOutput("plan_url", "");
1857
+ writeGitHubOutput("reason", reason);
1858
+ done({ ok: false, reason });
1859
+ process.exitCode = 1;
1860
+ }
1861
+ }
1913
1862
  function delay(ms) {
1914
1863
  return ms > 0
1915
1864
  ? new Promise((resolve) => setTimeout(resolve, ms))
@@ -2012,6 +1961,47 @@ async function defaultImportPlaywright() {
2012
1961
  return (await import("@playwright/test"));
2013
1962
  }
2014
1963
  }
1964
+ const RECAP_SYSTEM_CHROME_EXECUTABLES = [
1965
+ "/usr/bin/google-chrome-stable",
1966
+ "/usr/bin/google-chrome",
1967
+ "/usr/bin/chromium-browser",
1968
+ "/usr/bin/chromium",
1969
+ ];
1970
+ function shouldTrySystemChromeFallback(err) {
1971
+ const message = err instanceof Error ? err.message : String(err);
1972
+ return /Executable doesn't exist|playwright install|browser.*not found|chromium.*not found/i.test(message);
1973
+ }
1974
+ export async function launchRecapChromium(chromium) {
1975
+ const launchOptions = { args: ["--no-sandbox"] };
1976
+ try {
1977
+ return await chromium.launch(launchOptions);
1978
+ }
1979
+ catch (err) {
1980
+ if (!shouldTrySystemChromeFallback(err))
1981
+ throw err;
1982
+ const fallbackErrors = [];
1983
+ for (const executablePath of RECAP_SYSTEM_CHROME_EXECUTABLES) {
1984
+ if (!fs.existsSync(executablePath))
1985
+ continue;
1986
+ try {
1987
+ process.stderr.write(`[recap shot] Playwright browser unavailable; trying system Chrome at ${executablePath}\n`);
1988
+ return await chromium.launch({ ...launchOptions, executablePath });
1989
+ }
1990
+ catch (fallbackErr) {
1991
+ const message = fallbackErr instanceof Error
1992
+ ? fallbackErr.message
1993
+ : String(fallbackErr);
1994
+ fallbackErrors.push(`${executablePath}: ${message}`);
1995
+ process.stderr.write(`[recap shot] system Chrome launch failed at ${executablePath}: ${message}\n`);
1996
+ }
1997
+ }
1998
+ if (fallbackErrors.length) {
1999
+ const originalMessage = err instanceof Error ? err.message : String(err);
2000
+ throw new Error(`${originalMessage}; system Chrome fallback failed (${fallbackErrors.join("; ")})`, { cause: err });
2001
+ }
2002
+ throw err;
2003
+ }
2004
+ }
2015
2005
  function parseRecapScreenshotTheme(value) {
2016
2006
  if (value === undefined)
2017
2007
  return undefined;
@@ -2080,13 +2070,14 @@ importPlaywright = defaultImportPlaywright) {
2080
2070
  return;
2081
2071
  }
2082
2072
  let captured = false;
2073
+ let reason = "";
2083
2074
  let browser;
2084
2075
  const hardTimer = setTimeout(() => {
2085
2076
  done({ ok: false, reason: "hard 60s timeout reached" });
2086
2077
  process.exit(0);
2087
2078
  }, 60_000);
2088
2079
  try {
2089
- browser = await chromium.launch({ args: ["--no-sandbox"] });
2080
+ browser = await launchRecapChromium(chromium);
2090
2081
  const context = await browser.newContext({
2091
2082
  viewport: RECAP_SHOT_VIEWPORT,
2092
2083
  deviceScaleFactor: RECAP_SHOT_DEVICE_SCALE_FACTOR,
@@ -2211,12 +2202,20 @@ importPlaywright = defaultImportPlaywright) {
2211
2202
  });
2212
2203
  await page.waitForTimeout(250);
2213
2204
  await page.screenshot({ path: out });
2214
- // If the captured PNG is over the upload cap, remove it so the upload step
2215
- // sees no file and the comment falls back to a link-only recap.
2205
+ // If the captured PNG is over the upload cap, retry at CSS-pixel scale
2206
+ // before giving up. The server route rejects oversized files, and the
2207
+ // GitHub comment can only embed an image after a successful upload.
2216
2208
  const firstSize = fs.existsSync(out) ? fs.statSync(out).size : 0;
2217
2209
  if (firstSize > RECAP_SHOT_MAX_BYTES) {
2218
- process.stderr.write(`[recap shot] PNG is ${firstSize} bytes (cap ${RECAP_SHOT_MAX_BYTES}) — skipping upload\n`);
2210
+ process.stderr.write(`[recap shot] PNG is ${firstSize} bytes (cap ${RECAP_SHOT_MAX_BYTES}) — retrying at CSS-pixel scale\n`);
2219
2211
  fs.unlinkSync(out);
2212
+ await page.screenshot({ path: out, scale: "css" });
2213
+ const retrySize = fs.existsSync(out) ? fs.statSync(out).size : 0;
2214
+ if (retrySize > RECAP_SHOT_MAX_BYTES) {
2215
+ reason = `screenshot PNG exceeded upload cap (${retrySize} bytes > ${RECAP_SHOT_MAX_BYTES})`;
2216
+ process.stderr.write(`[recap shot] ${reason}; skipping upload\n`);
2217
+ fs.unlinkSync(out);
2218
+ }
2220
2219
  }
2221
2220
  captured = fs.existsSync(out);
2222
2221
  await browser.close();
@@ -2240,8 +2239,12 @@ importPlaywright = defaultImportPlaywright) {
2240
2239
  let imageUrl = null;
2241
2240
  if (captured && token && appUrl) {
2242
2241
  imageUrl = await uploadRecapImage({ appUrl, token, pngPath: out });
2242
+ if (!imageUrl) {
2243
+ reason = "screenshot captured but image upload failed";
2244
+ }
2243
2245
  }
2244
- done({ ok: captured, out, imageUrl });
2246
+ const ok = captured && (!(token && appUrl) || !!imageUrl);
2247
+ done({ ok, out, imageUrl, ...(reason ? { reason } : {}) });
2245
2248
  }
2246
2249
  async function runComment(args, sub) {
2247
2250
  const token = stringArg(args, "token");
@@ -2259,6 +2262,24 @@ async function runComment(args, sub) {
2259
2262
  return;
2260
2263
  }
2261
2264
  if (sub === "upsert") {
2265
+ const headSha = optionalArg(args, "head-sha") ?? process.env.HEAD_SHA ?? "";
2266
+ if (headSha) {
2267
+ const current = await isPullRequestHeadCurrent({
2268
+ token,
2269
+ owner,
2270
+ repo,
2271
+ issue,
2272
+ headSha,
2273
+ });
2274
+ if (current === false) {
2275
+ process.stdout.write(`${JSON.stringify({
2276
+ action: "skipped",
2277
+ id: 0,
2278
+ reason: "stale head sha",
2279
+ })}\n`);
2280
+ return;
2281
+ }
2282
+ }
2262
2283
  const result = await upsertComment({
2263
2284
  token,
2264
2285
  owner,
@@ -2617,13 +2638,16 @@ export function canonicalRecapUrl(rawUrl, appUrl) {
2617
2638
  }
2618
2639
  }
2619
2640
  export function inferLocalRecapUrlFailureReason(input = {}) {
2620
- const recapUrlPath = path.join(input.cwd ?? process.cwd(), "recap-url.txt");
2641
+ const cwd = input.cwd ?? process.cwd();
2642
+ const explicitReason = readRecapUrlReason(cwd);
2643
+ const recapUrlPath = path.join(cwd, "recap-url.txt");
2621
2644
  const raw = readTextIfExists(recapUrlPath);
2622
- if (raw === null)
2623
- return "recap-url.txt was not created by the agent.";
2645
+ if (raw === null) {
2646
+ return explicitReason?.trim() || "recap-url.txt was not created.";
2647
+ }
2624
2648
  const value = raw.replace(/[\r\n\s]/g, "");
2625
2649
  if (!value)
2626
- return "recap-url.txt was empty.";
2650
+ return explicitReason?.trim() || "recap-url.txt was empty.";
2627
2651
  const appUrl = input.appUrl ||
2628
2652
  process.env.PLAN_RECAP_APP_URL ||
2629
2653
  "https://plan.agent-native.com";
@@ -2637,10 +2661,12 @@ export function inferLocalRecapUrlFailureReason(input = {}) {
2637
2661
  if (parsed.origin !== trusted.origin) {
2638
2662
  return `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}.`;
2639
2663
  }
2640
- return "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app.";
2664
+ return (explicitReason?.trim() ||
2665
+ "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app.");
2641
2666
  }
2642
2667
  catch {
2643
- return "recap-url.txt was not a valid URL or recap path.";
2668
+ return (explicitReason?.trim() ||
2669
+ "recap-url.txt was not a valid URL or recap path.");
2644
2670
  }
2645
2671
  }
2646
2672
  export function buildRecapFailureDiagnostic(input) {
@@ -3063,10 +3089,10 @@ Usage:
3063
3089
  npx @agent-native/core@latest recap setup [--repo owner/name] [--agent claude|codex] [--app-url <url>] [--skip-secrets] [--dry-run] [--force]
3064
3090
  npx @agent-native/core@latest recap doctor [--repo owner/name] [--agent claude|codex] [--app-url <url>]
3065
3091
  npx @agent-native/core@latest recap collect-diff --base <baseSha> --head <headSha> [--out recap.diff] [--stat recap.stat]
3066
- npx @agent-native/core@latest recap mcp-config --agent claude|codex --app-url <url> [--out <path>]
3067
- npx @agent-native/core@latest recap mcp-smoke [--app-url <url>] [--token <planToken>]
3092
+ npx @agent-native/core@latest recap block-reference [--app-url <url>] [--out recap-blocks.md]
3068
3093
  npx @agent-native/core@latest recap scan --diff <path> [--mode off|high-confidence|strict]
3069
- npx @agent-native/core@latest recap build-prompt --pr <n> [--repo owner/name] [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--prev-plan-id <id>] [--huge] [--local-files] [--local-dir <folder>] [--skill-source auto|latest|repo] [--out <path>]
3094
+ npx @agent-native/core@latest recap build-prompt --pr <n> [--repo owner/name] [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--block-reference recap-blocks.md] [--prev-plan-id <id>] [--huge] [--local-files] [--local-dir <folder>] [--skill-source auto|latest|repo] [--out <path>]
3095
+ npx @agent-native/core@latest recap publish [--source recap-source.json] [--out recap-url.txt] [--repo owner/name] [--pr <n>] [--prev-plan-id <id>] [--app-url <url>] [--token <planToken>]
3070
3096
  npx @agent-native/core@latest recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png] [--theme light|dark]
3071
3097
  npx @agent-native/core@latest recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex] [--model <id>]
3072
3098
  npx @agent-native/core@latest recap agent-summary --result-file <path> [--stderr-file <path>] [--exit-code-file <path>] [--agent claude|codex]
@@ -3103,11 +3129,12 @@ Usage:
3103
3129
  shapes such as private key blocks and known provider token prefixes. Set
3104
3130
  VISUAL_RECAP_SECRET_SCAN=strict, or pass --mode strict, to restore generic
3105
3131
  TOKEN/SECRET assignment suppression; set off to disable this preflight.
3106
- npx @agent-native/core@latest recap mcp-smoke
3107
- Non-mutating Plan MCP JSON-RPC smoke test. Calls initialize + tools/list
3108
- against PLAN_RECAP_APP_URL / PLAN_RECAP_TOKEN and requires get-plan-blocks,
3109
- create-visual-recap, and set-resource-visibility to be exposed. Writes
3110
- ok=<true|false> and summary=<diagnostic> to $GITHUB_OUTPUT.
3132
+ npx @agent-native/core@latest recap block-reference
3133
+ Fetch the target Plan app's live get-plan-blocks reference over the public
3134
+ action route and write it to recap-blocks.md for the CI agent to read.
3135
+ npx @agent-native/core@latest recap publish
3136
+ Validate recap-source.json from the CI agent, publish it by POSTing the
3137
+ authenticated create-visual-recap action, and write recap-url.txt.
3111
3138
  npx @agent-native/core@latest recap setup
3112
3139
  Write/refresh .github/workflows/pr-visual-recap.yml, then configure GitHub
3113
3140
  Actions secrets and variables with gh when values are available from env or
@@ -3130,11 +3157,8 @@ export async function runRecap(argv) {
3130
3157
  case "collect-diff":
3131
3158
  runCollectDiff(args);
3132
3159
  return;
3133
- case "mcp-config":
3134
- runMcpConfig(args);
3135
- return;
3136
- case "mcp-smoke":
3137
- await runMcpSmoke(args);
3160
+ case "block-reference":
3161
+ await runBlockReference(args);
3138
3162
  return;
3139
3163
  case "scan":
3140
3164
  runScan(args);
@@ -3142,6 +3166,9 @@ export async function runRecap(argv) {
3142
3166
  case "build-prompt":
3143
3167
  runBuildPrompt(args);
3144
3168
  return;
3169
+ case "publish":
3170
+ await runPublish(args);
3171
+ return;
3145
3172
  case "shot":
3146
3173
  await runShot(args);
3147
3174
  return;