@agent-native/core 0.44.4 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action.d.ts +8 -1
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +20 -10
- package/dist/action.js.map +1 -1
- package/dist/cli/app-skill.d.ts +3 -1
- package/dist/cli/app-skill.d.ts.map +1 -1
- package/dist/cli/app-skill.js +50 -8
- package/dist/cli/app-skill.js.map +1 -1
- package/dist/cli/connect.d.ts +2 -1
- package/dist/cli/connect.d.ts.map +1 -1
- package/dist/cli/connect.js +224 -10
- package/dist/cli/connect.js.map +1 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +9 -7
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/index.js +69 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp-config-writers.d.ts +10 -0
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +60 -6
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +4 -6
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/plan-local.d.ts +43 -0
- package/dist/cli/plan-local.d.ts.map +1 -0
- package/dist/cli/plan-local.js +490 -0
- package/dist/cli/plan-local.js.map +1 -0
- package/dist/cli/plan-publish-store.d.ts +17 -7
- package/dist/cli/plan-publish-store.d.ts.map +1 -1
- package/dist/cli/plan-publish-store.js +33 -8
- package/dist/cli/plan-publish-store.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +1 -1
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/cli/recap.d.ts +225 -3
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +1267 -27
- package/dist/cli/recap.js.map +1 -1
- package/dist/cli/skills.d.ts +26 -11
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +810 -1365
- package/dist/cli/skills.js.map +1 -1
- package/dist/cli/templates-meta.d.ts.map +1 -1
- package/dist/cli/templates-meta.js +3 -2
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/AnnotatedCodeBlock.js +41 -10
- package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
- package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/DiffBlock.js +54 -23
- package/dist/client/blocks/library/DiffBlock.js.map +1 -1
- package/dist/client/blocks/library/annotation-rail.d.ts +27 -8
- package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
- package/dist/client/blocks/library/annotation-rail.js +64 -27
- package/dist/client/blocks/library/annotation-rail.js.map +1 -1
- package/dist/client/blocks/library/code-filename-label.d.ts +8 -0
- package/dist/client/blocks/library/code-filename-label.d.ts.map +1 -0
- package/dist/client/blocks/library/code-filename-label.js +15 -0
- package/dist/client/blocks/library/code-filename-label.js.map +1 -0
- package/dist/client/blocks/library/code.d.ts.map +1 -1
- package/dist/client/blocks/library/code.js +3 -2
- package/dist/client/blocks/library/code.js.map +1 -1
- package/dist/client/blocks/library/diff.config.d.ts +1 -1
- package/dist/client/blocks/library/diff.config.js.map +1 -1
- package/dist/client/blocks/library/html.d.ts.map +1 -1
- package/dist/client/blocks/library/html.js +3 -1
- package/dist/client/blocks/library/html.js.map +1 -1
- package/dist/client/blocks/library/narrow-container.d.ts +4 -4
- package/dist/client/blocks/library/narrow-container.d.ts.map +1 -1
- package/dist/client/blocks/library/narrow-container.js +10 -10
- package/dist/client/blocks/library/narrow-container.js.map +1 -1
- package/dist/client/blocks/library/question-form.d.ts.map +1 -1
- package/dist/client/blocks/library/question-form.js +4 -1
- package/dist/client/blocks/library/question-form.js.map +1 -1
- package/dist/client/blocks/library/tabs.d.ts.map +1 -1
- package/dist/client/blocks/library/tabs.js +7 -2
- package/dist/client/blocks/library/tabs.js.map +1 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +4 -1
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/db-admin/TableEditor.d.ts.map +1 -1
- package/dist/client/db-admin/TableEditor.js +3 -1
- package/dist/client/db-admin/TableEditor.js.map +1 -1
- package/dist/db/client.d.ts +8 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +23 -2
- package/dist/db/client.js.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +2 -1
- package/dist/db/migrations.js.map +1 -1
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +8 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/extensions/html-shell.js +1 -1
- package/dist/extensions/html-shell.js.map +1 -1
- package/dist/extensions/routes.d.ts +18 -0
- package/dist/extensions/routes.d.ts.map +1 -1
- package/dist/extensions/routes.js +30 -8
- package/dist/extensions/routes.js.map +1 -1
- package/dist/jobs/scheduler.d.ts.map +1 -1
- package/dist/jobs/scheduler.js +5 -1
- package/dist/jobs/scheduler.js.map +1 -1
- package/dist/mcp/build-server.d.ts +1 -0
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +7 -3
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/oauth-route.d.ts.map +1 -1
- package/dist/mcp/oauth-route.js +56 -19
- package/dist/mcp/oauth-route.js.map +1 -1
- package/dist/mcp/oauth-store.d.ts +1 -0
- package/dist/mcp/oauth-store.d.ts.map +1 -1
- package/dist/mcp/oauth-store.js +9 -0
- package/dist/mcp/oauth-store.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +9 -4
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp-client/errors.js +3 -3
- package/dist/mcp-client/errors.js.map +1 -1
- package/dist/oauth-tokens/store.d.ts.map +1 -1
- package/dist/oauth-tokens/store.js +42 -5
- package/dist/oauth-tokens/store.js.map +1 -1
- package/dist/scripts/db/index.d.ts.map +1 -1
- package/dist/scripts/db/index.js +1 -0
- package/dist/scripts/db/index.js.map +1 -1
- package/dist/scripts/db/migrate-encrypt-oauth-tokens.d.ts +28 -0
- package/dist/scripts/db/migrate-encrypt-oauth-tokens.d.ts.map +1 -0
- package/dist/scripts/db/migrate-encrypt-oauth-tokens.js +164 -0
- package/dist/scripts/db/migrate-encrypt-oauth-tokens.js.map +1 -0
- package/dist/scripts/db/scoping.d.ts.map +1 -1
- package/dist/scripts/db/scoping.js +7 -5
- package/dist/scripts/db/scoping.js.map +1 -1
- package/dist/secrets/index.d.ts +1 -0
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +4 -0
- package/dist/secrets/index.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +3 -1
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-teams.d.ts.map +1 -1
- package/dist/server/agent-teams.js +10 -2
- package/dist/server/agent-teams.js.map +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +7 -3
- package/dist/server/auth.js.map +1 -1
- package/dist/server/recap-image-route.d.ts.map +1 -1
- package/dist/server/recap-image-route.js +3 -6
- package/dist/server/recap-image-route.js.map +1 -1
- package/dist/server/sentry.d.ts.map +1 -1
- package/dist/server/sentry.js +12 -5
- package/dist/server/sentry.js.map +1 -1
- package/dist/server/social-og-image.d.ts.map +1 -1
- package/dist/server/social-og-image.js +3 -1
- package/dist/server/social-og-image.js.map +1 -1
- package/dist/sharing/actions/set-resource-visibility.d.ts.map +1 -1
- package/dist/sharing/actions/set-resource-visibility.js +4 -1
- package/dist/sharing/actions/set-resource-visibility.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +22 -6
- package/docs/content/plan-plugin.md +39 -7
- package/docs/content/pr-visual-recap.md +89 -13
- package/docs/content/skills-guide.md +13 -0
- package/docs/content/template-plan.md +62 -7
- package/package.json +5 -1
- package/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +22 -6
package/dist/cli/recap.js
CHANGED
|
@@ -1,26 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agent-native recap
|
|
3
|
-
*
|
|
2
|
+
* `agent-native recap` — the helper surface used by the PR Visual Recap GitHub
|
|
3
|
+
* Action. Run `agent-native recap help` for the full subcommand list.
|
|
4
4
|
*
|
|
5
5
|
* The action no longer generates the recap deterministically. Instead a coding
|
|
6
6
|
* agent (Claude Code or Codex) RUNS THE REPO'S visual-recap skill against the
|
|
7
7
|
* diff and publishes the plan via the plan MCP tools. These subcommands are the
|
|
8
8
|
* thin, deterministic glue around that:
|
|
9
9
|
*
|
|
10
|
+
* gate The security boundary: decide whether the recap runs at all
|
|
11
|
+
* (skipping drafts, forks, bots, missing secrets, an invalid
|
|
12
|
+
* agent/model, and PRs that touch recap-control files) and which
|
|
13
|
+
* normalized backend agent to use.
|
|
14
|
+
* collect-diff Collect the bounded base...head diff (excluding lockfiles,
|
|
15
|
+
* build output, snapshots), cap it at ~600KB, and classify the
|
|
16
|
+
* huge/tiny flags.
|
|
17
|
+
* mcp-config Write the plan MCP client config for the chosen backend
|
|
18
|
+
* (Claude Code JSON or Codex config.toml).
|
|
10
19
|
* scan Refuse to hand a secret-leaking diff to the agent.
|
|
11
|
-
* build-prompt Assemble the agent prompt =
|
|
20
|
+
* build-prompt Assemble the agent prompt = latest visual-recap skill bundle
|
|
21
|
+
* + a task wrapper (or repo-pinned skill with --skill-source).
|
|
12
22
|
* shot Screenshot the published plan and upload it to the plan app's
|
|
13
23
|
* signed public image route (for an inline PR-comment image).
|
|
24
|
+
* usage Parse and emit agent token-usage/cost from stdout.
|
|
14
25
|
* comment Find the previous plan id / upsert the sticky PR comment.
|
|
26
|
+
* check Evaluate the recap result and set a GitHub commit status.
|
|
27
|
+
* setup Install the PR Visual Recap GitHub Action workflow.
|
|
28
|
+
* doctor Diagnose missing secrets / misconfigured workflow.
|
|
15
29
|
*
|
|
16
30
|
* Promoting these to the published CLI means an installed repo's workflow calls
|
|
17
31
|
* `agent-native recap …` instead of copying helper scripts into the repo.
|
|
18
32
|
*
|
|
19
33
|
* Node built-ins only (plus an optional dynamic `playwright` import for `shot`).
|
|
20
34
|
*/
|
|
35
|
+
import { execFileSync } from "node:child_process";
|
|
21
36
|
import fs from "node:fs";
|
|
37
|
+
import os from "node:os";
|
|
22
38
|
import path from "node:path";
|
|
39
|
+
import { readPlanPublishAuth } from "./plan-publish-store.js";
|
|
23
40
|
import { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js";
|
|
41
|
+
import { BUILT_IN_APP_SKILLS, VISUAL_RECAP_SKILL_MD } from "./skills.js";
|
|
24
42
|
/* -------------------------------------------------------------------------- */
|
|
25
43
|
/* Arg parsing */
|
|
26
44
|
/* -------------------------------------------------------------------------- */
|
|
@@ -63,6 +81,7 @@ export const PR_VISUAL_RECAP_SETUP = [
|
|
|
63
81
|
"Optional (only if you change defaults):",
|
|
64
82
|
" OPENAI_API_KEY (secret) + VISUAL_RECAP_AGENT=codex (variable) — use Codex instead of Claude",
|
|
65
83
|
" VISUAL_RECAP_MODEL / VISUAL_RECAP_REASONING (variables) — pin the model (e.g. gpt-5.5) and reasoning depth (none|minimal|low|medium|high|xhigh; Codex only)",
|
|
84
|
+
" VISUAL_RECAP_SKILL_SOURCE=repo (variable) — pin CI to the repo-local visual-recap skill instead of latest bundled guidance",
|
|
66
85
|
" PLAN_RECAP_APP_URL (secret) — only when self-hosting the plan app (defaults to https://plan.agent-native.com)",
|
|
67
86
|
];
|
|
68
87
|
/** Write .github/workflows/pr-visual-recap.yml into a repo. */
|
|
@@ -74,6 +93,335 @@ export function writePrVisualRecapWorkflow(baseDir) {
|
|
|
74
93
|
fs.writeFileSync(file, PR_VISUAL_RECAP_WORKFLOW_YML);
|
|
75
94
|
return { path: path.relative(baseDir, file), existed };
|
|
76
95
|
}
|
|
96
|
+
const DEFAULT_RECAP_APP_URL = "https://plan.agent-native.com";
|
|
97
|
+
export function normalizeRecapAgent(value) {
|
|
98
|
+
const agent = (value || "claude").toLowerCase();
|
|
99
|
+
if (agent === "codex")
|
|
100
|
+
return "codex";
|
|
101
|
+
if (agent === "claude")
|
|
102
|
+
return "claude";
|
|
103
|
+
throw new Error(`Unsupported recap agent "${value}" (expected "claude" or "codex").`);
|
|
104
|
+
}
|
|
105
|
+
export function recapRequiredSecrets(agent) {
|
|
106
|
+
return [
|
|
107
|
+
"PLAN_RECAP_TOKEN",
|
|
108
|
+
agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY",
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
function recapWorkflowFile(baseDir) {
|
|
112
|
+
return path.join(baseDir, ".github", "workflows", "pr-visual-recap.yml");
|
|
113
|
+
}
|
|
114
|
+
function stripTrailingSlash(url) {
|
|
115
|
+
return url.replace(/\/+$/, "");
|
|
116
|
+
}
|
|
117
|
+
function sameRecapOrigin(a, b) {
|
|
118
|
+
try {
|
|
119
|
+
return new URL(a).origin === new URL(b).origin;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return stripTrailingSlash(a) === stripTrailingSlash(b);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function planTokenFromLocalStore(appUrl) {
|
|
126
|
+
const auth = readPlanPublishAuth();
|
|
127
|
+
if (!auth)
|
|
128
|
+
return undefined;
|
|
129
|
+
return sameRecapOrigin(auth.url, appUrl) ? auth.token : undefined;
|
|
130
|
+
}
|
|
131
|
+
function envValue(env, key) {
|
|
132
|
+
const value = env[key]?.trim();
|
|
133
|
+
return value || undefined;
|
|
134
|
+
}
|
|
135
|
+
function commandForMissingSecret(name, repo) {
|
|
136
|
+
return `gh secret set ${name}${repo ? ` --repo ${repo}` : ""}`;
|
|
137
|
+
}
|
|
138
|
+
function commandForMissingVariable(name, value, repo) {
|
|
139
|
+
return `gh variable set ${name} --body ${JSON.stringify(value)}${repo ? ` --repo ${repo}` : ""}`;
|
|
140
|
+
}
|
|
141
|
+
function gh(args, input) {
|
|
142
|
+
try {
|
|
143
|
+
const stdout = execFileSync("gh", args, {
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
input,
|
|
146
|
+
stdio: input === undefined
|
|
147
|
+
? ["ignore", "pipe", "pipe"]
|
|
148
|
+
: ["pipe", "pipe", "pipe"],
|
|
149
|
+
});
|
|
150
|
+
return { ok: true, stdout };
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { ok: false, stdout: "" };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function resolveGithubRepo(explicit) {
|
|
157
|
+
if (explicit)
|
|
158
|
+
return explicit;
|
|
159
|
+
const result = gh([
|
|
160
|
+
"repo",
|
|
161
|
+
"view",
|
|
162
|
+
"--json",
|
|
163
|
+
"nameWithOwner",
|
|
164
|
+
"--jq",
|
|
165
|
+
".nameWithOwner",
|
|
166
|
+
]);
|
|
167
|
+
const repo = result.stdout.trim();
|
|
168
|
+
return result.ok && repo ? repo : undefined;
|
|
169
|
+
}
|
|
170
|
+
function listGithubNames(kind, repo) {
|
|
171
|
+
const args = kind === "secret"
|
|
172
|
+
? ["secret", "list", "--json", "name"]
|
|
173
|
+
: ["variable", "list", "--json", "name,value"];
|
|
174
|
+
if (repo)
|
|
175
|
+
args.push("--repo", repo);
|
|
176
|
+
const result = gh(args);
|
|
177
|
+
if (!result.ok)
|
|
178
|
+
return null;
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(result.stdout);
|
|
181
|
+
if (!Array.isArray(parsed))
|
|
182
|
+
return null;
|
|
183
|
+
return new Set(parsed
|
|
184
|
+
.map((entry) => entry && typeof entry === "object"
|
|
185
|
+
? entry.name
|
|
186
|
+
: undefined)
|
|
187
|
+
.filter((name) => typeof name === "string"));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function listGithubVariables(repo) {
|
|
194
|
+
const args = ["variable", "list", "--json", "name,value"];
|
|
195
|
+
if (repo)
|
|
196
|
+
args.push("--repo", repo);
|
|
197
|
+
const result = gh(args);
|
|
198
|
+
if (!result.ok)
|
|
199
|
+
return null;
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(result.stdout);
|
|
202
|
+
if (!Array.isArray(parsed))
|
|
203
|
+
return null;
|
|
204
|
+
const out = new Map();
|
|
205
|
+
for (const entry of parsed) {
|
|
206
|
+
if (!entry || typeof entry !== "object")
|
|
207
|
+
continue;
|
|
208
|
+
const record = entry;
|
|
209
|
+
if (typeof record.name !== "string")
|
|
210
|
+
continue;
|
|
211
|
+
out.set(record.name, typeof record.value === "string" ? record.value : "");
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function setGithubSecret(name, value, repo, dryRun) {
|
|
220
|
+
if (!value)
|
|
221
|
+
return "missing";
|
|
222
|
+
if (dryRun)
|
|
223
|
+
return "dry-run";
|
|
224
|
+
const args = ["secret", "set", name];
|
|
225
|
+
if (repo)
|
|
226
|
+
args.push("--repo", repo);
|
|
227
|
+
return gh(args, `${value}\n`).ok ? "set" : "failed";
|
|
228
|
+
}
|
|
229
|
+
function setGithubVariable(name, value, repo, dryRun) {
|
|
230
|
+
if (!value)
|
|
231
|
+
return "skipped";
|
|
232
|
+
if (dryRun)
|
|
233
|
+
return "dry-run";
|
|
234
|
+
const args = ["variable", "set", name, "--body", value];
|
|
235
|
+
if (repo)
|
|
236
|
+
args.push("--repo", repo);
|
|
237
|
+
return gh(args).ok ? "set" : "failed";
|
|
238
|
+
}
|
|
239
|
+
export function buildRecapSetupPlan(input) {
|
|
240
|
+
const env = input.env ?? process.env;
|
|
241
|
+
const appUrl = stripTrailingSlash(input.appUrl || env.PLAN_RECAP_APP_URL || DEFAULT_RECAP_APP_URL);
|
|
242
|
+
const agent = normalizeRecapAgent(input.agent || env.VISUAL_RECAP_AGENT);
|
|
243
|
+
const requiredSecrets = recapRequiredSecrets(agent);
|
|
244
|
+
const planToken = envValue(env, "PLAN_RECAP_TOKEN") ?? planTokenFromLocalStore(appUrl);
|
|
245
|
+
const llmSecretName = agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
|
246
|
+
const variableValues = {};
|
|
247
|
+
if (agent !== "claude")
|
|
248
|
+
variableValues.VISUAL_RECAP_AGENT = agent;
|
|
249
|
+
for (const key of [
|
|
250
|
+
"VISUAL_RECAP_MODEL",
|
|
251
|
+
"VISUAL_RECAP_REASONING",
|
|
252
|
+
"VISUAL_RECAP_SKILL_SOURCE",
|
|
253
|
+
]) {
|
|
254
|
+
const value = envValue(env, key);
|
|
255
|
+
if (value)
|
|
256
|
+
variableValues[key] = value;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
agent,
|
|
260
|
+
appUrl,
|
|
261
|
+
repo: input.repo,
|
|
262
|
+
workflowPath: path.relative(input.baseDir, recapWorkflowFile(input.baseDir)),
|
|
263
|
+
workflowExists: fs.existsSync(recapWorkflowFile(input.baseDir)),
|
|
264
|
+
requiredSecrets,
|
|
265
|
+
variableValues,
|
|
266
|
+
secretValues: {
|
|
267
|
+
PLAN_RECAP_TOKEN: planToken,
|
|
268
|
+
[llmSecretName]: envValue(env, llmSecretName),
|
|
269
|
+
PLAN_RECAP_APP_URL: appUrl === DEFAULT_RECAP_APP_URL ? undefined : appUrl,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function flagArg(args, key) {
|
|
274
|
+
return args[key] === true || args[key] === "true";
|
|
275
|
+
}
|
|
276
|
+
function runSetup(args) {
|
|
277
|
+
const baseDir = process.cwd();
|
|
278
|
+
const dryRun = flagArg(args, "dry-run");
|
|
279
|
+
const skipSecrets = flagArg(args, "skip-secrets");
|
|
280
|
+
const repo = resolveGithubRepo(optionalArg(args, "repo"));
|
|
281
|
+
const plan = buildRecapSetupPlan({
|
|
282
|
+
baseDir,
|
|
283
|
+
appUrl: optionalArg(args, "app-url"),
|
|
284
|
+
agent: optionalArg(args, "agent"),
|
|
285
|
+
repo,
|
|
286
|
+
});
|
|
287
|
+
const lines = ["PR Visual Recap setup", ""];
|
|
288
|
+
if (dryRun) {
|
|
289
|
+
lines.push(`Workflow: would write ${plan.workflowPath}.`);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
const written = writePrVisualRecapWorkflow(baseDir);
|
|
293
|
+
lines.push(`Workflow: ${written.existed ? "refreshed" : "wrote"} ${written.path}.`);
|
|
294
|
+
}
|
|
295
|
+
lines.push(`Plan app: ${plan.appUrl}.`);
|
|
296
|
+
lines.push(`Backend: ${plan.agent}.`);
|
|
297
|
+
lines.push(repo
|
|
298
|
+
? `GitHub repo: ${repo}.`
|
|
299
|
+
: "GitHub repo: not detected; pass --repo owner/name or run from a GitHub checkout.");
|
|
300
|
+
if (skipSecrets) {
|
|
301
|
+
lines.push("");
|
|
302
|
+
lines.push("GitHub secrets/variables: skipped.");
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
lines.push("");
|
|
306
|
+
lines.push("GitHub secrets/variables:");
|
|
307
|
+
const secretNames = [
|
|
308
|
+
...plan.requiredSecrets,
|
|
309
|
+
...(plan.secretValues.PLAN_RECAP_APP_URL ? ["PLAN_RECAP_APP_URL"] : []),
|
|
310
|
+
];
|
|
311
|
+
for (const name of secretNames) {
|
|
312
|
+
const status = setGithubSecret(name, plan.secretValues[name], repo, dryRun);
|
|
313
|
+
if (status === "set") {
|
|
314
|
+
lines.push(` ${name}: set.`);
|
|
315
|
+
}
|
|
316
|
+
else if (status === "dry-run") {
|
|
317
|
+
lines.push(` ${name}: would set.`);
|
|
318
|
+
}
|
|
319
|
+
else if (status === "missing") {
|
|
320
|
+
lines.push(` ${name}: missing value.`);
|
|
321
|
+
if (name === "PLAN_RECAP_TOKEN") {
|
|
322
|
+
lines.push(` Run agent-native connect ${plan.appUrl} --client codex, then rerun this setup.`);
|
|
323
|
+
}
|
|
324
|
+
lines.push(` Or set manually: ${commandForMissingSecret(name, repo)}`);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
lines.push(` ${name}: could not set with gh.`);
|
|
328
|
+
lines.push(` Set manually: ${commandForMissingSecret(name, repo)}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const [name, value] of Object.entries(plan.variableValues)) {
|
|
332
|
+
const status = setGithubVariable(name, value, repo, dryRun);
|
|
333
|
+
if (status === "set") {
|
|
334
|
+
lines.push(` ${name}: set to ${value}.`);
|
|
335
|
+
}
|
|
336
|
+
else if (status === "dry-run") {
|
|
337
|
+
lines.push(` ${name}: would set to ${value}.`);
|
|
338
|
+
}
|
|
339
|
+
else if (status === "failed") {
|
|
340
|
+
lines.push(` ${name}: could not set with gh.`);
|
|
341
|
+
lines.push(` Set manually: ${commandForMissingVariable(name, value, repo)}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
lines.push("");
|
|
346
|
+
lines.push(`Next: commit ${plan.workflowPath}, then run agent-native recap doctor.`);
|
|
347
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
348
|
+
}
|
|
349
|
+
function runDoctor(args) {
|
|
350
|
+
const baseDir = process.cwd();
|
|
351
|
+
const repo = resolveGithubRepo(optionalArg(args, "repo"));
|
|
352
|
+
const variables = listGithubVariables(repo);
|
|
353
|
+
const agent = normalizeRecapAgent(optionalArg(args, "agent") ??
|
|
354
|
+
variables?.get("VISUAL_RECAP_AGENT") ??
|
|
355
|
+
process.env.VISUAL_RECAP_AGENT);
|
|
356
|
+
const plan = buildRecapSetupPlan({
|
|
357
|
+
baseDir,
|
|
358
|
+
appUrl: optionalArg(args, "app-url"),
|
|
359
|
+
agent,
|
|
360
|
+
repo,
|
|
361
|
+
});
|
|
362
|
+
const lines = ["PR Visual Recap doctor", ""];
|
|
363
|
+
let ok = true;
|
|
364
|
+
const workflowFile = recapWorkflowFile(baseDir);
|
|
365
|
+
if (!fs.existsSync(workflowFile)) {
|
|
366
|
+
ok = false;
|
|
367
|
+
lines.push(`[missing] Workflow missing: ${plan.workflowPath}.`);
|
|
368
|
+
lines.push(" Run agent-native skills add visual-plan --with-github-action.");
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const current = fs.readFileSync(workflowFile, "utf-8");
|
|
372
|
+
if (current === PR_VISUAL_RECAP_WORKFLOW_YML) {
|
|
373
|
+
lines.push(`[ok] Workflow installed: ${plan.workflowPath}.`);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
ok = false;
|
|
377
|
+
lines.push(`[missing] Workflow differs from the bundled template: ${plan.workflowPath}.`);
|
|
378
|
+
lines.push(" Run agent-native recap setup to refresh it.");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (plan.secretValues.PLAN_RECAP_TOKEN) {
|
|
382
|
+
lines.push("[ok] Local Plans publish token found.");
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
lines.push("[warn] Local Plans publish token not found.");
|
|
386
|
+
lines.push(` Run agent-native connect ${plan.appUrl} --client codex to mint one.`);
|
|
387
|
+
}
|
|
388
|
+
if (repo) {
|
|
389
|
+
lines.push(`[ok] GitHub repo detected: ${repo}.`);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
ok = false;
|
|
393
|
+
lines.push("[missing] GitHub repo not detected.");
|
|
394
|
+
lines.push(" Pass --repo owner/name or run from a GitHub checkout with gh auth.");
|
|
395
|
+
}
|
|
396
|
+
const secretNames = listGithubNames("secret", repo);
|
|
397
|
+
if (!secretNames) {
|
|
398
|
+
ok = false;
|
|
399
|
+
lines.push("[missing] Could not read GitHub Actions secrets with gh.");
|
|
400
|
+
lines.push(" Run gh auth status, or pass --repo owner/name.");
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
for (const name of plan.requiredSecrets) {
|
|
404
|
+
if (secretNames.has(name)) {
|
|
405
|
+
lines.push(`[ok] GitHub secret configured: ${name}.`);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
ok = false;
|
|
409
|
+
lines.push(`[missing] GitHub secret missing: ${name}.`);
|
|
410
|
+
lines.push(` Set it with: ${commandForMissingSecret(name, repo)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!variables) {
|
|
415
|
+
lines.push("[warn] Could not read GitHub Actions variables with gh.");
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const configuredAgent = variables.get("VISUAL_RECAP_AGENT") || "claude";
|
|
419
|
+
lines.push(`[ok] Recap backend variable: ${configuredAgent}.`);
|
|
420
|
+
}
|
|
421
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
422
|
+
if (!ok)
|
|
423
|
+
process.exitCode = 1;
|
|
424
|
+
}
|
|
77
425
|
/* -------------------------------------------------------------------------- */
|
|
78
426
|
/* Secret scan — defense-in-depth before any LLM sees the diff */
|
|
79
427
|
/* -------------------------------------------------------------------------- */
|
|
@@ -116,6 +464,277 @@ export function diffContainsSecret(diffText) {
|
|
|
116
464
|
return false;
|
|
117
465
|
}
|
|
118
466
|
/* -------------------------------------------------------------------------- */
|
|
467
|
+
/* Bounded diff collection — was the workflow's "Collect bounded diff" step */
|
|
468
|
+
/* -------------------------------------------------------------------------- */
|
|
469
|
+
/** ~600KB byte cap for the diff handed to the recap agent. */
|
|
470
|
+
export const RECAP_DIFF_BYTE_CAP = 614400;
|
|
471
|
+
/** The footer appended when a diff is truncated at the byte cap. */
|
|
472
|
+
export const RECAP_DIFF_TRUNCATED_FOOTER = "\n\n[diff truncated at 600KB for the recap agent]\n";
|
|
473
|
+
/**
|
|
474
|
+
* The pathspecs the bounded diff excludes — lockfiles, build output, and
|
|
475
|
+
* snapshots are noise for a visual recap. Kept as array args (not a shell
|
|
476
|
+
* string) so the `:(exclude)` pathspecs are never mangled by a shell.
|
|
477
|
+
*/
|
|
478
|
+
const RECAP_DIFF_PATHSPECS = [
|
|
479
|
+
".",
|
|
480
|
+
":(exclude)pnpm-lock.yaml",
|
|
481
|
+
":(exclude)**/dist/**",
|
|
482
|
+
":(exclude)**/*.snap",
|
|
483
|
+
":(exclude)**/*.lock",
|
|
484
|
+
];
|
|
485
|
+
/**
|
|
486
|
+
* Classify a bounded diff into the `huge` / `tiny` flags the workflow consumes.
|
|
487
|
+
*
|
|
488
|
+
* - huge: BYTES over the ~600KB cap. The agent is told to summarize AND the
|
|
489
|
+
* diff file is physically truncated so it can't overflow the prompt budget.
|
|
490
|
+
* - tiny: <= 1 changed file AND <= 8 changed lines. Uses ORIGINAL line count
|
|
491
|
+
* (captured before any truncation) so a large diff is never misclassified as
|
|
492
|
+
* tiny after the byte cap drops most of its lines.
|
|
493
|
+
*
|
|
494
|
+
* Pure (no I/O) so the classification can be unit-tested without invoking git.
|
|
495
|
+
*/
|
|
496
|
+
export function classifyDiff(input) {
|
|
497
|
+
return {
|
|
498
|
+
huge: input.bytes > RECAP_DIFF_BYTE_CAP,
|
|
499
|
+
tiny: input.changed <= 1 && input.originalLines <= 8,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Truncate a diff to the ~600KB byte cap at a COMPLETE LINE boundary, then
|
|
504
|
+
* append the truncated footer. Dropping the last (possibly-partial) line is the
|
|
505
|
+
* equivalent of the original `head -c 614400 | sed '$d'`: it guarantees the cap
|
|
506
|
+
* never cuts a multi-byte UTF-8 char or a diff line mid-way and corrupts the
|
|
507
|
+
* agent's input. Pure (string in, string out) so it can be unit-tested.
|
|
508
|
+
*/
|
|
509
|
+
export function truncateDiffAtLineBoundary(text) {
|
|
510
|
+
const capped = Buffer.from(text, "utf8")
|
|
511
|
+
.subarray(0, RECAP_DIFF_BYTE_CAP)
|
|
512
|
+
.toString("utf8");
|
|
513
|
+
const lastNewline = capped.lastIndexOf("\n");
|
|
514
|
+
// Drop everything after the last newline (the last, possibly-partial line),
|
|
515
|
+
// mirroring `sed '$d'`. If there is no newline at all, drop the whole partial
|
|
516
|
+
// line (empty body) — the footer still makes the truncation explicit.
|
|
517
|
+
const body = lastNewline >= 0 ? capped.slice(0, lastNewline) : "";
|
|
518
|
+
return body + RECAP_DIFF_TRUNCATED_FOOTER;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Count lines that begin with `+` or `-` (added/removed diff lines), excluding
|
|
522
|
+
* the `+++ b/file` / `--- a/file` unified-diff header lines. Without this
|
|
523
|
+
* exclusion a single-file change loses ~2 "real" lines from the 8-line tiny
|
|
524
|
+
* threshold, incorrectly classifying a small-but-meaningful change as tiny.
|
|
525
|
+
*/
|
|
526
|
+
export function countDiffLines(diffText) {
|
|
527
|
+
let count = 0;
|
|
528
|
+
for (const line of diffText.split("\n")) {
|
|
529
|
+
if (line.startsWith("+++") || line.startsWith("---"))
|
|
530
|
+
continue;
|
|
531
|
+
if (line.startsWith("+") || line.startsWith("-"))
|
|
532
|
+
count += 1;
|
|
533
|
+
}
|
|
534
|
+
return count;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Run `git diff <base>...<head> -- <pathspecs>` and return its stdout plus a
|
|
538
|
+
* `failed` flag. A non-zero exit that still produces stdout is treated as a
|
|
539
|
+
* partial result (same as the original `... || true`). A non-zero exit with
|
|
540
|
+
* empty stdout is a genuine failure (broken ref, missing object, etc.) and
|
|
541
|
+
* sets `failed: true` so `runCollectDiff` can exit with a distinct error
|
|
542
|
+
* instead of silently classifying the empty output as a tiny diff.
|
|
543
|
+
*
|
|
544
|
+
* Array args — NOT a shell string — so the `:(exclude)` pathspecs survive.
|
|
545
|
+
*/
|
|
546
|
+
function gitDiffRaw(base, head, extraArgs) {
|
|
547
|
+
const args = [
|
|
548
|
+
"diff",
|
|
549
|
+
"--no-color",
|
|
550
|
+
...extraArgs,
|
|
551
|
+
`${base}...${head}`,
|
|
552
|
+
"--",
|
|
553
|
+
...RECAP_DIFF_PATHSPECS,
|
|
554
|
+
];
|
|
555
|
+
try {
|
|
556
|
+
const stdout = execFileSync("git", args, {
|
|
557
|
+
encoding: "utf8",
|
|
558
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
559
|
+
});
|
|
560
|
+
return { stdout, failed: false };
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
// Recover whatever stdout git wrote before failing.
|
|
564
|
+
const raw = err && typeof err.stdout === "string"
|
|
565
|
+
? err.stdout
|
|
566
|
+
: err && Buffer.isBuffer(err.stdout)
|
|
567
|
+
? err.stdout.toString("utf8")
|
|
568
|
+
: "";
|
|
569
|
+
// An empty stdout from a non-zero exit means a broken ref / missing
|
|
570
|
+
// object — not a legitimate empty diff. Signal failure.
|
|
571
|
+
return { stdout: raw, failed: raw.trim() === "" };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* `recap collect-diff` — the bounded-diff collection that used to be ~60 lines
|
|
576
|
+
* of inline bash. Writes recap.diff + recap.stat, classifies huge/tiny, and
|
|
577
|
+
* emits the same `bytes/changed/huge/tiny` outputs the workflow expects:
|
|
578
|
+
* appended to $GITHUB_OUTPUT when set, AND printed as JSON to stdout (so it runs
|
|
579
|
+
* and is testable outside GitHub Actions).
|
|
580
|
+
*
|
|
581
|
+
* Exits non-zero when git itself fails (broken SHA / missing object) so the
|
|
582
|
+
* CI workflow treats it as a real failure instead of silently classifying an
|
|
583
|
+
* empty diff as "tiny" and skipping the recap with no diagnostic.
|
|
584
|
+
*/
|
|
585
|
+
function runCollectDiff(args) {
|
|
586
|
+
const base = stringArg(args, "base");
|
|
587
|
+
const head = stringArg(args, "head");
|
|
588
|
+
const outPath = optionalArg(args, "out") ?? "recap.diff";
|
|
589
|
+
const statPath = optionalArg(args, "stat") ?? "recap.stat";
|
|
590
|
+
// The unified diff and the --stat summary (both excluding lockfiles/noise).
|
|
591
|
+
const diffResult = gitDiffRaw(base, head, []);
|
|
592
|
+
if (diffResult.failed) {
|
|
593
|
+
process.stderr.write(`recap collect-diff: git diff failed for ${base}...${head} — ` +
|
|
594
|
+
`the SHAs may be missing (shallow clone?) or invalid.\n` +
|
|
595
|
+
`Make sure the workflow checks out with fetch-depth: 0 or at least ` +
|
|
596
|
+
`enough history to resolve both refs.\n`);
|
|
597
|
+
process.exit(1);
|
|
598
|
+
}
|
|
599
|
+
let diff = diffResult.stdout;
|
|
600
|
+
const stat = gitDiffRaw(base, head, ["--stat"]).stdout;
|
|
601
|
+
fs.writeFileSync(path.resolve(statPath), stat);
|
|
602
|
+
// ORIGINAL line count — captured BEFORE any byte-cap truncation so a large
|
|
603
|
+
// diff is never misclassified as tiny after truncation.
|
|
604
|
+
const originalLines = countDiffLines(diff);
|
|
605
|
+
// Changed-file count from `--name-only` over the same excludes.
|
|
606
|
+
const names = gitDiffRaw(base, head, ["--name-only"]).stdout;
|
|
607
|
+
const changed = names.split("\n").filter((line) => line.length > 0).length;
|
|
608
|
+
// Write the (possibly truncated) diff and compute the on-disk byte length.
|
|
609
|
+
const bytesBefore = Buffer.byteLength(diff, "utf8");
|
|
610
|
+
const { huge } = classifyDiff({ bytes: bytesBefore, changed, originalLines });
|
|
611
|
+
if (huge)
|
|
612
|
+
diff = truncateDiffAtLineBoundary(diff);
|
|
613
|
+
fs.writeFileSync(path.resolve(outPath), diff);
|
|
614
|
+
const bytes = fs.statSync(path.resolve(outPath)).size;
|
|
615
|
+
const { tiny } = classifyDiff({ bytes: bytesBefore, changed, originalLines });
|
|
616
|
+
// Preserve the existing steps.diff.outputs.{bytes,changed,huge,tiny} contract.
|
|
617
|
+
const githubOutput = process.env.GITHUB_OUTPUT;
|
|
618
|
+
if (githubOutput) {
|
|
619
|
+
fs.appendFileSync(githubOutput, `bytes=${bytes}\nchanged=${changed}\nhuge=${huge}\ntiny=${tiny}\n`);
|
|
620
|
+
}
|
|
621
|
+
process.stdout.write(`${JSON.stringify({ bytes, changed, huge, tiny })}\n`);
|
|
622
|
+
}
|
|
623
|
+
/* -------------------------------------------------------------------------- */
|
|
624
|
+
/* MCP config writers — were the two `node -e` one-liners in the agent steps */
|
|
625
|
+
/* -------------------------------------------------------------------------- */
|
|
626
|
+
/**
|
|
627
|
+
* The Claude Code MCP config the recap agent loads: a single HTTP `plan` server
|
|
628
|
+
* pointing at the app's `/_agent-native/mcp` endpoint, authorized with the
|
|
629
|
+
* PLAN_RECAP_TOKEN. Pure (returns the JSON string) so it can be unit-tested.
|
|
630
|
+
*/
|
|
631
|
+
export function buildRecapClaudeMcpConfig(appUrl, token) {
|
|
632
|
+
const url = appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
|
|
633
|
+
return JSON.stringify({
|
|
634
|
+
mcpServers: {
|
|
635
|
+
plan: {
|
|
636
|
+
type: "http",
|
|
637
|
+
url,
|
|
638
|
+
headers: { Authorization: "Bearer " + token },
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* The Codex `config.toml` the recap agent loads. JSON.stringify the URL value so
|
|
645
|
+
* a stray quote/newline in the app URL can't break out of the TOML basic string
|
|
646
|
+
* (TOML shares JSON's escaping); the key and env-var name stay literal. Pure so
|
|
647
|
+
* it can be unit-tested.
|
|
648
|
+
*/
|
|
649
|
+
export function buildRecapCodexMcpConfig(appUrl) {
|
|
650
|
+
const url = appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
|
|
651
|
+
return ("[mcp_servers.plan]\n" +
|
|
652
|
+
"url = " +
|
|
653
|
+
JSON.stringify(url) +
|
|
654
|
+
"\n" +
|
|
655
|
+
'bearer_token_env_var = "PLAN_RECAP_TOKEN"\n');
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* `recap mcp-config` — write the plan MCP client config for the chosen backend,
|
|
659
|
+
* replacing the two `node -e '...'` one-liners that previously lived inline in
|
|
660
|
+
* the agent steps. PLAN_RECAP_TOKEN is read from the environment (claude only),
|
|
661
|
+
* exactly as before.
|
|
662
|
+
*/
|
|
663
|
+
function runMcpConfig(args) {
|
|
664
|
+
const agent = stringArg(args, "agent").toLowerCase();
|
|
665
|
+
const appUrl = stringArg(args, "app-url");
|
|
666
|
+
const force = Boolean(args["force"]);
|
|
667
|
+
if (agent === "claude") {
|
|
668
|
+
const token = process.env.PLAN_RECAP_TOKEN;
|
|
669
|
+
if (!token) {
|
|
670
|
+
process.stderr.write(`recap mcp-config: PLAN_RECAP_TOKEN is not set.\n` +
|
|
671
|
+
`Set it in the workflow environment before running this step.\n`);
|
|
672
|
+
process.exit(1);
|
|
673
|
+
}
|
|
674
|
+
const out = stringArg(args, "out");
|
|
675
|
+
fs.writeFileSync(path.resolve(out), buildRecapClaudeMcpConfig(appUrl, token));
|
|
676
|
+
process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (agent === "codex") {
|
|
680
|
+
const out = optionalArg(args, "out") ??
|
|
681
|
+
path.join(os.homedir(), ".codex", "config.toml");
|
|
682
|
+
const absOut = path.resolve(out);
|
|
683
|
+
fs.mkdirSync(path.dirname(absOut), { recursive: true });
|
|
684
|
+
const newEntry = buildRecapCodexMcpConfig(appUrl);
|
|
685
|
+
const SECTION_MARKER = "[mcp_servers.plan]";
|
|
686
|
+
// If the file already exists and is non-empty, merge rather than overwrite.
|
|
687
|
+
let existing = "";
|
|
688
|
+
try {
|
|
689
|
+
const raw = fs.readFileSync(absOut, "utf8");
|
|
690
|
+
if (raw.trim())
|
|
691
|
+
existing = raw;
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
/* file absent — write fresh */
|
|
695
|
+
}
|
|
696
|
+
if (existing) {
|
|
697
|
+
if (existing.includes(SECTION_MARKER)) {
|
|
698
|
+
// Section already present — skip unless --force was passed.
|
|
699
|
+
if (!force) {
|
|
700
|
+
process.stdout.write(`${JSON.stringify({ ok: true, agent, out, skipped: true, reason: "plan entry already present; pass --force to overwrite" })}\n`);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// --force: replace the existing [mcp_servers.plan] block.
|
|
704
|
+
// Remove lines from the section header until the next `[` header or EOF.
|
|
705
|
+
const lines = existing.split("\n");
|
|
706
|
+
const startIdx = lines.findIndex((l) => l.trim() === SECTION_MARKER);
|
|
707
|
+
let endIdx = lines.length;
|
|
708
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
709
|
+
if (lines[i].trimStart().startsWith("[")) {
|
|
710
|
+
endIdx = i;
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const without = [
|
|
715
|
+
...lines.slice(0, startIdx),
|
|
716
|
+
...lines.slice(endIdx),
|
|
717
|
+
].join("\n");
|
|
718
|
+
const merged = (without.trimEnd() ? without.trimEnd() + "\n\n" : "") + newEntry;
|
|
719
|
+
fs.writeFileSync(absOut, merged, { mode: 0o600 });
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
// Append the new section to the existing config.
|
|
723
|
+
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
724
|
+
fs.writeFileSync(absOut, existing + separator + newEntry, {
|
|
725
|
+
mode: 0o600,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
fs.writeFileSync(absOut, newEntry);
|
|
731
|
+
}
|
|
732
|
+
process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
throw new Error(`Unknown --agent "${agent}" (expected "claude" or "codex")`);
|
|
736
|
+
}
|
|
737
|
+
/* -------------------------------------------------------------------------- */
|
|
119
738
|
/* Prompt builder — repo SKILL.md + task wrapper */
|
|
120
739
|
/* -------------------------------------------------------------------------- */
|
|
121
740
|
/**
|
|
@@ -138,15 +757,80 @@ export function readRepoSkillMd(cwd = process.cwd()) {
|
|
|
138
757
|
}
|
|
139
758
|
throw new Error("Could not find visual-recap/SKILL.md. Run `agent-native skills add visual-plan` first.");
|
|
140
759
|
}
|
|
760
|
+
function listRecapSkillReferenceFiles(skillDir) {
|
|
761
|
+
const out = {};
|
|
762
|
+
const walk = (current, prefix = "") => {
|
|
763
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
764
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
765
|
+
const abs = path.join(current, entry.name);
|
|
766
|
+
if (entry.isDirectory()) {
|
|
767
|
+
walk(abs, rel);
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (!entry.isFile() || rel === "SKILL.md")
|
|
771
|
+
continue;
|
|
772
|
+
if (rel === "agent-native-skill.json")
|
|
773
|
+
continue;
|
|
774
|
+
out[rel] = fs.readFileSync(abs, "utf8");
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
if (fs.existsSync(skillDir))
|
|
778
|
+
walk(skillDir);
|
|
779
|
+
return out;
|
|
780
|
+
}
|
|
781
|
+
function recapSkillBundleText(skillMd, referenceFiles) {
|
|
782
|
+
const refs = Object.keys(referenceFiles).sort();
|
|
783
|
+
if (refs.length === 0)
|
|
784
|
+
return skillMd;
|
|
785
|
+
const lines = [skillMd.trim(), "", "# Bundled visual-recap reference files"];
|
|
786
|
+
lines.push("These files live next to visual-recap/SKILL.md in a normal install. Treat them as part of the skill instructions.");
|
|
787
|
+
for (const rel of refs) {
|
|
788
|
+
lines.push("", `## ${rel}`, "", referenceFiles[rel].trim());
|
|
789
|
+
}
|
|
790
|
+
return lines.join("\n");
|
|
791
|
+
}
|
|
792
|
+
function readRepoSkillBundle(cwd = process.cwd()) {
|
|
793
|
+
const skill = readRepoSkillMd(cwd);
|
|
794
|
+
const skillDir = path.dirname(path.resolve(cwd, skill.source));
|
|
795
|
+
return {
|
|
796
|
+
text: recapSkillBundleText(skill.text, listRecapSkillReferenceFiles(skillDir)),
|
|
797
|
+
source: skill.source,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
function latestVisualRecapSkillBundle() {
|
|
801
|
+
const planSkill = BUILT_IN_APP_SKILLS["visual-plans"];
|
|
802
|
+
const references = "extraFiles" in planSkill
|
|
803
|
+
? (planSkill.extraFiles?.["visual-recap"] ?? {})
|
|
804
|
+
: {};
|
|
805
|
+
return {
|
|
806
|
+
text: recapSkillBundleText(VISUAL_RECAP_SKILL_MD, references),
|
|
807
|
+
source: "bundled:@agent-native/core/visual-recap",
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
export function readVisualRecapSkillBundle(cwd = process.cwd(), mode = "auto") {
|
|
811
|
+
if (mode === "latest" || mode === "auto") {
|
|
812
|
+
return latestVisualRecapSkillBundle();
|
|
813
|
+
}
|
|
814
|
+
return readRepoSkillBundle(cwd);
|
|
815
|
+
}
|
|
141
816
|
export function buildRecapPrompt(input) {
|
|
142
817
|
const appUrl = input.appUrl.replace(/\/$/, "");
|
|
818
|
+
const localDir = input.localDir ?? path.join("plans", `pr-${input.pr}-visual-recap`);
|
|
143
819
|
const lines = [];
|
|
144
|
-
lines.push(
|
|
820
|
+
lines.push(input.localFiles
|
|
821
|
+
? "# Task: create a DB-free local Visual Recap of this pull request"
|
|
822
|
+
: "# Task: publish a Visual Recap of this pull request");
|
|
145
823
|
lines.push("");
|
|
146
|
-
lines.push(
|
|
824
|
+
lines.push(input.localFiles
|
|
825
|
+
? `You are running non-interactively in local-files privacy mode. Follow the **visual-recap skill** included verbatim below to turn this PR's diff into a grounded Agent-Native Plan MDX folder, but do not publish it or call any Plan MCP/action write tool.`
|
|
826
|
+
: `You are running non-interactively in CI. Follow the **visual-recap skill** included verbatim below to turn this PR's diff into a grounded Agent-Native Plan, then publish it.`);
|
|
147
827
|
lines.push("");
|
|
148
828
|
lines.push("## Inputs (read them from disk with your Read tool)");
|
|
149
829
|
lines.push(`- PR number: **#${input.pr}**`);
|
|
830
|
+
if (input.repo) {
|
|
831
|
+
lines.push(`- Repository: **${input.repo}**`);
|
|
832
|
+
lines.push(`- Pull request URL: https://github.com/${input.repo}/pull/${input.pr}`);
|
|
833
|
+
}
|
|
150
834
|
if (input.head)
|
|
151
835
|
lines.push(`- Head commit: \`${input.head}\``);
|
|
152
836
|
lines.push(`- Unified diff: \`${input.diffPath}\` (read this file)`);
|
|
@@ -156,16 +840,30 @@ export function buildRecapPrompt(input) {
|
|
|
156
840
|
lines.push(`- The diff is LARGE — produce a **summarized** recap (top files + schema/API deltas), not an exhaustive one.`);
|
|
157
841
|
}
|
|
158
842
|
lines.push("");
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
843
|
+
if (input.localFiles) {
|
|
844
|
+
lines.push("## Local-Files Output (this is the only way to produce output)");
|
|
845
|
+
lines.push("Do NOT call the `plan` MCP server, `create-visual-recap`, `import-visual-plan-source`, `update-visual-plan`, `export-visual-plan`, or any hosted Plan action. This mode exists so the recap data never goes to a Plan app database.");
|
|
846
|
+
lines.push(`1. Create or replace the local MDX folder \`${localDir}\` with \`plan.mdx\` and optional \`canvas.mdx\`, \`prototype.mdx\`, and \`.plan-state.json\` derived ONLY from the real diff. Set \`kind: "recap"\` and \`localOnly: true\` in source metadata/state.`);
|
|
847
|
+
lines.push(`2. Run \`agent-native plan local preview --dir ${JSON.stringify(localDir)} --kind recap --out ${JSON.stringify(path.join(localDir, "preview.html"))}\` to validate the folder and generate the local preview.`);
|
|
848
|
+
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.");
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
lines.push("## Publish (this is the only way to produce output)");
|
|
852
|
+
lines.push(`The \`plan\` MCP server is configured for you. Call its tools by name (your host may expose them as \`get-plan-blocks\` / \`create-visual-recap\` or \`mcp__plan__get-plan-blocks\` / \`mcp__plan__create-visual-recap\` — same tools).`);
|
|
853
|
+
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 workflow/tool allowlist is stale. Report that `.github/workflows/pr-visual-recap.yml` must allow `mcp__plan__get-plan-blocks`; do not describe that case as a disconnected Plan MCP.");
|
|
854
|
+
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
|
|
855
|
+
? `, and also passing \`planId: "${input.prevPlanId}"\` so this REPLACES the existing recap plan`
|
|
856
|
+
: ""}.`);
|
|
857
|
+
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.`);
|
|
858
|
+
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.`);
|
|
859
|
+
}
|
|
166
860
|
lines.push("");
|
|
167
861
|
lines.push("Do not invent file names, schema fields, or endpoints. Redact anything that looks like a secret. If the diff has no reviewable substance, still publish a minimal recap and write recap-url.txt.");
|
|
168
862
|
lines.push("");
|
|
863
|
+
lines.push("## Depth preflight");
|
|
864
|
+
lines.push("Before authoring the recap, read the diff/stat and make a quick private 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.");
|
|
865
|
+
lines.push("For UI PRs, do not stop at one before/after. Show the entry point, the changed interaction surface, and the resulting/destination state; add role/access or empty/error states when the diff implements them. Then include the key file-tree and key-change diff tabs.");
|
|
866
|
+
lines.push("");
|
|
169
867
|
lines.push("---");
|
|
170
868
|
lines.push("");
|
|
171
869
|
lines.push("# visual-recap skill (follow this exactly)");
|
|
@@ -178,6 +876,7 @@ export function buildRecapPrompt(input) {
|
|
|
178
876
|
/* GitHub comment helpers */
|
|
179
877
|
/* -------------------------------------------------------------------------- */
|
|
180
878
|
const MARKER = "<!-- pr-visual-recap -->";
|
|
879
|
+
const RECAP_IMAGE_URL_PATH_PATTERN = /\/_agent-native\/recap-image\/[0-9a-f]{32,128}\.png$/;
|
|
181
880
|
function repoParts(repoFullName) {
|
|
182
881
|
const [owner, repo] = repoFullName.split("/");
|
|
183
882
|
if (!owner || !repo)
|
|
@@ -265,8 +964,15 @@ function originOf(url) {
|
|
|
265
964
|
}
|
|
266
965
|
/** Build the sticky comment body from the workflow's environment. */
|
|
267
966
|
export function buildCommentBody(env = process.env) {
|
|
268
|
-
const headShort = (env.HEAD_SHA || "").slice(0, 7);
|
|
269
967
|
const lines = [MARKER];
|
|
968
|
+
// Short head SHA for the "as of" freshness line.
|
|
969
|
+
const headSha = (env.HEAD_SHA || "").trim();
|
|
970
|
+
const headShort = headSha ? headSha.slice(0, 7) : "";
|
|
971
|
+
// Last-known plan id threaded from the previous run (supplied via PREV_PLAN_ID
|
|
972
|
+
// when the comment is rebuilt from scratch, or parsed from the env on upsert).
|
|
973
|
+
// We always emit the plan-id marker when any plan id is known so that a
|
|
974
|
+
// transient failure does not orphan the plan.
|
|
975
|
+
const prevPlanId = (env.PREV_PLAN_ID || "").trim() || null;
|
|
270
976
|
if (env.SUPPRESSED === "true") {
|
|
271
977
|
let reason = "potential secret in diff";
|
|
272
978
|
try {
|
|
@@ -281,7 +987,11 @@ export function buildCommentBody(env = process.env) {
|
|
|
281
987
|
lines.push("");
|
|
282
988
|
lines.push("The recap was **suppressed** because the diff matched a secret/credential pattern. No plan was published.");
|
|
283
989
|
lines.push("");
|
|
284
|
-
lines.push(`Reason: \`${reason}
|
|
990
|
+
lines.push(`Reason: \`${reason}\`.`);
|
|
991
|
+
if (headShort)
|
|
992
|
+
lines.push("", `_As of \`${headShort}\`_`);
|
|
993
|
+
if (prevPlanId)
|
|
994
|
+
lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
|
|
285
995
|
return lines.join("\n");
|
|
286
996
|
}
|
|
287
997
|
// Tiny diffs aren't worth a recap. Refresh an existing sticky comment to this
|
|
@@ -291,8 +1001,10 @@ export function buildCommentBody(env = process.env) {
|
|
|
291
1001
|
lines.push("### Visual recap — skipped (diff too small)");
|
|
292
1002
|
lines.push("");
|
|
293
1003
|
lines.push("The change in this push is too small to be worth a visual recap. This is informational only and does **not** block the PR.");
|
|
294
|
-
|
|
295
|
-
|
|
1004
|
+
if (headShort)
|
|
1005
|
+
lines.push("", `_As of \`${headShort}\`_`);
|
|
1006
|
+
if (prevPlanId)
|
|
1007
|
+
lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
|
|
296
1008
|
return lines.join("\n");
|
|
297
1009
|
}
|
|
298
1010
|
const planUrl = (env.PLAN_URL || "").trim();
|
|
@@ -307,12 +1019,25 @@ export function buildCommentBody(env = process.env) {
|
|
|
307
1019
|
const sameOriginOk = appUrl === "" || sameOrigin(planUrl, appUrl);
|
|
308
1020
|
const base = (appUrl || originOf(planUrl)).replace(/\/$/, "");
|
|
309
1021
|
const safeUrl = planId && base && sameOriginOk ? `${base}/recaps/${planId}` : "";
|
|
1022
|
+
// The plan id to embed in the marker — prefer the freshly-published one when
|
|
1023
|
+
// the origin is trusted, fall back to the previous run's id so the next push
|
|
1024
|
+
// can still replace in-place. Never use a plan id extracted from a bad-origin
|
|
1025
|
+
// URL as the marker (it would mask the last-good known id).
|
|
1026
|
+
const trustedPlanId = planId && sameOriginOk ? planId : null;
|
|
1027
|
+
const markerPlanId = trustedPlanId ?? prevPlanId;
|
|
310
1028
|
if (!safeUrl) {
|
|
311
1029
|
lines.push("### Visual recap — generation failed");
|
|
312
1030
|
lines.push("");
|
|
313
1031
|
lines.push("The visual recap could not be generated for this push. This is informational only and does **not** block the PR.");
|
|
314
|
-
|
|
315
|
-
|
|
1032
|
+
if (headShort)
|
|
1033
|
+
lines.push("", `_As of \`${headShort}\`_`);
|
|
1034
|
+
// Keep a link to the last-good recap so reviewers are not left in the dark.
|
|
1035
|
+
if (prevPlanId && base) {
|
|
1036
|
+
const prevSafeUrl = `${base}/recaps/${prevPlanId}`;
|
|
1037
|
+
lines.push("", `Previous recap (from an earlier push): [Open recap](${prevSafeUrl})`);
|
|
1038
|
+
}
|
|
1039
|
+
if (markerPlanId)
|
|
1040
|
+
lines.push("", `<!-- plan-id: ${markerPlanId} -->`);
|
|
316
1041
|
return lines.join("\n");
|
|
317
1042
|
}
|
|
318
1043
|
// The image URL is produced by our own recap-image route, but validate it is
|
|
@@ -321,10 +1046,10 @@ export function buildCommentBody(env = process.env) {
|
|
|
321
1046
|
const imageUrlRaw = (env.RECAP_IMAGE_URL || "").trim();
|
|
322
1047
|
const imageUrl = imageUrlRaw &&
|
|
323
1048
|
sameOrigin(imageUrlRaw, base) &&
|
|
324
|
-
|
|
1049
|
+
RECAP_IMAGE_URL_PATH_PATTERN.test(imageUrlRaw)
|
|
325
1050
|
? imageUrlRaw
|
|
326
1051
|
: "";
|
|
327
|
-
lines.push("### Visual recap
|
|
1052
|
+
lines.push("### Visual recap");
|
|
328
1053
|
lines.push("");
|
|
329
1054
|
if (imageUrl) {
|
|
330
1055
|
lines.push(`[](${safeUrl})`);
|
|
@@ -335,10 +1060,9 @@ export function buildCommentBody(env = process.env) {
|
|
|
335
1060
|
lines.push("");
|
|
336
1061
|
lines.push("> Large diff — this recap is a **summarized** view (top files + schema/API deltas).");
|
|
337
1062
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
lines.push("");
|
|
341
|
-
lines.push(`<!-- plan-id: ${planId} -->`);
|
|
1063
|
+
if (headShort)
|
|
1064
|
+
lines.push("", `_As of \`${headShort}\`_`);
|
|
1065
|
+
lines.push("", `<!-- plan-id: ${planId} -->`);
|
|
342
1066
|
return lines.join("\n");
|
|
343
1067
|
}
|
|
344
1068
|
/* -------------------------------------------------------------------------- */
|
|
@@ -355,21 +1079,75 @@ function runScan(args) {
|
|
|
355
1079
|
}
|
|
356
1080
|
}
|
|
357
1081
|
function runBuildPrompt(args) {
|
|
358
|
-
const
|
|
1082
|
+
const skillSource = optionalArg(args, "skill-source") ??
|
|
1083
|
+
process.env.VISUAL_RECAP_SKILL_SOURCE ??
|
|
1084
|
+
"auto";
|
|
1085
|
+
if (skillSource !== "auto" &&
|
|
1086
|
+
skillSource !== "latest" &&
|
|
1087
|
+
skillSource !== "repo") {
|
|
1088
|
+
throw new Error("--skill-source must be auto, latest, or repo.");
|
|
1089
|
+
}
|
|
1090
|
+
const skill = readVisualRecapSkillBundle(process.cwd(), skillSource);
|
|
359
1091
|
const prompt = buildRecapPrompt({
|
|
360
1092
|
skillMd: skill.text,
|
|
361
1093
|
pr: stringArg(args, "pr"),
|
|
1094
|
+
repo: optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY,
|
|
362
1095
|
head: optionalArg(args, "head"),
|
|
363
1096
|
appUrl: optionalArg(args, "app-url") ?? "https://plan.agent-native.com",
|
|
364
1097
|
diffPath: optionalArg(args, "diff") ?? "recap.diff",
|
|
365
1098
|
statPath: optionalArg(args, "stat"),
|
|
366
1099
|
prevPlanId: optionalArg(args, "prev-plan-id"),
|
|
367
1100
|
huge: args.huge === true || args.huge === "true",
|
|
1101
|
+
localFiles: args["local-files"] === true || args["local-files"] === "true",
|
|
1102
|
+
localDir: optionalArg(args, "local-dir"),
|
|
368
1103
|
});
|
|
369
1104
|
const out = optionalArg(args, "out") ?? "recap-prompt.md";
|
|
370
1105
|
fs.writeFileSync(path.resolve(out), prompt);
|
|
371
1106
|
process.stdout.write(`${JSON.stringify({ ok: true, out, skillSource: skill.source, bytes: prompt.length })}\n`);
|
|
372
1107
|
}
|
|
1108
|
+
function delay(ms) {
|
|
1109
|
+
return ms > 0
|
|
1110
|
+
? new Promise((resolve) => setTimeout(resolve, ms))
|
|
1111
|
+
: Promise.resolve();
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Confirm GitHub can fetch the uploaded image anonymously before we embed it.
|
|
1115
|
+
*
|
|
1116
|
+
* Default budget: 8 attempts with capped exponential backoff (1s, 2s, 3s, …
|
|
1117
|
+
* capped at 4s) → ~20s total. This is enough to survive a cold-start CDN
|
|
1118
|
+
* propagation delay that would otherwise cause `uploadRecapImage` to return a
|
|
1119
|
+
* URL that the GitHub PR comment can't display.
|
|
1120
|
+
*
|
|
1121
|
+
* The `attempts` and `delayMs` overrides remain for unit tests and for callers
|
|
1122
|
+
* that need a tighter or looser budget.
|
|
1123
|
+
*/
|
|
1124
|
+
export async function waitForPublicRecapImage(input) {
|
|
1125
|
+
const attempts = Math.max(1, input.attempts ?? 8);
|
|
1126
|
+
const delayMs = Math.max(0, input.delayMs ?? 1000);
|
|
1127
|
+
const fetchFn = input.fetchFn ?? fetch;
|
|
1128
|
+
const MAX_DELAY_MS = 4000;
|
|
1129
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1130
|
+
try {
|
|
1131
|
+
const res = await fetchFn(input.imageUrl, {
|
|
1132
|
+
method: "GET",
|
|
1133
|
+
headers: { accept: "image/png" },
|
|
1134
|
+
redirect: "follow",
|
|
1135
|
+
});
|
|
1136
|
+
const contentType = res.headers.get("content-type")?.toLowerCase() ?? "";
|
|
1137
|
+
if (res.ok && contentType.split(";")[0]?.trim() === "image/png") {
|
|
1138
|
+
const bytes = await res.arrayBuffer().catch(() => new ArrayBuffer(0));
|
|
1139
|
+
if (bytes.byteLength > 0)
|
|
1140
|
+
return true;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
/* retry below */
|
|
1145
|
+
}
|
|
1146
|
+
if (attempt < attempts)
|
|
1147
|
+
await delay(Math.min(delayMs * attempt, MAX_DELAY_MS));
|
|
1148
|
+
}
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
373
1151
|
/** Upload a PNG to the plan app's signed public image route; returns its URL. */
|
|
374
1152
|
async function uploadRecapImage(input) {
|
|
375
1153
|
try {
|
|
@@ -396,6 +1174,13 @@ async function uploadRecapImage(input) {
|
|
|
396
1174
|
process.stderr.write(`[recap shot] image upload returned no imageUrl (status ${res.status})\n`);
|
|
397
1175
|
return null;
|
|
398
1176
|
}
|
|
1177
|
+
const publiclyReadable = await waitForPublicRecapImage({
|
|
1178
|
+
imageUrl: json.imageUrl,
|
|
1179
|
+
});
|
|
1180
|
+
if (!publiclyReadable) {
|
|
1181
|
+
process.stderr.write(`[recap shot] uploaded image was not publicly readable as image/png: ${json.imageUrl}\n`);
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
399
1184
|
return json.imageUrl;
|
|
400
1185
|
}
|
|
401
1186
|
catch (err) {
|
|
@@ -501,7 +1286,7 @@ async function runShot(args) {
|
|
|
501
1286
|
// Zoom out slightly so more content fits. Keep the plan title (h1) in frame:
|
|
502
1287
|
// the recap reads better led by its own title than cropped to the body.
|
|
503
1288
|
await page.evaluate(() => {
|
|
504
|
-
document.documentElement.style.zoom = "
|
|
1289
|
+
document.documentElement.style.zoom = "90%";
|
|
505
1290
|
});
|
|
506
1291
|
await page.screenshot({ path: out });
|
|
507
1292
|
captured = true;
|
|
@@ -554,6 +1339,408 @@ async function runComment(args, sub) {
|
|
|
554
1339
|
}
|
|
555
1340
|
throw new Error("Usage: agent-native recap comment <find-plan-id|upsert> --repo owner/name --issue n --token token");
|
|
556
1341
|
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Files that, if a PR touches them, would let that PR rewrite what the trusted
|
|
1344
|
+
* recap job runs (the workflow itself, the skill, the local CLI, or any agent
|
|
1345
|
+
* config the runner loads) — so the whole job is skipped, not just the agent
|
|
1346
|
+
* step, to keep untrusted PR code away from the publish/API secrets.
|
|
1347
|
+
*
|
|
1348
|
+
* The `packages/core/**` rule is scoped to the BuilderIO/agent-native monorepo
|
|
1349
|
+
* (where packages/core IS the recap CLI source) so that consumer repos with an
|
|
1350
|
+
* unrelated `packages/core/` directory are not silently gated. Pass the
|
|
1351
|
+
* `repository` ("owner/name") to apply that scoping; omit it to match the old
|
|
1352
|
+
* unconditional behaviour (safe for the gate's self-test).
|
|
1353
|
+
*/
|
|
1354
|
+
export function isRecapSensitivePath(p, repository) {
|
|
1355
|
+
if (p === ".github/workflows/pr-visual-recap.yml" ||
|
|
1356
|
+
/(^|\/)skills\/visual-(recap|plan|plans)\//.test(p) ||
|
|
1357
|
+
/(^|\/)\.claude\//.test(p) ||
|
|
1358
|
+
/(^|\/)CLAUDE\.md$/.test(p) ||
|
|
1359
|
+
/(^|\/)AGENTS\.md$/.test(p) ||
|
|
1360
|
+
/(^|\/)\.mcp\.json$/.test(p)) {
|
|
1361
|
+
return true;
|
|
1362
|
+
}
|
|
1363
|
+
// packages/core is the recap-CLI source only in the agent-native monorepo.
|
|
1364
|
+
// In consumer repos an unrelated packages/core/ must not gate recaps.
|
|
1365
|
+
const isAgentNativeMonorepo = !repository || repository === "BuilderIO/agent-native";
|
|
1366
|
+
if (isAgentNativeMonorepo && /(^|\/)packages\/core\//.test(p)) {
|
|
1367
|
+
return true;
|
|
1368
|
+
}
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* The pure gate decision: given the PR payload, secret-presence flags, the
|
|
1373
|
+
* configured backend/model, and the PR's changed files, decide whether the
|
|
1374
|
+
* visual recap should run, which (normalized) agent to use, and — when skipped —
|
|
1375
|
+
* the human-readable reasons. This is the security boundary; it replicates the
|
|
1376
|
+
* inline github-script gate bit-for-bit. No I/O so it can be unit-tested.
|
|
1377
|
+
*/
|
|
1378
|
+
export function evaluateRecapGate(input) {
|
|
1379
|
+
const { pr } = input;
|
|
1380
|
+
const reasons = [];
|
|
1381
|
+
if (!pr)
|
|
1382
|
+
reasons.push("no pull_request payload");
|
|
1383
|
+
if (pr && pr.draft)
|
|
1384
|
+
reasons.push("draft PR");
|
|
1385
|
+
// Fork PRs: head repo differs from this repo. Plain pull_request runs fork
|
|
1386
|
+
// code with NO secrets, so publishing would fail anyway — skip.
|
|
1387
|
+
const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;
|
|
1388
|
+
if (pr && headRepo && headRepo !== input.repository) {
|
|
1389
|
+
reasons.push(`fork PR (${headRepo})`);
|
|
1390
|
+
}
|
|
1391
|
+
// Skip noisy automated authors.
|
|
1392
|
+
const login = ((pr && pr.user && pr.user.login) || "").toLowerCase();
|
|
1393
|
+
const botAuthors = [
|
|
1394
|
+
"dependabot[bot]",
|
|
1395
|
+
"dependabot",
|
|
1396
|
+
"renovate[bot]",
|
|
1397
|
+
"renovate",
|
|
1398
|
+
];
|
|
1399
|
+
if (botAuthors.includes(login))
|
|
1400
|
+
reasons.push(`bot author (${login})`);
|
|
1401
|
+
if (pr && pr.user && pr.user.type === "Bot")
|
|
1402
|
+
reasons.push("bot author (type=Bot)");
|
|
1403
|
+
// Publish secret must be configured — otherwise this is a no-op so the
|
|
1404
|
+
// workflow can be merged before secrets exist.
|
|
1405
|
+
if (!input.hasPlan)
|
|
1406
|
+
reasons.push("PLAN_RECAP_TOKEN not configured");
|
|
1407
|
+
// The chosen backend's API key must be present. Normalize the agent value once
|
|
1408
|
+
// here and validate it: an unknown or mis-cased value (e.g. "Claude", "gpt")
|
|
1409
|
+
// must NOT silently pass the gate and then match neither agent step.
|
|
1410
|
+
const agent = (input.agentRaw || "claude").toLowerCase();
|
|
1411
|
+
if (agent !== "claude" && agent !== "codex") {
|
|
1412
|
+
reasons.push(`unsupported VISUAL_RECAP_AGENT "${input.agentRaw}" (expected "claude" or "codex")`);
|
|
1413
|
+
}
|
|
1414
|
+
else if (agent === "codex") {
|
|
1415
|
+
if (!input.hasOpenai)
|
|
1416
|
+
reasons.push("OPENAI_API_KEY not configured (codex backend)");
|
|
1417
|
+
}
|
|
1418
|
+
else {
|
|
1419
|
+
if (!input.hasAnthropic)
|
|
1420
|
+
reasons.push("ANTHROPIC_API_KEY not configured (claude backend)");
|
|
1421
|
+
}
|
|
1422
|
+
// Validate VISUAL_RECAP_MODEL if set — an unchecked value could be injected by
|
|
1423
|
+
// a repo settings writer and passed straight to the agent CLI.
|
|
1424
|
+
const model = input.model || "";
|
|
1425
|
+
if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {
|
|
1426
|
+
reasons.push("invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})");
|
|
1427
|
+
}
|
|
1428
|
+
// Self-modifying guard: if this PR changes the workflow, the
|
|
1429
|
+
// visual-recap/visual-plan skill, the local CLI (packages/core), or any agent
|
|
1430
|
+
// config the runner would load (.claude/**, CLAUDE.md, .mcp.json), skip the
|
|
1431
|
+
// ENTIRE job — not just the agent — so a PR can never rewrite what runs
|
|
1432
|
+
// (skill, hooks, settings, CLI) and exfiltrate the publish/API secrets.
|
|
1433
|
+
const hits = input.changedFiles.filter((p) => isRecapSensitivePath(p, input.repository));
|
|
1434
|
+
if (hits.length) {
|
|
1435
|
+
reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(", ")}${hits.length > 3 ? ", …" : ""}) — skipping so untrusted PR code never runs with secrets`);
|
|
1436
|
+
}
|
|
1437
|
+
return { run: reasons.length === 0, agent, reasons };
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Page through `GET /repos/{owner}/{repo}/pulls/{n}/files`, following the
|
|
1441
|
+
* `Link` rel="next" header, and return every changed filename. Uses the same
|
|
1442
|
+
* api.github.com base + auth headers as `githubRequest`; reads the `Link`
|
|
1443
|
+
* header (which `githubRequest` discards) so it can paginate. Throws on any
|
|
1444
|
+
* non-2xx so the caller can fail CLOSED — exactly like the inline gate did when
|
|
1445
|
+
* `github.paginate(listFiles)` rejected.
|
|
1446
|
+
*/
|
|
1447
|
+
async function listPullRequestFiles(input) {
|
|
1448
|
+
const filenames = [];
|
|
1449
|
+
let url = `https://api.github.com/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/pulls/${input.pull}/files?per_page=100`;
|
|
1450
|
+
while (url) {
|
|
1451
|
+
const res = await fetch(url, {
|
|
1452
|
+
headers: {
|
|
1453
|
+
accept: "application/vnd.github+json",
|
|
1454
|
+
authorization: `Bearer ${input.token}`,
|
|
1455
|
+
"x-github-api-version": "2022-11-28",
|
|
1456
|
+
},
|
|
1457
|
+
});
|
|
1458
|
+
if (!res.ok) {
|
|
1459
|
+
const detail = await res.text().catch(() => "");
|
|
1460
|
+
throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${detail.slice(0, 500)}`);
|
|
1461
|
+
}
|
|
1462
|
+
const page = (await res.json());
|
|
1463
|
+
for (const f of page) {
|
|
1464
|
+
if (typeof f.filename === "string")
|
|
1465
|
+
filenames.push(f.filename);
|
|
1466
|
+
}
|
|
1467
|
+
// Follow Link rel="next" for the next page; absent => done.
|
|
1468
|
+
const link = res.headers.get("link") || "";
|
|
1469
|
+
const next = link.match(/<([^>]+)>\s*;\s*rel="next"/);
|
|
1470
|
+
url = next ? next[1] : null;
|
|
1471
|
+
}
|
|
1472
|
+
return filenames;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* `recap gate` — the I/O wrapper around `evaluateRecapGate`. Reads the PR
|
|
1476
|
+
* payload from GITHUB_EVENT_PATH, the secret-presence/agent/model signals from
|
|
1477
|
+
* the environment, and the PR's changed files from the GitHub REST API (paged,
|
|
1478
|
+
* with GH_TOKEN/GITHUB_TOKEN). Writes `run` + the normalized `agent` to
|
|
1479
|
+
* $GITHUB_OUTPUT and logs the run/skip summary. Fails CLOSED on any file-list
|
|
1480
|
+
* error so an untrusted PR can never run the agent with secrets.
|
|
1481
|
+
*/
|
|
1482
|
+
async function runGate() {
|
|
1483
|
+
const repository = process.env.GITHUB_REPOSITORY;
|
|
1484
|
+
// Read the pull_request object out of the event payload, tolerating a
|
|
1485
|
+
// missing/unreadable file (degrades to the "no pull_request payload" reason).
|
|
1486
|
+
let pr = null;
|
|
1487
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
1488
|
+
if (eventPath) {
|
|
1489
|
+
try {
|
|
1490
|
+
const payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
|
|
1491
|
+
pr = payload && payload.pull_request ? payload.pull_request : null;
|
|
1492
|
+
}
|
|
1493
|
+
catch {
|
|
1494
|
+
pr = null;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// Fetch the PR's changed files for the self-modifying guard. Any error here is
|
|
1498
|
+
// turned into a skip reason (fail-closed), mirroring the inline gate's
|
|
1499
|
+
// try/catch around github.paginate(listFiles).
|
|
1500
|
+
const changedFiles = [];
|
|
1501
|
+
let fileListError = null;
|
|
1502
|
+
if (pr && typeof pr.number === "number" && repository) {
|
|
1503
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "";
|
|
1504
|
+
try {
|
|
1505
|
+
const { owner, repo } = repoParts(repository);
|
|
1506
|
+
const files = await listPullRequestFiles({
|
|
1507
|
+
token,
|
|
1508
|
+
owner,
|
|
1509
|
+
repo,
|
|
1510
|
+
pull: pr.number,
|
|
1511
|
+
});
|
|
1512
|
+
changedFiles.push(...files);
|
|
1513
|
+
}
|
|
1514
|
+
catch (e) {
|
|
1515
|
+
fileListError = e instanceof Error ? e.message : String(e);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const decision = evaluateRecapGate({
|
|
1519
|
+
pr,
|
|
1520
|
+
repository,
|
|
1521
|
+
hasPlan: process.env.HAS_PLAN === "true",
|
|
1522
|
+
hasAnthropic: process.env.HAS_ANTHROPIC === "true",
|
|
1523
|
+
hasOpenai: process.env.HAS_OPENAI === "true",
|
|
1524
|
+
agentRaw: process.env.AGENT,
|
|
1525
|
+
model: process.env.VISUAL_RECAP_MODEL,
|
|
1526
|
+
changedFiles,
|
|
1527
|
+
});
|
|
1528
|
+
// If listing PR files failed, append the same fail-closed reason the inline
|
|
1529
|
+
// gate used and force run=false.
|
|
1530
|
+
let { run } = decision;
|
|
1531
|
+
const reasons = [...decision.reasons];
|
|
1532
|
+
if (fileListError !== null) {
|
|
1533
|
+
reasons.push(`could not list PR files for the self-modifying guard (${fileListError}); skipping to be safe`);
|
|
1534
|
+
run = false;
|
|
1535
|
+
}
|
|
1536
|
+
// Preserve the github-script contract: write `run` + the NORMALIZED agent to
|
|
1537
|
+
// $GITHUB_OUTPUT so the recap job's step conditions match case-insensitively.
|
|
1538
|
+
const githubOutput = process.env.GITHUB_OUTPUT;
|
|
1539
|
+
if (githubOutput) {
|
|
1540
|
+
fs.appendFileSync(githubOutput, `run=${run ? "true" : "false"}\nagent=${decision.agent}\n`);
|
|
1541
|
+
}
|
|
1542
|
+
// eslint-disable-next-line no-console
|
|
1543
|
+
console.log(run
|
|
1544
|
+
? `Visual recap will run (${decision.agent}).`
|
|
1545
|
+
: `Visual recap skipped: ${reasons.join("; ")}`);
|
|
1546
|
+
}
|
|
1547
|
+
/* -------------------------------------------------------------------------- */
|
|
1548
|
+
/* Check run — the "Visual Recap" GitHub check (was two inline github-script */
|
|
1549
|
+
/* steps in the workflow's recap job). */
|
|
1550
|
+
/* -------------------------------------------------------------------------- */
|
|
1551
|
+
/**
|
|
1552
|
+
* Canonicalize the agent-written plan URL into a trusted recap URL, or "".
|
|
1553
|
+
*
|
|
1554
|
+
* recap-url.txt is produced by the (LLM) agent, so the raw URL is untrusted.
|
|
1555
|
+
* This rebuilds a canonical `${origin}${base}/recaps/<id>` link from the TRUSTED
|
|
1556
|
+
* app URL plus a strictly-validated plan id, enforcing the app origin and
|
|
1557
|
+
* honoring a path-prefixed mount (e.g. https://host/agent-native). Returns ""
|
|
1558
|
+
* for a wrong origin or an unrecognized path. Pure so it can be unit-tested —
|
|
1559
|
+
* SAME impl as the workflow's previous inline `canonicalRecapUrl`.
|
|
1560
|
+
*/
|
|
1561
|
+
export function canonicalRecapUrl(rawUrl, appUrl) {
|
|
1562
|
+
try {
|
|
1563
|
+
const parsed = new URL(rawUrl);
|
|
1564
|
+
const trusted = new URL(appUrl || "https://plan.agent-native.com");
|
|
1565
|
+
if (parsed.origin !== trusted.origin)
|
|
1566
|
+
return "";
|
|
1567
|
+
// Honor a path-prefixed mount (e.g. https://host/agent-native): strip the
|
|
1568
|
+
// trusted base path before matching /plans|recaps/<id>.
|
|
1569
|
+
const base = trusted.pathname.replace(/\/$/, "");
|
|
1570
|
+
let rest = parsed.pathname;
|
|
1571
|
+
if (base && rest.startsWith(base))
|
|
1572
|
+
rest = rest.slice(base.length);
|
|
1573
|
+
const match = rest.match(/^\/(?:plans|recaps)\/([A-Za-z0-9_-]+)\/?$/);
|
|
1574
|
+
return match ? `${trusted.origin}${base}/recaps/${match[1]}` : "";
|
|
1575
|
+
}
|
|
1576
|
+
catch {
|
|
1577
|
+
return "";
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Map the workflow's terminal recap state to the completed check's
|
|
1582
|
+
* conclusion/title/summary/text/details_url. Pure so it can be unit-tested —
|
|
1583
|
+
* reproduces the workflow's previous inline branch logic EXACTLY:
|
|
1584
|
+
*
|
|
1585
|
+
* - default → neutral "Visual recap not generated"
|
|
1586
|
+
* - planOk + valid recapUrl → success "Visual recap ready" (huge → "summarized"
|
|
1587
|
+
* summary), Open-recap link as text, details_url = recapUrl
|
|
1588
|
+
* - planOk + invalid url → neutral "Visual recap published" (see the comment)
|
|
1589
|
+
* - else tiny → skipped "Visual recap skipped"
|
|
1590
|
+
* - else suppressed → skipped "Visual recap suppressed" (reason from scan JSON)
|
|
1591
|
+
*/
|
|
1592
|
+
export function recapCheckOutcome(input) {
|
|
1593
|
+
let conclusion = "neutral";
|
|
1594
|
+
let title = "Visual recap not generated";
|
|
1595
|
+
let summary = "The visual recap did not produce a plan URL. This is informational only and does not block the PR.";
|
|
1596
|
+
let text = "";
|
|
1597
|
+
let detailsUrl = input.workflowUrl;
|
|
1598
|
+
if (input.planOk) {
|
|
1599
|
+
const recapUrl = canonicalRecapUrl(input.planUrl, input.appUrl);
|
|
1600
|
+
if (recapUrl) {
|
|
1601
|
+
conclusion = "success";
|
|
1602
|
+
title = "Visual recap ready";
|
|
1603
|
+
summary = input.huge
|
|
1604
|
+
? "A summarized visual recap was generated for this large PR."
|
|
1605
|
+
: "A visual code-review recap was generated for this PR.";
|
|
1606
|
+
detailsUrl = recapUrl;
|
|
1607
|
+
text = `**[Open visual recap](${recapUrl})**`;
|
|
1608
|
+
}
|
|
1609
|
+
else {
|
|
1610
|
+
// Agent reported success but the URL didn't validate against the trusted
|
|
1611
|
+
// plan origin — don't claim "not generated"; the recap is linked in the
|
|
1612
|
+
// sticky comment.
|
|
1613
|
+
title = "Visual recap published";
|
|
1614
|
+
summary =
|
|
1615
|
+
"A recap was published; see the visual recap comment on this PR for the link.";
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
else if (input.tiny) {
|
|
1619
|
+
conclusion = "skipped";
|
|
1620
|
+
title = "Visual recap skipped";
|
|
1621
|
+
summary = "The diff is too small to need a visual recap.";
|
|
1622
|
+
}
|
|
1623
|
+
else if (input.suppressed) {
|
|
1624
|
+
let reason = "potential secret in diff";
|
|
1625
|
+
try {
|
|
1626
|
+
const parsed = JSON.parse(input.suppressedJson || "{}");
|
|
1627
|
+
if (parsed && typeof parsed.reason === "string")
|
|
1628
|
+
reason = parsed.reason;
|
|
1629
|
+
}
|
|
1630
|
+
catch {
|
|
1631
|
+
// Keep the default reason.
|
|
1632
|
+
}
|
|
1633
|
+
conclusion = "skipped";
|
|
1634
|
+
title = "Visual recap suppressed";
|
|
1635
|
+
summary = `No recap was published because ${reason}.`;
|
|
1636
|
+
}
|
|
1637
|
+
return { conclusion, title, summary, text, detailsUrl };
|
|
1638
|
+
}
|
|
1639
|
+
function boolFlag(args, key) {
|
|
1640
|
+
return args[key] === true || args[key] === "true";
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* `recap check start` — create the in-progress "Visual Recap" GitHub check run
|
|
1644
|
+
* and write its id to $GITHUB_OUTPUT (check_run_id). Best-effort: on any API
|
|
1645
|
+
* error, warn on stderr and exit 0 (don't fail the job) without emitting an id.
|
|
1646
|
+
* Replaces the workflow's inline "Start visual recap check" github-script step.
|
|
1647
|
+
*/
|
|
1648
|
+
async function runCheckStart(args) {
|
|
1649
|
+
const repo = optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY ?? "";
|
|
1650
|
+
const sha = optionalArg(args, "sha") ?? process.env.HEAD_SHA ?? "";
|
|
1651
|
+
const token = optionalArg(args, "token") ||
|
|
1652
|
+
process.env.GH_TOKEN ||
|
|
1653
|
+
process.env.GITHUB_TOKEN ||
|
|
1654
|
+
"";
|
|
1655
|
+
const workflowUrl = optionalArg(args, "workflow-url") ?? "";
|
|
1656
|
+
const emit = (id) => {
|
|
1657
|
+
const githubOutput = process.env.GITHUB_OUTPUT;
|
|
1658
|
+
if (githubOutput) {
|
|
1659
|
+
fs.appendFileSync(githubOutput, `check_run_id=${id}\n`);
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
try {
|
|
1663
|
+
const { owner, repo: name } = repoParts(repo);
|
|
1664
|
+
const created = await githubRequest(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/check-runs`, {
|
|
1665
|
+
method: "POST",
|
|
1666
|
+
headers: { "content-type": "application/json" },
|
|
1667
|
+
body: JSON.stringify({
|
|
1668
|
+
name: "Visual Recap",
|
|
1669
|
+
head_sha: sha,
|
|
1670
|
+
status: "in_progress",
|
|
1671
|
+
started_at: new Date().toISOString(),
|
|
1672
|
+
details_url: workflowUrl,
|
|
1673
|
+
output: {
|
|
1674
|
+
title: "Visual recap in progress",
|
|
1675
|
+
summary: "Generating a visual code-review recap for this pull request.",
|
|
1676
|
+
},
|
|
1677
|
+
}),
|
|
1678
|
+
});
|
|
1679
|
+
emit(String(created.id));
|
|
1680
|
+
}
|
|
1681
|
+
catch (err) {
|
|
1682
|
+
process.stderr.write(`[recap check] could not create Visual Recap check run: ${String(err)}\n`);
|
|
1683
|
+
// Best-effort: don't fail the job and don't emit a check_run_id.
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* `recap check complete` — PATCH the "Visual Recap" check run to completed with
|
|
1688
|
+
* the computed conclusion/title/summary/text/details_url. Best-effort: on any
|
|
1689
|
+
* API error, warn on stderr and exit 0. Replaces the workflow's inline
|
|
1690
|
+
* "Complete visual recap check" github-script step.
|
|
1691
|
+
*/
|
|
1692
|
+
async function runCheckComplete(args) {
|
|
1693
|
+
const repo = optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY ?? "";
|
|
1694
|
+
const token = optionalArg(args, "token") ||
|
|
1695
|
+
process.env.GH_TOKEN ||
|
|
1696
|
+
process.env.GITHUB_TOKEN ||
|
|
1697
|
+
"";
|
|
1698
|
+
const checkRunId = optionalArg(args, "check-run-id") ?? "";
|
|
1699
|
+
const outcome = recapCheckOutcome({
|
|
1700
|
+
planOk: boolFlag(args, "plan-ok"),
|
|
1701
|
+
planUrl: optionalArg(args, "plan-url") ?? "",
|
|
1702
|
+
appUrl: optionalArg(args, "app-url") ?? process.env.PLAN_RECAP_APP_URL ?? "",
|
|
1703
|
+
huge: boolFlag(args, "huge"),
|
|
1704
|
+
tiny: boolFlag(args, "tiny"),
|
|
1705
|
+
suppressed: boolFlag(args, "suppressed"),
|
|
1706
|
+
suppressedJson: optionalArg(args, "suppressed-json") ?? "",
|
|
1707
|
+
workflowUrl: optionalArg(args, "workflow-url") ?? "",
|
|
1708
|
+
});
|
|
1709
|
+
try {
|
|
1710
|
+
const { owner, repo: name } = repoParts(repo);
|
|
1711
|
+
await githubRequest(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/check-runs/${encodeURIComponent(checkRunId)}`, {
|
|
1712
|
+
method: "PATCH",
|
|
1713
|
+
headers: { "content-type": "application/json" },
|
|
1714
|
+
body: JSON.stringify({
|
|
1715
|
+
status: "completed",
|
|
1716
|
+
conclusion: outcome.conclusion,
|
|
1717
|
+
completed_at: new Date().toISOString(),
|
|
1718
|
+
details_url: outcome.detailsUrl,
|
|
1719
|
+
output: {
|
|
1720
|
+
title: outcome.title,
|
|
1721
|
+
summary: outcome.summary,
|
|
1722
|
+
text: outcome.text,
|
|
1723
|
+
},
|
|
1724
|
+
}),
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
catch (err) {
|
|
1728
|
+
process.stderr.write(`[recap check] could not update Visual Recap check run: ${String(err)}\n`);
|
|
1729
|
+
// Best-effort: don't fail the job.
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
/** `recap check <start|complete>` dispatcher. */
|
|
1733
|
+
async function runCheck(args, sub) {
|
|
1734
|
+
if (sub === "start") {
|
|
1735
|
+
await runCheckStart(args);
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (sub === "complete") {
|
|
1739
|
+
await runCheckComplete(args);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
throw new Error("Usage: agent-native recap check <start|complete> [flags] (see `recap help`)");
|
|
1743
|
+
}
|
|
557
1744
|
/** Parse the last top-level JSON object from a possibly-noisy stdout dump. */
|
|
558
1745
|
function parseLastJsonObject(text) {
|
|
559
1746
|
const trimmed = text.trim();
|
|
@@ -728,16 +1915,63 @@ async function runUsage(args) {
|
|
|
728
1915
|
const HELP = `agent-native recap — PR visual recap helpers (used by the GitHub Action)
|
|
729
1916
|
|
|
730
1917
|
Usage:
|
|
1918
|
+
agent-native recap setup [--repo owner/name] [--agent claude|codex] [--app-url <url>] [--skip-secrets] [--dry-run]
|
|
1919
|
+
agent-native recap doctor [--repo owner/name] [--agent claude|codex] [--app-url <url>]
|
|
1920
|
+
agent-native recap collect-diff --base <baseSha> --head <headSha> [--out recap.diff] [--stat recap.stat]
|
|
1921
|
+
agent-native recap mcp-config --agent claude|codex --app-url <url> [--out <path>]
|
|
731
1922
|
agent-native recap scan --diff <path>
|
|
732
|
-
agent-native recap build-prompt --pr <n> [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--prev-plan-id <id>] [--huge] [--out <path>]
|
|
1923
|
+
agent-native recap build-prompt --pr <n> [--repo owner/name] [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--prev-plan-id <id>] [--huge] [--local-files] [--local-dir <folder>] [--skill-source auto|latest|repo] [--out <path>]
|
|
733
1924
|
agent-native recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png]
|
|
734
1925
|
agent-native recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex] [--model <id>]
|
|
735
1926
|
agent-native recap comment <find-plan-id|upsert> --repo owner/name --issue <n> --token <github-token>
|
|
1927
|
+
agent-native recap check start [--repo owner/name] [--sha <headSha>] [--token <github-token>] [--workflow-url <url>]
|
|
1928
|
+
Create the in-progress "Visual Recap" GitHub check run and write its id to
|
|
1929
|
+
$GITHUB_OUTPUT (check_run_id). repo/sha/token default to GITHUB_REPOSITORY /
|
|
1930
|
+
HEAD_SHA / GH_TOKEN (or GITHUB_TOKEN). Best-effort: warns and exits 0 on any
|
|
1931
|
+
API error without emitting an id.
|
|
1932
|
+
agent-native recap check complete --check-run-id <id> [--repo owner/name] [--token <github-token>] [--plan-ok <bool>] [--plan-url <url>] [--app-url <url>] [--suppressed <bool>] [--suppressed-json <json>] [--huge <bool>] [--tiny <bool>] [--workflow-url <url>]
|
|
1933
|
+
Mark the "Visual Recap" check run completed with a computed
|
|
1934
|
+
conclusion/title/summary/text/details_url (success when the agent published a
|
|
1935
|
+
plan whose URL validates against --app-url; neutral/skipped otherwise).
|
|
1936
|
+
repo/token/app-url default to GITHUB_REPOSITORY / GH_TOKEN / PLAN_RECAP_APP_URL.
|
|
1937
|
+
Best-effort: warns and exits 0 on any API error.
|
|
1938
|
+
agent-native recap gate
|
|
1939
|
+
The PR Visual Recap security gate. Decides whether to run the recap at all
|
|
1940
|
+
and which (normalized) backend agent to use. Reads the pull_request payload
|
|
1941
|
+
from $GITHUB_EVENT_PATH, the secret-presence/agent/model signals from the
|
|
1942
|
+
environment (HAS_PLAN / HAS_ANTHROPIC / HAS_OPENAI === 'true', AGENT,
|
|
1943
|
+
VISUAL_RECAP_MODEL), the repo from $GITHUB_REPOSITORY, and the PR's changed
|
|
1944
|
+
files from the GitHub REST API (paged, with GH_TOKEN/GITHUB_TOKEN). Skips
|
|
1945
|
+
drafts, forks, bot authors, the missing-secret case, an invalid agent/model,
|
|
1946
|
+
and any PR that touches recap-control files (the workflow, the skill,
|
|
1947
|
+
packages/core, .claude/**, CLAUDE.md, AGENTS.md, .mcp.json) — failing CLOSED
|
|
1948
|
+
on any file-list error. Writes run=<true|false> and agent=<claude|codex> to
|
|
1949
|
+
$GITHUB_OUTPUT.
|
|
1950
|
+
agent-native recap setup
|
|
1951
|
+
Write/refresh .github/workflows/pr-visual-recap.yml, then configure GitHub
|
|
1952
|
+
Actions secrets and variables with gh when values are available from env or
|
|
1953
|
+
the local Plans publish-token store. Missing values are printed as exact next
|
|
1954
|
+
commands; secret values are sent to gh through stdin, never argv.
|
|
1955
|
+
agent-native recap doctor
|
|
1956
|
+
Check workflow presence/drift, local Plans publish-token availability, gh
|
|
1957
|
+
repo access, and required GitHub Actions secrets for the selected backend.
|
|
736
1958
|
`;
|
|
737
1959
|
export async function runRecap(argv) {
|
|
738
1960
|
const [sub, ...rest] = argv;
|
|
739
1961
|
const args = parseArgs(rest);
|
|
740
1962
|
switch (sub) {
|
|
1963
|
+
case "setup":
|
|
1964
|
+
runSetup(args);
|
|
1965
|
+
return;
|
|
1966
|
+
case "doctor":
|
|
1967
|
+
runDoctor(args);
|
|
1968
|
+
return;
|
|
1969
|
+
case "collect-diff":
|
|
1970
|
+
runCollectDiff(args);
|
|
1971
|
+
return;
|
|
1972
|
+
case "mcp-config":
|
|
1973
|
+
runMcpConfig(args);
|
|
1974
|
+
return;
|
|
741
1975
|
case "scan":
|
|
742
1976
|
runScan(args);
|
|
743
1977
|
return;
|
|
@@ -753,6 +1987,12 @@ export async function runRecap(argv) {
|
|
|
753
1987
|
case "comment":
|
|
754
1988
|
await runComment(parseArgs(rest.slice(1)), rest[0] ?? "");
|
|
755
1989
|
return;
|
|
1990
|
+
case "check":
|
|
1991
|
+
await runCheck(parseArgs(rest.slice(1)), rest[0] ?? "");
|
|
1992
|
+
return;
|
|
1993
|
+
case "gate":
|
|
1994
|
+
await runGate();
|
|
1995
|
+
return;
|
|
756
1996
|
case "help":
|
|
757
1997
|
case "--help":
|
|
758
1998
|
case "-h":
|