@agent-native/core 0.45.0 → 0.46.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.
- package/README.md +1 -0
- 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.map +1 -1
- package/dist/cli/connect.js +39 -5
- 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 +42 -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.map +1 -1
- package/dist/cli/plan-local.js +15 -2
- package/dist/cli/plan-local.js.map +1 -1
- 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 +63 -5
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +641 -48
- 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 +644 -972
- 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 +37 -9
- 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 +44 -12
- package/dist/client/blocks/library/DiffBlock.js.map +1 -1
- package/dist/client/blocks/library/annotation-rail.d.ts +12 -3
- package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
- package/dist/client/blocks/library/annotation-rail.js +29 -3
- package/dist/client/blocks/library/annotation-rail.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/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/components/LiveCursorOverlay.d.ts +46 -0
- package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -0
- package/dist/client/components/LiveCursorOverlay.js +137 -0
- package/dist/client/components/LiveCursorOverlay.js.map +1 -0
- package/dist/client/components/PresenceBar.d.ts +11 -1
- package/dist/client/components/PresenceBar.d.ts.map +1 -1
- package/dist/client/components/PresenceBar.js +39 -7
- package/dist/client/components/PresenceBar.js.map +1 -1
- package/dist/client/components/RemoteSelectionRings.d.ts +43 -0
- package/dist/client/components/RemoteSelectionRings.d.ts.map +1 -0
- package/dist/client/components/RemoteSelectionRings.js +116 -0
- package/dist/client/components/RemoteSelectionRings.js.map +1 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -1
- package/dist/collab/awareness.d.ts +25 -0
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/awareness.js +42 -5
- package/dist/collab/awareness.js.map +1 -1
- package/dist/collab/client.d.ts +19 -1
- package/dist/collab/client.d.ts.map +1 -1
- package/dist/collab/client.js +362 -57
- package/dist/collab/client.js.map +1 -1
- package/dist/collab/follow-mode.d.ts +56 -0
- package/dist/collab/follow-mode.d.ts.map +1 -0
- package/dist/collab/follow-mode.js +54 -0
- package/dist/collab/follow-mode.js.map +1 -0
- package/dist/collab/index.d.ts +3 -1
- package/dist/collab/index.d.ts.map +1 -1
- package/dist/collab/index.js +5 -1
- package/dist/collab/index.js.map +1 -1
- package/dist/collab/presence.d.ts +56 -0
- package/dist/collab/presence.d.ts.map +1 -0
- package/dist/collab/presence.js +98 -0
- package/dist/collab/presence.js.map +1 -0
- package/dist/collab/routes.d.ts.map +1 -1
- package/dist/collab/routes.js +33 -6
- package/dist/collab/routes.js.map +1 -1
- package/dist/collab/struct-routes.d.ts.map +1 -1
- package/dist/collab/struct-routes.js +24 -4
- package/dist/collab/struct-routes.js.map +1 -1
- package/dist/collab/ydoc-manager.d.ts +13 -0
- package/dist/collab/ydoc-manager.d.ts.map +1 -1
- package/dist/collab/ydoc-manager.js +51 -15
- package/dist/collab/ydoc-manager.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/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/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/collab-plugin.d.ts +6 -0
- package/dist/server/collab-plugin.d.ts.map +1 -1
- package/dist/server/collab-plugin.js +105 -5
- package/dist/server/collab-plugin.js.map +1 -1
- package/dist/server/poll-events.d.ts +5 -0
- package/dist/server/poll-events.d.ts.map +1 -1
- package/dist/server/poll-events.js +27 -4
- package/dist/server/poll-events.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/default/.agents/skills/real-time-collab/SKILL.md +185 -37
- package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
- package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
- package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
- package/docs/content/plan-plugin.md +21 -6
- package/docs/content/pr-visual-recap.md +52 -3
- package/docs/content/real-time-collaboration.md +481 -97
- package/docs/content/skills-guide.md +13 -0
- package/docs/content/template-plan.md +18 -7
- package/package.json +5 -1
- package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
- package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
- package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
- package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
package/dist/cli/recap.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
@@ -17,10 +17,15 @@
|
|
|
17
17
|
* mcp-config Write the plan MCP client config for the chosen backend
|
|
18
18
|
* (Claude Code JSON or Codex config.toml).
|
|
19
19
|
* scan Refuse to hand a secret-leaking diff to the agent.
|
|
20
|
-
* 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).
|
|
21
22
|
* shot Screenshot the published plan and upload it to the plan app's
|
|
22
23
|
* signed public image route (for an inline PR-comment image).
|
|
24
|
+
* usage Parse and emit agent token-usage/cost from stdout.
|
|
23
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.
|
|
24
29
|
*
|
|
25
30
|
* Promoting these to the published CLI means an installed repo's workflow calls
|
|
26
31
|
* `agent-native recap …` instead of copying helper scripts into the repo.
|
|
@@ -31,7 +36,9 @@ import { execFileSync } from "node:child_process";
|
|
|
31
36
|
import fs from "node:fs";
|
|
32
37
|
import os from "node:os";
|
|
33
38
|
import path from "node:path";
|
|
39
|
+
import { readPlanPublishAuth } from "./plan-publish-store.js";
|
|
34
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";
|
|
35
42
|
/* -------------------------------------------------------------------------- */
|
|
36
43
|
/* Arg parsing */
|
|
37
44
|
/* -------------------------------------------------------------------------- */
|
|
@@ -74,6 +81,7 @@ export const PR_VISUAL_RECAP_SETUP = [
|
|
|
74
81
|
"Optional (only if you change defaults):",
|
|
75
82
|
" OPENAI_API_KEY (secret) + VISUAL_RECAP_AGENT=codex (variable) — use Codex instead of Claude",
|
|
76
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",
|
|
77
85
|
" PLAN_RECAP_APP_URL (secret) — only when self-hosting the plan app (defaults to https://plan.agent-native.com)",
|
|
78
86
|
];
|
|
79
87
|
/** Write .github/workflows/pr-visual-recap.yml into a repo. */
|
|
@@ -85,6 +93,335 @@ export function writePrVisualRecapWorkflow(baseDir) {
|
|
|
85
93
|
fs.writeFileSync(file, PR_VISUAL_RECAP_WORKFLOW_YML);
|
|
86
94
|
return { path: path.relative(baseDir, file), existed };
|
|
87
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
|
+
}
|
|
88
425
|
/* -------------------------------------------------------------------------- */
|
|
89
426
|
/* Secret scan — defense-in-depth before any LLM sees the diff */
|
|
90
427
|
/* -------------------------------------------------------------------------- */
|
|
@@ -180,22 +517,33 @@ export function truncateDiffAtLineBoundary(text) {
|
|
|
180
517
|
const body = lastNewline >= 0 ? capped.slice(0, lastNewline) : "";
|
|
181
518
|
return body + RECAP_DIFF_TRUNCATED_FOOTER;
|
|
182
519
|
}
|
|
183
|
-
/**
|
|
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
|
+
*/
|
|
184
526
|
export function countDiffLines(diffText) {
|
|
185
527
|
let count = 0;
|
|
186
528
|
for (const line of diffText.split("\n")) {
|
|
529
|
+
if (line.startsWith("+++") || line.startsWith("---"))
|
|
530
|
+
continue;
|
|
187
531
|
if (line.startsWith("+") || line.startsWith("-"))
|
|
188
532
|
count += 1;
|
|
189
533
|
}
|
|
190
534
|
return count;
|
|
191
535
|
}
|
|
192
536
|
/**
|
|
193
|
-
* Run `git diff <base>...<head> -- <pathspecs>` and return its stdout
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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.
|
|
197
545
|
*/
|
|
198
|
-
function
|
|
546
|
+
function gitDiffRaw(base, head, extraArgs) {
|
|
199
547
|
const args = [
|
|
200
548
|
"diff",
|
|
201
549
|
"--no-color",
|
|
@@ -205,19 +553,22 @@ function gitDiff(base, head, extraArgs) {
|
|
|
205
553
|
...RECAP_DIFF_PATHSPECS,
|
|
206
554
|
];
|
|
207
555
|
try {
|
|
208
|
-
|
|
556
|
+
const stdout = execFileSync("git", args, {
|
|
209
557
|
encoding: "utf8",
|
|
210
558
|
maxBuffer: 256 * 1024 * 1024,
|
|
211
559
|
});
|
|
560
|
+
return { stdout, failed: false };
|
|
212
561
|
}
|
|
213
562
|
catch (err) {
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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() === "" };
|
|
221
572
|
}
|
|
222
573
|
}
|
|
223
574
|
/**
|
|
@@ -226,6 +577,10 @@ function gitDiff(base, head, extraArgs) {
|
|
|
226
577
|
* emits the same `bytes/changed/huge/tiny` outputs the workflow expects:
|
|
227
578
|
* appended to $GITHUB_OUTPUT when set, AND printed as JSON to stdout (so it runs
|
|
228
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.
|
|
229
584
|
*/
|
|
230
585
|
function runCollectDiff(args) {
|
|
231
586
|
const base = stringArg(args, "base");
|
|
@@ -233,14 +588,22 @@ function runCollectDiff(args) {
|
|
|
233
588
|
const outPath = optionalArg(args, "out") ?? "recap.diff";
|
|
234
589
|
const statPath = optionalArg(args, "stat") ?? "recap.stat";
|
|
235
590
|
// The unified diff and the --stat summary (both excluding lockfiles/noise).
|
|
236
|
-
|
|
237
|
-
|
|
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;
|
|
238
601
|
fs.writeFileSync(path.resolve(statPath), stat);
|
|
239
602
|
// ORIGINAL line count — captured BEFORE any byte-cap truncation so a large
|
|
240
603
|
// diff is never misclassified as tiny after truncation.
|
|
241
604
|
const originalLines = countDiffLines(diff);
|
|
242
605
|
// Changed-file count from `--name-only` over the same excludes.
|
|
243
|
-
const names =
|
|
606
|
+
const names = gitDiffRaw(base, head, ["--name-only"]).stdout;
|
|
244
607
|
const changed = names.split("\n").filter((line) => line.length > 0).length;
|
|
245
608
|
// Write the (possibly truncated) diff and compute the on-disk byte length.
|
|
246
609
|
const bytesBefore = Buffer.byteLength(diff, "utf8");
|
|
@@ -300,17 +663,72 @@ export function buildRecapCodexMcpConfig(appUrl) {
|
|
|
300
663
|
function runMcpConfig(args) {
|
|
301
664
|
const agent = stringArg(args, "agent").toLowerCase();
|
|
302
665
|
const appUrl = stringArg(args, "app-url");
|
|
666
|
+
const force = Boolean(args["force"]);
|
|
303
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
|
+
}
|
|
304
674
|
const out = stringArg(args, "out");
|
|
305
|
-
fs.writeFileSync(path.resolve(out), buildRecapClaudeMcpConfig(appUrl,
|
|
675
|
+
fs.writeFileSync(path.resolve(out), buildRecapClaudeMcpConfig(appUrl, token));
|
|
306
676
|
process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
|
|
307
677
|
return;
|
|
308
678
|
}
|
|
309
679
|
if (agent === "codex") {
|
|
310
680
|
const out = optionalArg(args, "out") ??
|
|
311
681
|
path.join(os.homedir(), ".codex", "config.toml");
|
|
312
|
-
|
|
313
|
-
fs.
|
|
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
|
+
}
|
|
314
732
|
process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
|
|
315
733
|
return;
|
|
316
734
|
}
|
|
@@ -339,6 +757,62 @@ export function readRepoSkillMd(cwd = process.cwd()) {
|
|
|
339
757
|
}
|
|
340
758
|
throw new Error("Could not find visual-recap/SKILL.md. Run `agent-native skills add visual-plan` first.");
|
|
341
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
|
+
}
|
|
342
816
|
export function buildRecapPrompt(input) {
|
|
343
817
|
const appUrl = input.appUrl.replace(/\/$/, "");
|
|
344
818
|
const localDir = input.localDir ?? path.join("plans", `pr-${input.pr}-visual-recap`);
|
|
@@ -375,16 +849,21 @@ export function buildRecapPrompt(input) {
|
|
|
375
849
|
}
|
|
376
850
|
else {
|
|
377
851
|
lines.push("## Publish (this is the only way to produce output)");
|
|
378
|
-
lines.push(`The \`plan\` MCP server is configured for you. Call its tools by name (your host may expose them as \`create-visual-recap\` or \`mcp__plan__create-visual-recap\` — same
|
|
379
|
-
lines.push(`
|
|
380
|
-
|
|
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`
|
|
381
856
|
: ""}.`);
|
|
382
|
-
lines.push(`2.
|
|
383
|
-
lines.push(`3.
|
|
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.`);
|
|
384
859
|
}
|
|
385
860
|
lines.push("");
|
|
386
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.");
|
|
387
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("");
|
|
388
867
|
lines.push("---");
|
|
389
868
|
lines.push("");
|
|
390
869
|
lines.push("# visual-recap skill (follow this exactly)");
|
|
@@ -397,6 +876,7 @@ export function buildRecapPrompt(input) {
|
|
|
397
876
|
/* GitHub comment helpers */
|
|
398
877
|
/* -------------------------------------------------------------------------- */
|
|
399
878
|
const MARKER = "<!-- pr-visual-recap -->";
|
|
879
|
+
const RECAP_IMAGE_URL_PATH_PATTERN = /\/_agent-native\/recap-image\/[0-9a-f]{32,128}\.png$/;
|
|
400
880
|
function repoParts(repoFullName) {
|
|
401
881
|
const [owner, repo] = repoFullName.split("/");
|
|
402
882
|
if (!owner || !repo)
|
|
@@ -484,8 +964,15 @@ function originOf(url) {
|
|
|
484
964
|
}
|
|
485
965
|
/** Build the sticky comment body from the workflow's environment. */
|
|
486
966
|
export function buildCommentBody(env = process.env) {
|
|
487
|
-
const headShort = (env.HEAD_SHA || "").slice(0, 7);
|
|
488
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;
|
|
489
976
|
if (env.SUPPRESSED === "true") {
|
|
490
977
|
let reason = "potential secret in diff";
|
|
491
978
|
try {
|
|
@@ -500,7 +987,11 @@ export function buildCommentBody(env = process.env) {
|
|
|
500
987
|
lines.push("");
|
|
501
988
|
lines.push("The recap was **suppressed** because the diff matched a secret/credential pattern. No plan was published.");
|
|
502
989
|
lines.push("");
|
|
503
|
-
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} -->`);
|
|
504
995
|
return lines.join("\n");
|
|
505
996
|
}
|
|
506
997
|
// Tiny diffs aren't worth a recap. Refresh an existing sticky comment to this
|
|
@@ -510,8 +1001,10 @@ export function buildCommentBody(env = process.env) {
|
|
|
510
1001
|
lines.push("### Visual recap — skipped (diff too small)");
|
|
511
1002
|
lines.push("");
|
|
512
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.");
|
|
513
|
-
|
|
514
|
-
|
|
1004
|
+
if (headShort)
|
|
1005
|
+
lines.push("", `_As of \`${headShort}\`_`);
|
|
1006
|
+
if (prevPlanId)
|
|
1007
|
+
lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
|
|
515
1008
|
return lines.join("\n");
|
|
516
1009
|
}
|
|
517
1010
|
const planUrl = (env.PLAN_URL || "").trim();
|
|
@@ -526,12 +1019,25 @@ export function buildCommentBody(env = process.env) {
|
|
|
526
1019
|
const sameOriginOk = appUrl === "" || sameOrigin(planUrl, appUrl);
|
|
527
1020
|
const base = (appUrl || originOf(planUrl)).replace(/\/$/, "");
|
|
528
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;
|
|
529
1028
|
if (!safeUrl) {
|
|
530
1029
|
lines.push("### Visual recap — generation failed");
|
|
531
1030
|
lines.push("");
|
|
532
1031
|
lines.push("The visual recap could not be generated for this push. This is informational only and does **not** block the PR.");
|
|
533
|
-
|
|
534
|
-
|
|
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} -->`);
|
|
535
1041
|
return lines.join("\n");
|
|
536
1042
|
}
|
|
537
1043
|
// The image URL is produced by our own recap-image route, but validate it is
|
|
@@ -540,10 +1046,10 @@ export function buildCommentBody(env = process.env) {
|
|
|
540
1046
|
const imageUrlRaw = (env.RECAP_IMAGE_URL || "").trim();
|
|
541
1047
|
const imageUrl = imageUrlRaw &&
|
|
542
1048
|
sameOrigin(imageUrlRaw, base) &&
|
|
543
|
-
|
|
1049
|
+
RECAP_IMAGE_URL_PATH_PATTERN.test(imageUrlRaw)
|
|
544
1050
|
? imageUrlRaw
|
|
545
1051
|
: "";
|
|
546
|
-
lines.push("### Visual recap
|
|
1052
|
+
lines.push("### Visual recap");
|
|
547
1053
|
lines.push("");
|
|
548
1054
|
if (imageUrl) {
|
|
549
1055
|
lines.push(`[](${safeUrl})`);
|
|
@@ -554,10 +1060,9 @@ export function buildCommentBody(env = process.env) {
|
|
|
554
1060
|
lines.push("");
|
|
555
1061
|
lines.push("> Large diff — this recap is a **summarized** view (top files + schema/API deltas).");
|
|
556
1062
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
lines.push("");
|
|
560
|
-
lines.push(`<!-- plan-id: ${planId} -->`);
|
|
1063
|
+
if (headShort)
|
|
1064
|
+
lines.push("", `_As of \`${headShort}\`_`);
|
|
1065
|
+
lines.push("", `<!-- plan-id: ${planId} -->`);
|
|
561
1066
|
return lines.join("\n");
|
|
562
1067
|
}
|
|
563
1068
|
/* -------------------------------------------------------------------------- */
|
|
@@ -574,7 +1079,15 @@ function runScan(args) {
|
|
|
574
1079
|
}
|
|
575
1080
|
}
|
|
576
1081
|
function runBuildPrompt(args) {
|
|
577
|
-
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);
|
|
578
1091
|
const prompt = buildRecapPrompt({
|
|
579
1092
|
skillMd: skill.text,
|
|
580
1093
|
pr: stringArg(args, "pr"),
|
|
@@ -592,6 +1105,49 @@ function runBuildPrompt(args) {
|
|
|
592
1105
|
fs.writeFileSync(path.resolve(out), prompt);
|
|
593
1106
|
process.stdout.write(`${JSON.stringify({ ok: true, out, skillSource: skill.source, bytes: prompt.length })}\n`);
|
|
594
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
|
+
}
|
|
595
1151
|
/** Upload a PNG to the plan app's signed public image route; returns its URL. */
|
|
596
1152
|
async function uploadRecapImage(input) {
|
|
597
1153
|
try {
|
|
@@ -618,6 +1174,13 @@ async function uploadRecapImage(input) {
|
|
|
618
1174
|
process.stderr.write(`[recap shot] image upload returned no imageUrl (status ${res.status})\n`);
|
|
619
1175
|
return null;
|
|
620
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
|
+
}
|
|
621
1184
|
return json.imageUrl;
|
|
622
1185
|
}
|
|
623
1186
|
catch (err) {
|
|
@@ -723,7 +1286,7 @@ async function runShot(args) {
|
|
|
723
1286
|
// Zoom out slightly so more content fits. Keep the plan title (h1) in frame:
|
|
724
1287
|
// the recap reads better led by its own title than cropped to the body.
|
|
725
1288
|
await page.evaluate(() => {
|
|
726
|
-
document.documentElement.style.zoom = "
|
|
1289
|
+
document.documentElement.style.zoom = "90%";
|
|
727
1290
|
});
|
|
728
1291
|
await page.screenshot({ path: out });
|
|
729
1292
|
captured = true;
|
|
@@ -781,15 +1344,29 @@ async function runComment(args, sub) {
|
|
|
781
1344
|
* recap job runs (the workflow itself, the skill, the local CLI, or any agent
|
|
782
1345
|
* config the runner loads) — so the whole job is skipped, not just the agent
|
|
783
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).
|
|
784
1353
|
*/
|
|
785
|
-
export function isRecapSensitivePath(p) {
|
|
786
|
-
|
|
1354
|
+
export function isRecapSensitivePath(p, repository) {
|
|
1355
|
+
if (p === ".github/workflows/pr-visual-recap.yml" ||
|
|
787
1356
|
/(^|\/)skills\/visual-(recap|plan|plans)\//.test(p) ||
|
|
788
1357
|
/(^|\/)\.claude\//.test(p) ||
|
|
789
1358
|
/(^|\/)CLAUDE\.md$/.test(p) ||
|
|
790
1359
|
/(^|\/)AGENTS\.md$/.test(p) ||
|
|
791
|
-
/(^|\/)\.mcp\.json$/.test(p)
|
|
792
|
-
|
|
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;
|
|
793
1370
|
}
|
|
794
1371
|
/**
|
|
795
1372
|
* The pure gate decision: given the PR payload, secret-presence flags, the
|
|
@@ -853,7 +1430,7 @@ export function evaluateRecapGate(input) {
|
|
|
853
1430
|
// config the runner would load (.claude/**, CLAUDE.md, .mcp.json), skip the
|
|
854
1431
|
// ENTIRE job — not just the agent — so a PR can never rewrite what runs
|
|
855
1432
|
// (skill, hooks, settings, CLI) and exfiltrate the publish/API secrets.
|
|
856
|
-
const hits = input.changedFiles.filter(isRecapSensitivePath);
|
|
1433
|
+
const hits = input.changedFiles.filter((p) => isRecapSensitivePath(p, input.repository));
|
|
857
1434
|
if (hits.length) {
|
|
858
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`);
|
|
859
1436
|
}
|
|
@@ -1338,10 +1915,12 @@ async function runUsage(args) {
|
|
|
1338
1915
|
const HELP = `agent-native recap — PR visual recap helpers (used by the GitHub Action)
|
|
1339
1916
|
|
|
1340
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>]
|
|
1341
1920
|
agent-native recap collect-diff --base <baseSha> --head <headSha> [--out recap.diff] [--stat recap.stat]
|
|
1342
1921
|
agent-native recap mcp-config --agent claude|codex --app-url <url> [--out <path>]
|
|
1343
1922
|
agent-native recap scan --diff <path>
|
|
1344
|
-
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>] [--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>]
|
|
1345
1924
|
agent-native recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png]
|
|
1346
1925
|
agent-native recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex] [--model <id>]
|
|
1347
1926
|
agent-native recap comment <find-plan-id|upsert> --repo owner/name --issue <n> --token <github-token>
|
|
@@ -1368,11 +1947,25 @@ Usage:
|
|
|
1368
1947
|
packages/core, .claude/**, CLAUDE.md, AGENTS.md, .mcp.json) — failing CLOSED
|
|
1369
1948
|
on any file-list error. Writes run=<true|false> and agent=<claude|codex> to
|
|
1370
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.
|
|
1371
1958
|
`;
|
|
1372
1959
|
export async function runRecap(argv) {
|
|
1373
1960
|
const [sub, ...rest] = argv;
|
|
1374
1961
|
const args = parseArgs(rest);
|
|
1375
1962
|
switch (sub) {
|
|
1963
|
+
case "setup":
|
|
1964
|
+
runSetup(args);
|
|
1965
|
+
return;
|
|
1966
|
+
case "doctor":
|
|
1967
|
+
runDoctor(args);
|
|
1968
|
+
return;
|
|
1376
1969
|
case "collect-diff":
|
|
1377
1970
|
runCollectDiff(args);
|
|
1378
1971
|
return;
|