@agent-native/recap-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cli.d.ts +3 -0
  2. package/dist/cli.d.ts.map +1 -0
  3. package/dist/cli.js +10 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +3 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/openai-compatible-endpoint.d.ts +3 -0
  10. package/dist/openai-compatible-endpoint.d.ts.map +1 -0
  11. package/dist/openai-compatible-endpoint.js +23 -0
  12. package/dist/openai-compatible-endpoint.js.map +1 -0
  13. package/dist/plan-blocks.d.ts +19 -0
  14. package/dist/plan-blocks.d.ts.map +1 -0
  15. package/dist/plan-blocks.js +96 -0
  16. package/dist/plan-blocks.js.map +1 -0
  17. package/dist/plan-publish-store.d.ts +62 -0
  18. package/dist/plan-publish-store.d.ts.map +1 -0
  19. package/dist/plan-publish-store.js +128 -0
  20. package/dist/plan-publish-store.js.map +1 -0
  21. package/dist/pr-visual-recap-workflow.d.ts +3 -0
  22. package/dist/pr-visual-recap-workflow.d.ts.map +1 -0
  23. package/dist/pr-visual-recap-workflow.js +3 -0
  24. package/dist/pr-visual-recap-workflow.js.map +1 -0
  25. package/dist/recap.d.ts +565 -0
  26. package/dist/recap.d.ts.map +1 -0
  27. package/dist/recap.js +3877 -0
  28. package/dist/recap.js.map +1 -0
  29. package/dist/skill-content/connection.d.ts +2 -0
  30. package/dist/skill-content/connection.d.ts.map +1 -0
  31. package/dist/skill-content/connection.js +53 -0
  32. package/dist/skill-content/connection.js.map +1 -0
  33. package/dist/skill-content/local-files.d.ts +2 -0
  34. package/dist/skill-content/local-files.d.ts.map +1 -0
  35. package/dist/skill-content/local-files.js +101 -0
  36. package/dist/skill-content/local-files.js.map +1 -0
  37. package/dist/skill-content/visual-recap-skill.d.ts +2 -0
  38. package/dist/skill-content/visual-recap-skill.d.ts.map +1 -0
  39. package/dist/skill-content/visual-recap-skill.js +548 -0
  40. package/dist/skill-content/visual-recap-skill.js.map +1 -0
  41. package/dist/skill-content/wireframe.d.ts +4 -0
  42. package/dist/skill-content/wireframe.d.ts.map +1 -0
  43. package/dist/skill-content/wireframe.js +347 -0
  44. package/dist/skill-content/wireframe.js.map +1 -0
  45. package/dist/skill-content.d.ts +8 -0
  46. package/dist/skill-content.d.ts.map +1 -0
  47. package/dist/skill-content.js +11 -0
  48. package/dist/skill-content.js.map +1 -0
  49. package/package.json +50 -0
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { runRecap } from "./recap.js";
3
+ const argv = process.argv.slice(2);
4
+ if (argv[0] === "recap")
5
+ argv.shift();
6
+ runRecap(argv).catch((error) => {
7
+ console.error(error instanceof Error ? error.message : error);
8
+ process.exit(1);
9
+ });
10
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AACnC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,OAAO;IAAE,IAAI,CAAC,KAAK,EAAE,CAAC;AAEtC,QAAQ,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IACtC,OAAO,CAAC,KAAK,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC9D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC","sourcesContent":["#!/usr/bin/env node\n\nimport { runRecap } from \"./recap.js\";\n\nconst argv = process.argv.slice(2);\nif (argv[0] === \"recap\") argv.shift();\n\nrunRecap(argv).catch((error: unknown) => {\n console.error(error instanceof Error ? error.message : error);\n process.exit(1);\n});\n"]}
@@ -0,0 +1,3 @@
1
+ export * from "./recap.js";
2
+ export { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,4BAA4B,EAAE,MAAM,+BAA+B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./recap.js";
2
+ export { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,4BAA4B,EAAE,MAAM,+BAA+B,CAAC","sourcesContent":["export * from \"./recap.js\";\nexport { PR_VISUAL_RECAP_WORKFLOW_YML } from \"./pr-visual-recap-workflow.js\";\n"]}
@@ -0,0 +1,3 @@
1
+ export declare const OPENAI_BASE_URL_ENV_VAR = "OPENAI_BASE_URL";
2
+ export declare function normalizeOpenAiBaseUrl(value: string): string;
3
+ //# sourceMappingURL=openai-compatible-endpoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai-compatible-endpoint.d.ts","sourceRoot":"","sources":["../src/openai-compatible-endpoint.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,uBAAuB,oBAAoB,CAAC;AAEzD,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAsB5D"}
@@ -0,0 +1,23 @@
1
+ export const OPENAI_BASE_URL_ENV_VAR = "OPENAI_BASE_URL";
2
+ export function normalizeOpenAiBaseUrl(value) {
3
+ const trimmed = value.trim();
4
+ if (!trimmed) {
5
+ throw new Error("Endpoint URL is required.");
6
+ }
7
+ let url;
8
+ try {
9
+ url = new URL(trimmed);
10
+ }
11
+ catch {
12
+ throw new Error("Endpoint URL must be a valid URL.");
13
+ }
14
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
15
+ throw new Error("Endpoint URL must start with http:// or https://.");
16
+ }
17
+ if (url.username || url.password) {
18
+ throw new Error("Endpoint URL must not include credentials.");
19
+ }
20
+ url.hash = "";
21
+ return url.toString().replace(/\/+$/, "");
22
+ }
23
+ //# sourceMappingURL=openai-compatible-endpoint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai-compatible-endpoint.js","sourceRoot":"","sources":["../src/openai-compatible-endpoint.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,uBAAuB,GAAG,iBAAiB,CAAC;AAEzD,MAAM,UAAU,sBAAsB,CAAC,KAAa;IAClD,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;IAC/C,CAAC;IAED,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;IACvD,CAAC;IAED,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAC1D,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;IACvE,CAAC;IACD,IAAI,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,QAAQ,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;IAChE,CAAC;IAED,GAAG,CAAC,IAAI,GAAG,EAAE,CAAC;IACd,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;AAC5C,CAAC","sourcesContent":["export const OPENAI_BASE_URL_ENV_VAR = \"OPENAI_BASE_URL\";\n\nexport function normalizeOpenAiBaseUrl(value: string): string {\n const trimmed = value.trim();\n if (!trimmed) {\n throw new Error(\"Endpoint URL is required.\");\n }\n\n let url: URL;\n try {\n url = new URL(trimmed);\n } catch {\n throw new Error(\"Endpoint URL must be a valid URL.\");\n }\n\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new Error(\"Endpoint URL must start with http:// or https://.\");\n }\n if (url.username || url.password) {\n throw new Error(\"Endpoint URL must not include credentials.\");\n }\n\n url.hash = \"\";\n return url.toString().replace(/\\/+$/, \"\");\n}\n"]}
@@ -0,0 +1,19 @@
1
+ export declare const DEFAULT_PLAN_APP_URL = "https://plan.agent-native.com";
2
+ export type PlanBlockFormat = "reference" | "schema";
3
+ export type FetchPlanBlockCatalogInput = {
4
+ appUrl?: string;
5
+ out?: string;
6
+ format?: PlanBlockFormat;
7
+ fetchFn?: typeof fetch;
8
+ };
9
+ export type FetchPlanBlockCatalogResult = {
10
+ ok: true;
11
+ out: string;
12
+ count?: number;
13
+ format: PlanBlockFormat;
14
+ };
15
+ export declare function planActionEndpoint(appUrl: string, action: string): string;
16
+ export declare function normalizePlanBlockFormat(value: string | undefined): PlanBlockFormat;
17
+ export declare function defaultPlanBlocksOut(format: PlanBlockFormat): string;
18
+ export declare function fetchPlanBlockCatalog(input: FetchPlanBlockCatalogInput): Promise<FetchPlanBlockCatalogResult>;
19
+ //# sourceMappingURL=plan-blocks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan-blocks.d.ts","sourceRoot":"","sources":["../src/plan-blocks.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB,kCAAkC,CAAC;AAIpE,MAAM,MAAM,eAAe,GAAG,WAAW,GAAG,QAAQ,CAAC;AAErD,MAAM,MAAM,0BAA0B,GAAG;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,EAAE,EAAE,IAAI,CAAC;IACT,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,CAAC;CACzB,CAAC;AAEF,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEzE;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,MAAM,GAAG,SAAS,GACxB,eAAe,CAIjB;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAEpE;AAkDD,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,0BAA0B,GAChC,OAAO,CAAC,2BAA2B,CAAC,CAqDtC"}
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export const DEFAULT_PLAN_APP_URL = "https://plan.agent-native.com";
4
+ const PLAN_BLOCKS_HTTP_TIMEOUT_MS = 45_000;
5
+ export function planActionEndpoint(appUrl, action) {
6
+ return `${appUrl.replace(/\/$/, "")}/_agent-native/actions/${action}`;
7
+ }
8
+ export function normalizePlanBlockFormat(value) {
9
+ if (!value || value === "reference")
10
+ return "reference";
11
+ if (value === "schema")
12
+ return "schema";
13
+ throw new Error(`Invalid --format "${value}" (expected reference or schema)`);
14
+ }
15
+ export function defaultPlanBlocksOut(format) {
16
+ return format === "schema" ? "plan-blocks.schema.json" : "plan-blocks.md";
17
+ }
18
+ function sanitizeFetchDetail(value, maxLength) {
19
+ const normalized = value.replace(/\s+/g, " ").trim();
20
+ if (normalized.length <= maxLength)
21
+ return normalized;
22
+ return `${normalized.slice(0, maxLength - 3)}...`;
23
+ }
24
+ function shouldRetryPlanBlocks(status) {
25
+ return (status === 404 ||
26
+ status === 408 ||
27
+ status === 409 ||
28
+ status === 425 ||
29
+ status === 429 ||
30
+ status >= 500);
31
+ }
32
+ function delay(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
+ }
35
+ async function fetchWithTimeout(url, init, fetchFn) {
36
+ return await fetchFn(url, {
37
+ ...init,
38
+ signal: init.signal ?? AbortSignal.timeout(PLAN_BLOCKS_HTTP_TIMEOUT_MS),
39
+ });
40
+ }
41
+ function renderBlockCatalogOutput(json, format) {
42
+ if (format === "schema") {
43
+ if (!Array.isArray(json.blocks)) {
44
+ throw new Error("get-plan-blocks returned no schema blocks.");
45
+ }
46
+ return `${JSON.stringify({ count: json.count, blocks: json.blocks }, null, 2)}\n`;
47
+ }
48
+ if (typeof json.reference !== "string" || json.reference.length === 0) {
49
+ throw new Error("get-plan-blocks returned no reference text.");
50
+ }
51
+ return json.reference;
52
+ }
53
+ export async function fetchPlanBlockCatalog(input) {
54
+ const fetchFn = input.fetchFn ?? fetch;
55
+ const appUrl = input.appUrl ?? DEFAULT_PLAN_APP_URL;
56
+ const format = input.format ?? "reference";
57
+ const out = input.out ?? defaultPlanBlocksOut(format);
58
+ const endpoint = new URL(planActionEndpoint(appUrl, "get-plan-blocks"));
59
+ endpoint.searchParams.set("format", format);
60
+ let lastError = "";
61
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
62
+ try {
63
+ const response = await fetchWithTimeout(endpoint.toString(), { method: "GET", headers: { accept: "application/json" } }, fetchFn);
64
+ if (!response.ok) {
65
+ const detail = await response.text().catch(() => "");
66
+ lastError = `get-plan-blocks failed ${response.status} ${response.statusText}: ${sanitizeFetchDetail(detail, 500)}`;
67
+ if (attempt < 4 && shouldRetryPlanBlocks(response.status)) {
68
+ await delay(attempt * 1500);
69
+ continue;
70
+ }
71
+ throw new Error(lastError);
72
+ }
73
+ const json = (await response.json().catch(() => null));
74
+ if (!json)
75
+ throw new Error("get-plan-blocks returned invalid JSON.");
76
+ const text = renderBlockCatalogOutput(json, format);
77
+ fs.writeFileSync(path.resolve(out), text, "utf-8");
78
+ return {
79
+ ok: true,
80
+ out,
81
+ count: typeof json.count === "number" ? json.count : undefined,
82
+ format,
83
+ };
84
+ }
85
+ catch (err) {
86
+ lastError = err instanceof Error ? err.message : String(err);
87
+ if (attempt < 4) {
88
+ await delay(attempt * 1500);
89
+ continue;
90
+ }
91
+ throw new Error(lastError);
92
+ }
93
+ }
94
+ throw new Error(lastError || "get-plan-blocks failed.");
95
+ }
96
+ //# sourceMappingURL=plan-blocks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan-blocks.js","sourceRoot":"","sources":["../src/plan-blocks.ts"],"names":[],"mappings":"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","sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\n\nexport const DEFAULT_PLAN_APP_URL = \"https://plan.agent-native.com\";\n\nconst PLAN_BLOCKS_HTTP_TIMEOUT_MS = 45_000;\n\nexport type PlanBlockFormat = \"reference\" | \"schema\";\n\nexport type FetchPlanBlockCatalogInput = {\n appUrl?: string;\n out?: string;\n format?: PlanBlockFormat;\n fetchFn?: typeof fetch;\n};\n\nexport type FetchPlanBlockCatalogResult = {\n ok: true;\n out: string;\n count?: number;\n format: PlanBlockFormat;\n};\n\nexport function planActionEndpoint(appUrl: string, action: string): string {\n return `${appUrl.replace(/\\/$/, \"\")}/_agent-native/actions/${action}`;\n}\n\nexport function normalizePlanBlockFormat(\n value: string | undefined,\n): PlanBlockFormat {\n if (!value || value === \"reference\") return \"reference\";\n if (value === \"schema\") return \"schema\";\n throw new Error(`Invalid --format \"${value}\" (expected reference or schema)`);\n}\n\nexport function defaultPlanBlocksOut(format: PlanBlockFormat): string {\n return format === \"schema\" ? \"plan-blocks.schema.json\" : \"plan-blocks.md\";\n}\n\nfunction sanitizeFetchDetail(value: string, maxLength: number): string {\n const normalized = value.replace(/\\s+/g, \" \").trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength - 3)}...`;\n}\n\nfunction shouldRetryPlanBlocks(status: number): boolean {\n return (\n status === 404 ||\n status === 408 ||\n status === 409 ||\n status === 425 ||\n status === 429 ||\n status >= 500\n );\n}\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nasync function fetchWithTimeout(\n url: string,\n init: RequestInit,\n fetchFn: typeof fetch,\n): Promise<Response> {\n return await fetchFn(url, {\n ...init,\n signal: init.signal ?? AbortSignal.timeout(PLAN_BLOCKS_HTTP_TIMEOUT_MS),\n });\n}\n\nfunction renderBlockCatalogOutput(\n json: { reference?: unknown; blocks?: unknown; count?: unknown },\n format: PlanBlockFormat,\n): string {\n if (format === \"schema\") {\n if (!Array.isArray(json.blocks)) {\n throw new Error(\"get-plan-blocks returned no schema blocks.\");\n }\n return `${JSON.stringify({ count: json.count, blocks: json.blocks }, null, 2)}\\n`;\n }\n if (typeof json.reference !== \"string\" || json.reference.length === 0) {\n throw new Error(\"get-plan-blocks returned no reference text.\");\n }\n return json.reference;\n}\n\nexport async function fetchPlanBlockCatalog(\n input: FetchPlanBlockCatalogInput,\n): Promise<FetchPlanBlockCatalogResult> {\n const fetchFn = input.fetchFn ?? fetch;\n const appUrl = input.appUrl ?? DEFAULT_PLAN_APP_URL;\n const format = input.format ?? \"reference\";\n const out = input.out ?? defaultPlanBlocksOut(format);\n const endpoint = new URL(planActionEndpoint(appUrl, \"get-plan-blocks\"));\n endpoint.searchParams.set(\"format\", format);\n\n let lastError = \"\";\n for (let attempt = 1; attempt <= 4; attempt += 1) {\n try {\n const response = await fetchWithTimeout(\n endpoint.toString(),\n { method: \"GET\", headers: { accept: \"application/json\" } },\n fetchFn,\n );\n if (!response.ok) {\n const detail = await response.text().catch(() => \"\");\n lastError = `get-plan-blocks failed ${response.status} ${\n response.statusText\n }: ${sanitizeFetchDetail(detail, 500)}`;\n if (attempt < 4 && shouldRetryPlanBlocks(response.status)) {\n await delay(attempt * 1500);\n continue;\n }\n throw new Error(lastError);\n }\n\n const json = (await response.json().catch(() => null)) as {\n reference?: unknown;\n blocks?: unknown;\n count?: unknown;\n } | null;\n if (!json) throw new Error(\"get-plan-blocks returned invalid JSON.\");\n const text = renderBlockCatalogOutput(json, format);\n fs.writeFileSync(path.resolve(out), text, \"utf-8\");\n return {\n ok: true,\n out,\n count: typeof json.count === \"number\" ? json.count : undefined,\n format,\n };\n } catch (err) {\n lastError = err instanceof Error ? err.message : String(err);\n if (attempt < 4) {\n await delay(attempt * 1500);\n continue;\n }\n throw new Error(lastError);\n }\n }\n\n throw new Error(lastError || \"get-plan-blocks failed.\");\n}\n"]}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Canonical publish-token store for the local Plans server.
3
+ *
4
+ * `agent-native connect <hosted-url>` mints a bearer token and writes it into
5
+ * each coding agent's per-client MCP config (`.mcp.json` / Codex `config.toml`
6
+ * via `mcp-config-writers`). Those files are client-specific, so the local Plans
7
+ * server cannot read them for a server-to-server publish.
8
+ *
9
+ * To close that seam, the connect flow ALSO writes a single canonical record to
10
+ * `~/.agent-native/plan-publish.json` whenever it authenticates a first-party
11
+ * Plans app. The local server's `publish-visual-plan` action reads the exact
12
+ * same file (see `templates/plan/server/lib/plan-publish.ts`):
13
+ *
14
+ * { "url": "https://plan.agent-native.com", "token": "<bearer>" }
15
+ *
16
+ * This mirrors the existing device-token precedent in
17
+ * `code-agent-connector.ts` (`~/.agent-native/remote-device.json`): home-dir
18
+ * JSON, env-overridable path, atomic 0600 write. The write is additive — it
19
+ * merges into any existing file rather than clobbering sibling keys — and
20
+ * best-effort, since persisting MCP config is the primary contract and a failed
21
+ * canonical write must never fail the connect.
22
+ */
23
+ /**
24
+ * Absolute path to the canonical publish-token file. Honors
25
+ * `PLAN_PUBLISH_CONFIG_PATH` so connect and the local server agree on the
26
+ * location in tests and custom setups; defaults to
27
+ * `~/.agent-native/plan-publish.json`.
28
+ */
29
+ export declare function planPublishConfigPath(): string;
30
+ /**
31
+ * Whether `url`'s host is the first-party Agent-Native Plans app whose token
32
+ * we should mirror to the canonical publish file. Only the hosted Plans app
33
+ * (`plan.agent-native.com`) qualifies — mirroring tokens for other
34
+ * agent-native subdomains (assets, mail, …) would silently overwrite the
35
+ * canonical Plans endpoint with the wrong URL+token each time `connect --all`
36
+ * runs last-write-wins. A custom self-hosted origin (ngrok, localhost, a
37
+ * private deployment) is intentionally excluded: the user can still point the
38
+ * server at it via `PLAN_PUBLISH_URL` / `PLAN_PUBLISH_TOKEN` env vars.
39
+ */
40
+ export declare function isFirstPartyPlanHost(url: string): boolean;
41
+ /**
42
+ * Merge `{ url, token }` into the canonical publish file without clobbering any
43
+ * other keys the file already holds. Best-effort: returns the written path on
44
+ * success, or `null` if the write failed or the inputs were unusable.
45
+ *
46
+ * `filePath` is injectable for tests; production callers omit it and get the
47
+ * env-overridable home-dir path.
48
+ */
49
+ export declare function writePlanPublishAuth(params: {
50
+ url: string;
51
+ token: string;
52
+ }, filePath?: string): string | null;
53
+ /**
54
+ * Read the canonical Plans publish auth written by `agent-native connect`.
55
+ * Returns `null` for missing/corrupt/incomplete files so callers can treat the
56
+ * publish token as optional and guide the user to reconnect.
57
+ */
58
+ export declare function readPlanPublishAuth(filePath?: string): {
59
+ url: string;
60
+ token: string;
61
+ } | null;
62
+ //# sourceMappingURL=plan-publish-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan-publish-store.d.ts","sourceRoot":"","sources":["../src/plan-publish-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AASH;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,CAK9C;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAOzD;AAMD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EACtC,QAAQ,GAAE,MAAgC,GACzC,MAAM,GAAG,IAAI,CAmCf;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,GAAE,MAAgC,GACzC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAgBvC"}
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Canonical publish-token store for the local Plans server.
3
+ *
4
+ * `agent-native connect <hosted-url>` mints a bearer token and writes it into
5
+ * each coding agent's per-client MCP config (`.mcp.json` / Codex `config.toml`
6
+ * via `mcp-config-writers`). Those files are client-specific, so the local Plans
7
+ * server cannot read them for a server-to-server publish.
8
+ *
9
+ * To close that seam, the connect flow ALSO writes a single canonical record to
10
+ * `~/.agent-native/plan-publish.json` whenever it authenticates a first-party
11
+ * Plans app. The local server's `publish-visual-plan` action reads the exact
12
+ * same file (see `templates/plan/server/lib/plan-publish.ts`):
13
+ *
14
+ * { "url": "https://plan.agent-native.com", "token": "<bearer>" }
15
+ *
16
+ * This mirrors the existing device-token precedent in
17
+ * `code-agent-connector.ts` (`~/.agent-native/remote-device.json`): home-dir
18
+ * JSON, env-overridable path, atomic 0600 write. The write is additive — it
19
+ * merges into any existing file rather than clobbering sibling keys — and
20
+ * best-effort, since persisting MCP config is the primary contract and a failed
21
+ * canonical write must never fail the connect.
22
+ */
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ /** Matches the env override read by `templates/plan/server/lib/plan-publish.ts`. */
27
+ const CONFIG_PATH_ENV = "PLAN_PUBLISH_CONFIG_PATH";
28
+ /**
29
+ * Absolute path to the canonical publish-token file. Honors
30
+ * `PLAN_PUBLISH_CONFIG_PATH` so connect and the local server agree on the
31
+ * location in tests and custom setups; defaults to
32
+ * `~/.agent-native/plan-publish.json`.
33
+ */
34
+ export function planPublishConfigPath() {
35
+ return path.resolve(process.env[CONFIG_PATH_ENV] ??
36
+ path.join(os.homedir(), ".agent-native", "plan-publish.json"));
37
+ }
38
+ /**
39
+ * Whether `url`'s host is the first-party Agent-Native Plans app whose token
40
+ * we should mirror to the canonical publish file. Only the hosted Plans app
41
+ * (`plan.agent-native.com`) qualifies — mirroring tokens for other
42
+ * agent-native subdomains (assets, mail, …) would silently overwrite the
43
+ * canonical Plans endpoint with the wrong URL+token each time `connect --all`
44
+ * runs last-write-wins. A custom self-hosted origin (ngrok, localhost, a
45
+ * private deployment) is intentionally excluded: the user can still point the
46
+ * server at it via `PLAN_PUBLISH_URL` / `PLAN_PUBLISH_TOKEN` env vars.
47
+ */
48
+ export function isFirstPartyPlanHost(url) {
49
+ try {
50
+ const host = new URL(url).hostname.toLowerCase();
51
+ return host === "plan.agent-native.com";
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ function stripTrailingSlash(url) {
58
+ return url.replace(/\/+$/, "");
59
+ }
60
+ /**
61
+ * Merge `{ url, token }` into the canonical publish file without clobbering any
62
+ * other keys the file already holds. Best-effort: returns the written path on
63
+ * success, or `null` if the write failed or the inputs were unusable.
64
+ *
65
+ * `filePath` is injectable for tests; production callers omit it and get the
66
+ * env-overridable home-dir path.
67
+ */
68
+ export function writePlanPublishAuth(params, filePath = planPublishConfigPath()) {
69
+ const url = stripTrailingSlash((params.url ?? "").trim());
70
+ const token = (params.token ?? "").trim();
71
+ if (!url || !token)
72
+ return null;
73
+ try {
74
+ let existing = {};
75
+ try {
76
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
77
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
78
+ existing = raw;
79
+ }
80
+ }
81
+ catch {
82
+ // No existing file (or unreadable/corrupt) — start fresh.
83
+ }
84
+ const merged = {
85
+ ...existing,
86
+ url,
87
+ token,
88
+ updatedAt: new Date().toISOString(),
89
+ };
90
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
91
+ const tmp = `${filePath}.tmp`;
92
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + "\n", {
93
+ mode: 0o600,
94
+ });
95
+ fs.renameSync(tmp, filePath);
96
+ return filePath;
97
+ }
98
+ catch {
99
+ // Best-effort: the per-client MCP config is the primary write. A failed
100
+ // canonical write must never fail the connect flow.
101
+ return null;
102
+ }
103
+ }
104
+ /**
105
+ * Read the canonical Plans publish auth written by `agent-native connect`.
106
+ * Returns `null` for missing/corrupt/incomplete files so callers can treat the
107
+ * publish token as optional and guide the user to reconnect.
108
+ */
109
+ export function readPlanPublishAuth(filePath = planPublishConfigPath()) {
110
+ try {
111
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
112
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
113
+ return null;
114
+ }
115
+ const record = parsed;
116
+ const urlValue = record.url ?? record.baseUrl ?? record.hostedUrl;
117
+ const tokenValue = record.token ?? record.accessToken ?? record.bearerToken;
118
+ const url = typeof urlValue === "string" ? urlValue.trim() : "";
119
+ const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
120
+ if (!url || !token)
121
+ return null;
122
+ return { url: stripTrailingSlash(url), token };
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ //# sourceMappingURL=plan-publish-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan-publish-store.js","sourceRoot":"","sources":["../src/plan-publish-store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Canonical publish-token store for the local Plans server.\n *\n * `agent-native connect <hosted-url>` mints a bearer token and writes it into\n * each coding agent's per-client MCP config (`.mcp.json` / Codex `config.toml`\n * via `mcp-config-writers`). Those files are client-specific, so the local Plans\n * server cannot read them for a server-to-server publish.\n *\n * To close that seam, the connect flow ALSO writes a single canonical record to\n * `~/.agent-native/plan-publish.json` whenever it authenticates a first-party\n * Plans app. The local server's `publish-visual-plan` action reads the exact\n * same file (see `templates/plan/server/lib/plan-publish.ts`):\n *\n * { \"url\": \"https://plan.agent-native.com\", \"token\": \"<bearer>\" }\n *\n * This mirrors the existing device-token precedent in\n * `code-agent-connector.ts` (`~/.agent-native/remote-device.json`): home-dir\n * JSON, env-overridable path, atomic 0600 write. The write is additive — it\n * merges into any existing file rather than clobbering sibling keys — and\n * best-effort, since persisting MCP config is the primary contract and a failed\n * canonical write must never fail the connect.\n */\n\nimport fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\n/** Matches the env override read by `templates/plan/server/lib/plan-publish.ts`. */\nconst CONFIG_PATH_ENV = \"PLAN_PUBLISH_CONFIG_PATH\";\n\n/**\n * Absolute path to the canonical publish-token file. Honors\n * `PLAN_PUBLISH_CONFIG_PATH` so connect and the local server agree on the\n * location in tests and custom setups; defaults to\n * `~/.agent-native/plan-publish.json`.\n */\nexport function planPublishConfigPath(): string {\n return path.resolve(\n process.env[CONFIG_PATH_ENV] ??\n path.join(os.homedir(), \".agent-native\", \"plan-publish.json\"),\n );\n}\n\n/**\n * Whether `url`'s host is the first-party Agent-Native Plans app whose token\n * we should mirror to the canonical publish file. Only the hosted Plans app\n * (`plan.agent-native.com`) qualifies — mirroring tokens for other\n * agent-native subdomains (assets, mail, …) would silently overwrite the\n * canonical Plans endpoint with the wrong URL+token each time `connect --all`\n * runs last-write-wins. A custom self-hosted origin (ngrok, localhost, a\n * private deployment) is intentionally excluded: the user can still point the\n * server at it via `PLAN_PUBLISH_URL` / `PLAN_PUBLISH_TOKEN` env vars.\n */\nexport function isFirstPartyPlanHost(url: string): boolean {\n try {\n const host = new URL(url).hostname.toLowerCase();\n return host === \"plan.agent-native.com\";\n } catch {\n return false;\n }\n}\n\nfunction stripTrailingSlash(url: string): string {\n return url.replace(/\\/+$/, \"\");\n}\n\n/**\n * Merge `{ url, token }` into the canonical publish file without clobbering any\n * other keys the file already holds. Best-effort: returns the written path on\n * success, or `null` if the write failed or the inputs were unusable.\n *\n * `filePath` is injectable for tests; production callers omit it and get the\n * env-overridable home-dir path.\n */\nexport function writePlanPublishAuth(\n params: { url: string; token: string },\n filePath: string = planPublishConfigPath(),\n): string | null {\n const url = stripTrailingSlash((params.url ?? \"\").trim());\n const token = (params.token ?? \"\").trim();\n if (!url || !token) return null;\n\n try {\n let existing: Record<string, unknown> = {};\n try {\n const raw = JSON.parse(fs.readFileSync(filePath, \"utf-8\")) as unknown;\n if (raw && typeof raw === \"object\" && !Array.isArray(raw)) {\n existing = raw as Record<string, unknown>;\n }\n } catch {\n // No existing file (or unreadable/corrupt) — start fresh.\n }\n\n const merged = {\n ...existing,\n url,\n token,\n updatedAt: new Date().toISOString(),\n };\n\n fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });\n const tmp = `${filePath}.tmp`;\n fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + \"\\n\", {\n mode: 0o600,\n });\n fs.renameSync(tmp, filePath);\n return filePath;\n } catch {\n // Best-effort: the per-client MCP config is the primary write. A failed\n // canonical write must never fail the connect flow.\n return null;\n }\n}\n\n/**\n * Read the canonical Plans publish auth written by `agent-native connect`.\n * Returns `null` for missing/corrupt/incomplete files so callers can treat the\n * publish token as optional and guide the user to reconnect.\n */\nexport function readPlanPublishAuth(\n filePath: string = planPublishConfigPath(),\n): { url: string; token: string } | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(filePath, \"utf-8\")) as unknown;\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return null;\n }\n const record = parsed as Record<string, unknown>;\n const urlValue = record.url ?? record.baseUrl ?? record.hostedUrl;\n const tokenValue = record.token ?? record.accessToken ?? record.bearerToken;\n const url = typeof urlValue === \"string\" ? urlValue.trim() : \"\";\n const token = typeof tokenValue === \"string\" ? tokenValue.trim() : \"\";\n if (!url || !token) return null;\n return { url: stripTrailingSlash(url), token };\n } catch {\n return null;\n }\n}\n"]}
@@ -0,0 +1,3 @@
1
+ /** Canonical PR Visual Recap workflow bundled by the CLI installer. */
2
+ export declare const PR_VISUAL_RECAP_WORKFLOW_YML = "name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo's visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || 'claude' }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}\n\njobs:\n gate:\n name: Gate\n # A custom plain-label runner is allowed only for trusted same-repo authors.\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\n # any step starts. The only fromJSON input is this static association list.\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || 'ubuntu-latest') || 'ubuntu-latest' }}\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n runs_on: ${{ steps.decide.outputs.runs_on }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals \u2014 never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != '' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != '' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != '' }}\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != '' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || '\"ubuntu-latest\"' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push('no pull_request payload');\n if (pr && pr.draft) reasons.push('draft PR');\n if (pr && context.payload.action === 'closed' && !pr.merged) {\n reasons.push('closed without merge');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub's \"Send secrets to workflows from pull requests\" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip \u2014 with an actionable hint \u2014 those that don't.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || '').toUpperCase();\n const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n let configuredRunner = 'ubuntu-latest';\n let usesSelfHostedRunner = false;\n try {\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || '\"ubuntu-latest\"');\n const hosted = typeof candidate === 'string' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes('self-hosted') && candidate.every((label) => typeof label === 'string' && label.length >= 1 && label.length <= 100 && !/[\\u0000-\\u001f\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\n if (!hosted && !selfHosted) throw new Error('unsupported runner value');\n configuredRunner = candidate;\n usesSelfHostedRunner = selfHosted;\n } catch {\n reasons.push('invalid VISUAL_RECAP_RUNS_ON JSON');\n }\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\n reasons.push('self-hosted runner mode requires a trusted same-repository PR author');\n }\n if (isFork && process.env.HAS_PLAN !== 'true') {\n reasons.push(`fork PR (${headRepo}) without secret access \u2014 enable \"Send secrets to workflows from pull requests\" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\n }\n\n const login = (pr && pr.user && pr.user.login || '').toLowerCase();\n const botAuthors = ['dependabot[bot]', 'dependabot', 'renovate[bot]', 'renovate'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === 'Bot') reasons.push('bot author (type=Bot)');\n\n if (!isFork && process.env.HAS_PLAN !== 'true') reasons.push('PLAN_RECAP_TOKEN not configured');\n\n // Normalize + validate the agent so a mis-cased value can't pass the\n // gate and then match neither agent step below.\n const rawAgent = (process.env.AGENT || 'claude').toLowerCase();\n const agent = ['deepseek', 'kimi', 'moonshot', 'custom'].includes(rawAgent) ? 'openai-compatible' : rawAgent;\n if (!['claude', 'codex', 'openai-compatible'].includes(agent)) {\n reasons.push(`unsupported VISUAL_RECAP_AGENT \"${process.env.AGENT}\" (expected \"claude\", \"codex\", or \"openai-compatible\")`);\n } else if (agent === 'codex') {\n if (process.env.HAS_OPENAI !== 'true') reasons.push('OPENAI_API_KEY not configured (codex backend)');\n } else if (agent === 'claude') {\n if (process.env.HAS_ANTHROPIC !== 'true') reasons.push('ANTHROPIC_API_KEY not configured (claude backend)');\n } else {\n if (process.env.HAS_COMPATIBLE !== 'true') reasons.push('VISUAL_RECAP_API_KEY not configured (openai-compatible backend)');\n if (!(process.env.VISUAL_RECAP_MODEL || '').trim()) reasons.push('VISUAL_RECAP_MODEL is required (openai-compatible backend)');\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || '';\n try {\n const parsed = new URL(baseUrl);\n if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {\n reasons.push('VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials');\n }\n } catch {\n reasons.push('VISUAL_RECAP_BASE_URL must be a valid http(s) URL');\n }\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || '';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || 'auto').toLowerCase();\n if (!['auto', 'latest', 'repo'].includes(skillSource)) {\n reasons.push('invalid VISUAL_RECAP_SKILL_SOURCE value (expected \"auto\", \"latest\", or \"repo\")');\n }\n const usesRepoSkill = skillSource === 'repo';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can't rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for untrusted forks and untrusted public-repo PRs.\n // Trusted write actors may edit recap-control files as normal\n // reviewable content; running the recap is useful signal for those\n // changes.\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n p.startsWith('.claude/') ||\n p === 'CLAUDE.md' ||\n p === 'AGENTS.md' ||\n p === '.mcp.json';\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', \u2026' : ''}) \u2014 skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can't be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput('run', run ? 'true' : 'false');\n core.setOutput('agent', agent);\n core.setOutput('runs_on', JSON.stringify(configuredRunner));\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it's clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join('; ')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = '<!-- pr-visual-recap -->';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === 'Bot' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || '').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : 'latest push';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith('could not list PR files for the self-modifying guard')\n )[0] || reasons[0] || 'skipped';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n const baseBody = `${MARKER}\\n### Visual recap \u2014 skipped\\n\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\n const planIdMatch = (existing && existing.body ? existing.body : '').match(/<!--\\s*plan-id:\\s*([A-Za-z0-9_-]{1,64})\\s*-->/);\n const planIdMarker = planIdMatch ? `\\n\\n<!-- plan-id: ${planIdMatch[1]} -->` : '';\n const updatedBody = `${baseBody}${planIdMarker}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == 'true'\n runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}\n timeout-minutes: 30\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || 'https://plan.agent-native.com' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don't leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. \"1.2.3\"). Defaults to\n # \"latest\" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}\n run: |\n if [ \"$GITHUB_REPOSITORY\" = \"BuilderIO/agent-native\" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo \"local=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"local=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == 'true'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == 'true'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: \"22\"\n cache: ${{ steps.cli.outputs.local == 'true' && 'pnpm' || '' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == 'true'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n pnpm --filter @agent-native/recap-cli build\n echo \"RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\n echo \"CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\n echo \"RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright\" >> \"$GITHUB_ENV\"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != 'true'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}\n run: |\n set -euo pipefail\n VERSION=\"$RECAP_CLI_VERSION\"\n if [ \"$VERSION\" = \"latest\" ]; then\n VERSION=\"$(npm view @agent-native/recap-cli@latest version)\"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix \"$RUNNER_TEMP/recap-cli\" --no-audit --no-fund --ignore-scripts \"@agent-native/recap-cli@$VERSION\"; then\n break\n fi\n if [ \"$attempt\" = \"3\" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo \"RECAP_CLI_VERSION=$VERSION\" >> \"$GITHUB_ENV\"\n echo \"RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native\" >> \"$GITHUB_ENV\"\n echo \"RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright\" >> \"$GITHUB_ENV\"\n\n - name: Install OpenAI-compatible provider runtime\n if: needs.gate.outputs.agent == 'openai-compatible' && steps.cli.outputs.local != 'true'\n env:\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || 'latest' }}\n run: |\n set -euo pipefail\n CORE_VERSION=\"$CORE_CLI_VERSION\"\n if [ \"$CORE_VERSION\" = \"latest\" ]; then\n CORE_VERSION=\"$(npm view @agent-native/core@latest version)\"\n fi\n npm install --prefix \"$RUNNER_TEMP/recap-code\" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai \"@agent-native/core@$CORE_VERSION\"\n echo \"CORE_CLI_VERSION=$CORE_VERSION\" >> \"$GITHUB_ENV\"\n echo \"CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native\" >> \"$GITHUB_ENV\"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha \"$HEAD_SHA\" --workflow-url \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e \"${HEAD_SHA}^{commit}\" 2>/dev/null; then\n git update-ref refs/recap/pr-head \"$HEAD_SHA\"\n else\n AUTH_B64=\"$(printf 'x-access-token:%s' \"$GH_TOKEN\" | base64 | tr -d '\\n')\"\n git -c \"http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64\" fetch origin \"pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head\"\n fi\n FETCHED_SHA=\"$(git rev-parse refs/recap/pr-head)\"\n if [ \"$FETCHED_SHA\" != \"$HEAD_SHA\" ]; then\n echo \"FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA \u2014 aborting to avoid recapping the wrong commit\"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base \"$BASE_SHA\" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != 'true'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app's action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e '\n const https = require(\"https\");\n const url = new URL(\"/_agent-native/actions/record-recap-usage\", process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\");\n const req = https.request(url, { method: \"POST\", headers: { \"authorization\": \"Bearer \" + process.env.PLAN_RECAP_TOKEN, \"content-type\": \"application/json\" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on(\"error\", () => process.stdout.write(\"0\"));\n req.end(JSON.stringify({ planId: \"__probe__\" }));\n ' 2>/dev/null || echo \"0\")\n if [ \"$HTTP_STATUS\" = \"401\" ]; then\n echo \"auth_failed=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"auth_failed=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != 'true'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app's recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy \u2014 the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE=\"$1\" METHOD=\"$2\" node -e '\n const https = require(\"https\");\n const base = process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === \"GET\") url.searchParams.set(\"format\", \"reference\");\n const req = https.request(url, { method: process.env.METHOD, headers: { \"authorization\": \"Bearer \" + (process.env.PLAN_RECAP_TOKEN || \"\"), \"content-type\": \"application/json\" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on(\"error\", () => process.stdout.write(\"0\"));\n req.on(\"timeout\", () => { process.stdout.write(\"0\"); req.destroy(); });\n if (process.env.METHOD === \"POST\") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n ' 2>/dev/null || echo \"0\"\n }\n CREATE_STATUS=\"$(probe_status /_agent-native/actions/create-visual-recap POST)\"\n BLOCKS_STATUS=\"$(probe_status /_agent-native/actions/get-plan-blocks GET)\"\n REASON=\"\"\n if [ \"$CREATE_STATUS\" = \"404\" ] || [ \"$BLOCKS_STATUS\" = \"404\" ]; then\n REASON=\"Plan app routes return 404 \u2014 deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating.\"\n echo \"::error::$REASON\"\n echo \"unhealthy=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"unhealthy=false\" >> \"$GITHUB_OUTPUT\"\n fi\n {\n echo 'reason<<__RECAP_ROUTE_HEALTH_EOF__'\n echo \"$REASON\"\n echo '__RECAP_ROUTE_HEALTH_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON=\"$($RECAP_CLI recap scan --diff recap.diff --mode \"$VISUAL_RECAP_SECRET_SCAN\")\"; then\n SCAN_JSON='{\"suppressed\":true,\"reason\":\"secret scan failed to run; failing closed\"}'\n fi\n {\n echo 'json<<__RECAP_SCAN_EOF__'\n echo \"$SCAN_JSON\"\n echo '__RECAP_SCAN_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n SUPPRESSED=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?\"true\":\"false\")}catch{process.stdout.write(\"true\")}' \"$SCAN_JSON\")\n echo \"suppressed=$SUPPRESSED\" >> \"$GITHUB_OUTPUT\"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID=\"$($RECAP_CLI recap comment find-plan-id --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\")\"\n echo \"plan_id=$PLAN_ID\" >> \"$GITHUB_OUTPUT\"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url \"$PLAN_RECAP_APP_URL\" --out recap-blocks.md; then\n echo \"ok=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"ok=false\" >> \"$GITHUB_OUTPUT\"\n {\n echo 'summary<<__RECAP_BLOCK_REFERENCE_EOF__'\n echo \"Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the publisher will validate the final MDX.\"\n echo '__RECAP_BLOCK_REFERENCE_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n cat > recap-blocks.md <<'EOF'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr \"$PR_NUMBER\" --repo \"$GITHUB_REPOSITORY\" --head \"$HEAD_SHA\" --app-url \"$PLAN_RECAP_APP_URL\" --skill-source \"$VISUAL_RECAP_SKILL_SOURCE\" --out recap-prompt.md)\n if [ \"${DIFF_HUGE:-}\" = \"true\" ]; then ARGS+=(--huge); fi\n if [ \"${IS_FORK:-}\" = \"true\" ]; then ARGS+=(--fork-pr true); fi\n if [ -n \"${PREV_PLAN_ID:-}\" ]; then ARGS+=(--prev-plan-id \"$PREV_PLAN_ID\"); fi\n $RECAP_CLI recap build-prompt \"${ARGS[@]}\"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == 'claude' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS=\"Read,Write,Bash(git diff:*)\"\n CLAUDE_ARGS=(-p \"$(cat recap-prompt.md)\" --allowedTools \"$CLAUDE_ALLOWED_TOOLS\" --permission-mode dontAsk --output-format json)\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CLAUDE_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS=\"$?\"\n set -e\n echo \"$CLAUDE_STATUS\" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # \"retry me\" signal \u2014 the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' claude-result.json claude-stderr.log 2>/dev/null; then\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\n else\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\n sleep 5\n run_claude\n fi\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == 'codex' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex's own sandbox\n # (bubblewrap can't init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CODEX_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case \"${VISUAL_RECAP_REASONING:-}\" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c \"model_reasoning_effort=\\\"$VISUAL_RECAP_REASONING\\\"\") ;;\n \"\") ;;\n *) echo \"Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING\" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-prompt.md)\" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS=\"${PIPESTATUS[0]}\"\n set -e\n echo \"$CODEX_STATUS\" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) \u2014 the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' codex-events.jsonl codex-stderr.log 2>/dev/null; then\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\n else\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\n sleep 5\n run_codex\n fi\n fi\n\n - name: Run agent (OpenAI-compatible)\n id: openai_compatible\n if: needs.gate.outputs.agent == 'openai-compatible' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\n run_openai_compatible() {\n set +e\n $CODE_CLI code exec --permission-mode auto-edit \"$(cat recap-prompt.md)\" > openai-compatible-result.txt 2> openai-compatible-stderr.log\n OPENAI_COMPATIBLE_STATUS=\"$?\"\n set -e\n echo \"$OPENAI_COMPATIBLE_STATUS\" > openai-compatible-exit-code.txt\n }\n run_openai_compatible\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\n else\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\n sleep 5\n run_openai_compatible\n fi\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo \"$GITHUB_REPOSITORY\" --pr \"$PR_NUMBER\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\")\n if [ -n \"${PREV_PLAN_ID:-}\" ]; then ARGS+=(--prev-plan-id \"$PREV_PLAN_ID\"); fi\n ARGS+=(--source-type pull-request --source-repo \"$GITHUB_REPOSITORY\" --source-pr-number \"$PR_NUMBER\")\n if [ \"${PR_MERGED:-false}\" = \"true\" ] || [ -n \"${PR_MERGED_AT:-}\" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n \"${PR_STATE:-}\" ]; then\n ARGS+=(--source-pr-state \"$PR_STATE\")\n fi\n if [ -n \"${PR_MERGED_AT:-}\" ]; then ARGS+=(--source-pr-merged-at \"$PR_MERGED_AT\"); fi\n $RECAP_CLI recap publish \"${ARGS[@]}\"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n run: |\n set -uo pipefail\n PLAN_URL=\"\"\n URL_REASON=\"\"\n if [ -f recap-url.txt ]; then\n PLAN_URL=\"$(tr -d '\\r\\n' < recap-url.txt | tr -d ' ')\"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON=\"$(cat recap-url-reason.txt)\"\n else\n URL_REASON=\"recap-url.txt was not created.\"\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z \"$URL_REASON\" ]; then\n URL_RESULT=$(PLAN_URL=\"$PLAN_URL\" node <<'NODE'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || \"\";\n if (!raw) {\n emit({ url: \"\", reason: \"recap-url.txt was empty\" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: \"\", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, \"\");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || \"/\");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: \"\" });\n process.exit(0);\n }\n }\n emit({ url: \"\", reason: \"recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app\" });\n } catch {\n emit({ url: \"\", reason: \"recap-url.txt was not a valid URL or recap path\" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).url||\"\")}catch{process.stdout.write(\"\")}' \"$URL_RESULT\")\n URL_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||\"\")}catch{process.stdout.write(\"recap-url.txt URL validation failed\")}' \"$URL_RESULT\")\n else\n CANONICAL_URL=\"\"\n fi\n if [ -n \"$CANONICAL_URL\" ]; then\n echo \"plan_url=$CANONICAL_URL\" >> \"$GITHUB_OUTPUT\"; echo \"ok=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"plan_url=\" >> \"$GITHUB_OUTPUT\"; echo \"ok=false\" >> \"$GITHUB_OUTPUT\"\n fi\n {\n echo 'reason<<__RECAP_URL_REASON_EOF__'\n echo \"$URL_REASON\"\n echo '__RECAP_URL_REASON_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != 'true' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ \"$RECAP_AGENT\" = \"codex\" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n elif [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then\n RESULT=openai-compatible-result.txt\n STDERR=openai-compatible-stderr.log\n EXIT_CODE=openai-compatible-exit-code.txt\n fi\n SUMMARY_JSON=\"$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent \"$RECAP_AGENT\" --result-file \"$RESULT\" --stderr-file \"$STDERR\" --exit-code-file \"$EXIT_CODE\" || echo '{}')\"\n SUMMARY=\"$(node -e 'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === \"string\" ? value : \"\"); } catch {}' \"$SUMMARY_JSON\")\"\n if [ -n \"$SUMMARY\" ]; then\n {\n echo 'summary<<__RECAP_AGENT_SUMMARY_EOF__'\n echo \"$SUMMARY\"\n echo '__RECAP_AGENT_SUMMARY_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n elif [ -n \"${RECAP_BLOCK_REFERENCE_SUMMARY:-}\" ]; then\n {\n echo 'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__'\n echo \"$RECAP_BLOCK_REFERENCE_SUMMARY\"\n echo '__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n elif [ -n \"${RECAP_PUBLISH_REASON:-}\" ]; then\n {\n echo 'summary<<__RECAP_PUBLISH_SUMMARY_EOF__'\n echo \"$RECAP_PUBLISH_REASON\"\n echo '__RECAP_PUBLISH_SUMMARY_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n fi\n\n - name: Attach usage\n if: steps.url.outputs.ok == 'true'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so \"Codex\" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ \"$RECAP_AGENT\" = \"codex\" ]; then RESULT=codex-events.jsonl; fi\n if [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then RESULT=openai-compatible-usage.json; fi\n if [ -f \"$RESULT\" ]; then $RECAP_CLI recap usage --plan-url \"$PLAN_URL\" --agent \"$RECAP_AGENT\" --result-file \"$RESULT\" --model \"${VISUAL_RECAP_MODEL:-}\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\" || true; fi\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == 'true'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == 'true'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n \"${RECAP_PLAYWRIGHT:-}\" ] && [ -x \"$RECAP_PLAYWRIGHT\" ]; then\n \"$RECAP_PLAYWRIGHT\" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n IMAGE_CACHE_KEY=\"$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT\"\n LIGHT_SHOT_JSON=\"$($RECAP_CLI recap shot --url \"$PLAN_URL\" --token \"$PLAN_RECAP_TOKEN\" --app-url \"$PLAN_RECAP_APP_URL\" --out recap.png --theme light --image-cache-key \"$IMAGE_CACHE_KEY\" || echo '{}')\"\n DARK_SHOT_JSON=\"$($RECAP_CLI recap shot --url \"$PLAN_URL\" --token \"$PLAN_RECAP_TOKEN\" --app-url \"$PLAN_RECAP_APP_URL\" --out recap-dark.png --theme dark --image-cache-key \"$IMAGE_CACHE_KEY\" || echo '{}')\"\n for SHOT_LABEL in light dark; do\n if [ \"$SHOT_LABEL\" = \"light\" ]; then SHOT_JSON=\"$LIGHT_SHOT_JSON\"; else SHOT_JSON=\"$DARK_SHOT_JSON\"; fi\n SHOT_LABEL=\"$SHOT_LABEL\" SHOT_JSON=\"$SHOT_JSON\" node -e 'const label = process.env.SHOT_LABEL || \"shot\"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || \"{}\"); } catch { parsed = { ok: false, reason: \"invalid shot JSON\" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? \"[present]\" : \"\", out: typeof parsed.out === \"string\" ? parsed.out : \"\", reason: typeof parsed.reason === \"string\" ? parsed.reason.slice(0, 500) : \"\" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);'\n done\n IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||\"\")}catch{process.stdout.write(\"\")}' \"$LIGHT_SHOT_JSON\")\n DARK_IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||\"\")}catch{process.stdout.write(\"\")}' \"$DARK_SHOT_JSON\")\n SHOT_STATUS=$(LIGHT_SHOT_JSON=\"$LIGHT_SHOT_JSON\" DARK_SHOT_JSON=\"$DARK_SHOT_JSON\" node <<'NODE'\n const parse = (raw) => { try { return JSON.parse(raw || \"{}\"); } catch { return { ok: false, reason: \"invalid shot JSON\" }; } };\n const shots = [[\"light\", parse(process.env.LIGHT_SHOT_JSON)], [\"dark\", parse(process.env.DARK_SHOT_JSON)]];\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === \"string\" && shot.imageUrl.trim());\n const reasons = shots.flatMap(([label, shot]) => {\n if (typeof shot.reason === \"string\" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\n if (!(typeof shot.imageUrl === \"string\" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\n return [];\n });\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? \"\" : reasons.join(\"; \").slice(0, 1000) }));\n NODE\n )\n SHOT_OK=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?\"true\":\"false\")}catch{process.stdout.write(\"false\")}' \"$SHOT_STATUS\")\n SHOT_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||\"\")}catch{process.stdout.write(\"invalid shot status JSON\")}' \"$SHOT_STATUS\")\n if [ \"$SHOT_OK\" != \"true\" ]; then\n echo \"::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON\"\n fi\n echo \"image_url=$IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\n echo \"light_image_url=$IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\n echo \"dark_image_url=$DARK_IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\n echo \"shot_ok=$SHOT_OK\" >> \"$GITHUB_OUTPUT\"\n {\n echo 'shot_reason<<__RECAP_SHOT_REASON_EOF__'\n echo \"$SHOT_REASON\"\n echo '__RECAP_SHOT_REASON_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo \"captured=true\" >> \"$GITHUB_OUTPUT\"; else echo \"captured=false\" >> \"$GITHUB_OUTPUT\"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == 'true'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) \u2014 INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n claude-result.json\n claude-stderr.log\n codex-events.jsonl\n codex-stderr.log\n openai-compatible-result.txt\n openai-compatible-usage.json\n openai-compatible-stderr.log\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # \"recap-url.txt was not created\" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\" --head-sha \"$HEAD_SHA\"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != ''\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id \"$CHECK_RUN_ID\" \\\n --plan-ok \"$PLAN_OK\" \\\n --plan-url \"$PLAN_URL\" \\\n --suppressed \"$SUPPRESSED\" \\\n --suppressed-json \"$SUPPRESSED_JSON\" \\\n --huge \"$DIFF_HUGE\" \\\n --tiny \"$DIFF_TINY\" \\\n --failure-summary \"$RECAP_AGENT_SUMMARY\" \\\n --url-reason \"$RECAP_URL_REASON\" \\\n --workflow-url \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\"\n";
3
+ //# sourceMappingURL=pr-visual-recap-workflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pr-visual-recap-workflow.d.ts","sourceRoot":"","sources":["../src/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,eAAO,MAAM,4BAA4B,s/jDACqkjD,CAAC"}