@agent-native/recap-cli 0.4.5 → 0.4.7

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.
@@ -0,0 +1,1093 @@
1
+ name: PR Visual Recap
2
+
3
+ # Visual code review: a coding agent runs the repo's visual-recap skill over the
4
+ # PR diff, publishes a plan, and upserts one sticky comment with a screenshot.
5
+ # Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.
6
+
7
+ on:
8
+ pull_request:
9
+ types: [opened, synchronize, reopened, ready_for_review, closed]
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ concurrency:
15
+ group: pr-visual-recap-${{ github.event.pull_request.number }}
16
+ cancel-in-progress: true
17
+
18
+ env:
19
+ VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || 'claude' }}
20
+ VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }}
21
+ VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}
22
+ VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}
23
+
24
+ jobs:
25
+ gate:
26
+ name: Gate
27
+ # A custom plain-label runner is allowed only for trusted same-repo authors.
28
+ # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before
29
+ # any step starts. The only fromJSON input is this static association list.
30
+ runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || 'ubuntu-latest') || 'ubuntu-latest' }}
31
+ timeout-minutes: 10
32
+ permissions:
33
+ contents: read
34
+ issues: write
35
+ pull-requests: write
36
+ outputs:
37
+ run: ${{ steps.decide.outputs.run }}
38
+ agent: ${{ steps.decide.outputs.agent }}
39
+ runs_on: ${{ steps.decide.outputs.runs_on }}
40
+ steps:
41
+ - id: decide
42
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
43
+ env:
44
+ # Presence-only signals — never expose secret VALUES to the gate.
45
+ HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != '' }}
46
+ HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != '' }}
47
+ HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != '' }}
48
+ HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != '' }}
49
+ AGENT: ${{ env.VISUAL_RECAP_AGENT }}
50
+ VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}
51
+ VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}
52
+ VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || '"ubuntu-latest"' }}
53
+ VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}
54
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
55
+ with:
56
+ script: |
57
+ const pr = context.payload.pull_request;
58
+ const reasons = [];
59
+
60
+ if (!pr) reasons.push('no pull_request payload');
61
+ if (pr && pr.draft) reasons.push('draft PR');
62
+ if (pr && context.payload.action === 'closed' && !pr.merged) {
63
+ reasons.push('closed without merge');
64
+ }
65
+
66
+ // Fork PRs only receive repo secrets when the org/repo opts into
67
+ // GitHub's "Send secrets to workflows from pull requests" setting
68
+ // (common in private orgs that use forks heavily). Gate on secret
69
+ // availability, not fork-ness: run on forks that have the token,
70
+ // and skip — with an actionable hint — those that don't.
71
+ const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;
72
+ const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);
73
+ const isPrivate = !!(context.payload.repository && context.payload.repository.private);
74
+ const association = (pr && pr.author_association || '').toUpperCase();
75
+ const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
76
+ const isTrustedAuthor = trustedAssociations.includes(association);
77
+ let configuredRunner = 'ubuntu-latest';
78
+ let usesSelfHostedRunner = false;
79
+ try {
80
+ const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || '"ubuntu-latest"');
81
+ const hosted = typeof candidate === 'string' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);
82
+ const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes('self-hosted') && candidate.every((label) => typeof label === 'string' && label.length >= 1 && label.length <= 100 && !/[\u0000-\u001f\u007f]/.test(label)) && new Set(candidate).size === candidate.length;
83
+ if (!hosted && !selfHosted) throw new Error('unsupported runner value');
84
+ configuredRunner = candidate;
85
+ usesSelfHostedRunner = selfHosted;
86
+ } catch {
87
+ reasons.push('invalid VISUAL_RECAP_RUNS_ON JSON');
88
+ }
89
+ if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {
90
+ reasons.push('self-hosted runner mode requires a trusted same-repository PR author');
91
+ }
92
+ if (isFork && process.env.HAS_PLAN !== 'true') {
93
+ reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);
94
+ }
95
+
96
+ const login = (pr && pr.user && pr.user.login || '').toLowerCase();
97
+ const botAuthors = ['dependabot[bot]', 'dependabot', 'renovate[bot]', 'renovate'];
98
+ if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);
99
+ if (pr && pr.user && pr.user.type === 'Bot') reasons.push('bot author (type=Bot)');
100
+
101
+ if (!isFork && process.env.HAS_PLAN !== 'true') reasons.push('PLAN_RECAP_TOKEN not configured');
102
+
103
+ // Normalize + validate the agent so a mis-cased value can't pass the
104
+ // gate and then match neither agent step below.
105
+ const rawAgent = (process.env.AGENT || 'claude').toLowerCase();
106
+ const agent = ['deepseek', 'kimi', 'moonshot', 'custom'].includes(rawAgent) ? 'openai-compatible' : rawAgent;
107
+ if (!['claude', 'codex', 'openai-compatible'].includes(agent)) {
108
+ reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`);
109
+ } else if (agent === 'codex') {
110
+ if (process.env.HAS_OPENAI !== 'true') reasons.push('OPENAI_API_KEY not configured (codex backend)');
111
+ } else if (agent === 'claude') {
112
+ if (process.env.HAS_ANTHROPIC !== 'true') reasons.push('ANTHROPIC_API_KEY not configured (claude backend)');
113
+ } else {
114
+ if (process.env.HAS_COMPATIBLE !== 'true') reasons.push('VISUAL_RECAP_API_KEY not configured (openai-compatible backend)');
115
+ if (!(process.env.VISUAL_RECAP_MODEL || '').trim()) reasons.push('VISUAL_RECAP_MODEL is required (openai-compatible backend)');
116
+ const baseUrl = process.env.VISUAL_RECAP_BASE_URL || '';
117
+ try {
118
+ const parsed = new URL(baseUrl);
119
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
120
+ reasons.push('VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials');
121
+ }
122
+ } catch {
123
+ reasons.push('VISUAL_RECAP_BASE_URL must be a valid http(s) URL');
124
+ }
125
+ }
126
+
127
+ // Validate the model before it reaches the agent CLI.
128
+ const model = process.env.VISUAL_RECAP_MODEL || '';
129
+ if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {
130
+ reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);
131
+ }
132
+
133
+ const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || 'auto').toLowerCase();
134
+ if (!['auto', 'latest', 'repo'].includes(skillSource)) {
135
+ reasons.push('invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")');
136
+ }
137
+ const usesRepoSkill = skillSource === 'repo';
138
+
139
+ // Self-modifying guard, evaluated in the trusted gate (runs NO
140
+ // PR-checked-out code): skip the ENTIRE job if the PR touches the
141
+ // repo-pinned skill instructions or any agent config the runner
142
+ // loads, so a PR can't rewrite what the agent loads and exfiltrate
143
+ // secrets. With the default bundled skill source, visual skill and
144
+ // recap workflow files are reviewed content, not instructions loaded
145
+ // by the runner.
146
+ // Keep this guard for untrusted forks and untrusted public-repo PRs.
147
+ // Trusted write actors may edit recap-control files as normal
148
+ // reviewable content; running the recap is useful signal for those
149
+ // changes.
150
+ if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {
151
+ try {
152
+ const files = await github.paginate(github.rest.pulls.listFiles, {
153
+ owner: context.repo.owner,
154
+ repo: context.repo.repo,
155
+ pull_number: pr.number,
156
+ per_page: 100,
157
+ });
158
+ const isSensitive = (p) =>
159
+ (usesRepoSkill && /(^|\/)skills\/visual-(recap|plan|plans)\//.test(p)) ||
160
+ p.startsWith('.claude/') ||
161
+ p === 'CLAUDE.md' ||
162
+ p === 'AGENTS.md' ||
163
+ p === '.mcp.json';
164
+ const hits = files.map((f) => f.filename).filter(isSensitive);
165
+ if (hits.length) {
166
+ reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', …' : ''}) — skipping so untrusted PR code never runs with secrets`);
167
+ }
168
+ } catch (e) {
169
+ // Fail closed: if the file list can't be read, skip.
170
+ reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);
171
+ }
172
+ }
173
+
174
+ const run = reasons.length === 0;
175
+ core.setOutput('run', run ? 'true' : 'false');
176
+ core.setOutput('agent', agent);
177
+ core.setOutput('runs_on', JSON.stringify(configuredRunner));
178
+ if (run) {
179
+ core.info(`Visual recap will run (${agent}).`);
180
+ } else {
181
+ // Surface the skip reason as a run-summary annotation, not just a
182
+ // buried info log, so it's clear in the Actions UI why we skipped.
183
+ core.notice(`Visual recap skipped: ${reasons.join('; ')}`);
184
+ }
185
+
186
+ // When skipping, upsert a sticky recap comment with a short skip
187
+ // line so the PR always explains why the recap job did not run.
188
+ if (!run && pr) {
189
+ try {
190
+ const MARKER = '<!-- pr-visual-recap -->';
191
+ const { data: comments } = await github.rest.issues.listComments({
192
+ owner: context.repo.owner,
193
+ repo: context.repo.repo,
194
+ issue_number: pr.number,
195
+ per_page: 100,
196
+ });
197
+ const existing = comments.find(
198
+ (c) => c.user && c.user.type === 'Bot' && c.body && c.body.includes(MARKER)
199
+ );
200
+ const headShort = (process.env.HEAD_SHA || '').slice(0, 7);
201
+ const shaRef = headShort ? `\`${headShort}\`` : 'latest push';
202
+ const primaryReason = reasons.filter(
203
+ (r) => !r.startsWith('could not list PR files for the self-modifying guard')
204
+ )[0] || reasons[0] || 'skipped';
205
+ const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;
206
+ const baseBody = `${MARKER}\n### Visual recap — skipped\n\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;
207
+ const planIdMatch = (existing && existing.body ? existing.body : '').match(/<!--\s*plan-id:\s*([A-Za-z0-9_-]{1,64})\s*-->/);
208
+ const planIdMarker = planIdMatch ? `\n\n<!-- plan-id: ${planIdMatch[1]} -->` : '';
209
+ const updatedBody = `${baseBody}${planIdMarker}\n\n${skipLine}`;
210
+ if (existing) {
211
+ await github.rest.issues.updateComment({
212
+ owner: context.repo.owner,
213
+ repo: context.repo.repo,
214
+ comment_id: existing.id,
215
+ body: updatedBody,
216
+ });
217
+ } else {
218
+ await github.rest.issues.createComment({
219
+ owner: context.repo.owner,
220
+ repo: context.repo.repo,
221
+ issue_number: pr.number,
222
+ body: updatedBody,
223
+ });
224
+ }
225
+ } catch (e) {
226
+ core.warning(`Could not update recap skip comment: ${e.message}`);
227
+ }
228
+ }
229
+
230
+ recap:
231
+ name: Generate visual recap
232
+ needs: gate
233
+ if: needs.gate.outputs.run == 'true'
234
+ runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}
235
+ timeout-minutes: 30
236
+ defaults:
237
+ run:
238
+ shell: bash
239
+ permissions:
240
+ actions: write
241
+ checks: write
242
+ contents: read
243
+ issues: write
244
+ pull-requests: write
245
+ env:
246
+ PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || 'https://plan.agent-native.com' }}
247
+ PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}
248
+ GH_TOKEN: ${{ github.token }}
249
+ PR_NUMBER: ${{ github.event.pull_request.number }}
250
+ PR_STATE: ${{ github.event.pull_request.state }}
251
+ PR_MERGED: ${{ github.event.pull_request.merged }}
252
+ PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}
253
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
254
+ VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}
255
+ VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }}
256
+ VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}
257
+ VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}
258
+ VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}
259
+ steps:
260
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
261
+ with:
262
+ fetch-depth: 0
263
+ # This job runs an agent over untrusted PR diff; don't leave the token
264
+ # in .git/config (it uses GH_TOKEN for gh API calls, never git push).
265
+ persist-credentials: false
266
+
267
+ # Dogfood trusted base-branch source inside this monorepo, else install the
268
+ # published package once. Never execute PR-head recap CLI code.
269
+ - name: Resolve recap CLI
270
+ id: cli
271
+ env:
272
+ # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to
273
+ # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.
274
+ RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}
275
+ run: |
276
+ if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then
277
+ echo "local=true" >> "$GITHUB_OUTPUT"
278
+ else
279
+ echo "local=false" >> "$GITHUB_OUTPUT"
280
+ fi
281
+
282
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
283
+ if: steps.cli.outputs.local == 'true'
284
+ with:
285
+ ref: ${{ github.event.pull_request.base.sha }}
286
+ path: .recap-cli-source
287
+ fetch-depth: 1
288
+ persist-credentials: false
289
+
290
+ - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
291
+ if: steps.cli.outputs.local == 'true'
292
+
293
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
294
+ with:
295
+ node-version: "22"
296
+ cache: ${{ steps.cli.outputs.local == 'true' && 'pnpm' || '' }}
297
+
298
+ - name: Install trusted workspace recap CLI
299
+ if: steps.cli.outputs.local == 'true'
300
+ working-directory: .recap-cli-source
301
+ run: |
302
+ set -euo pipefail
303
+ pnpm install --frozen-lockfile --ignore-scripts
304
+ pnpm --filter @agent-native/recap-cli build
305
+ echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"
306
+ echo "CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"
307
+ echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"
308
+
309
+ - name: Install published recap CLI
310
+ if: steps.cli.outputs.local != 'true'
311
+ env:
312
+ RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}
313
+ run: |
314
+ set -euo pipefail
315
+ VERSION="$RECAP_CLI_VERSION"
316
+ if [ "$VERSION" = "latest" ]; then
317
+ VERSION="$(npm view @agent-native/recap-cli@latest version)"
318
+ fi
319
+ for attempt in 1 2 3; do
320
+ if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then
321
+ break
322
+ fi
323
+ if [ "$attempt" = "3" ]; then exit 1; fi
324
+ sleep $((attempt * 10))
325
+ done
326
+ echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
327
+ echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"
328
+ echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"
329
+
330
+ - name: Install OpenAI-compatible provider runtime
331
+ if: needs.gate.outputs.agent == 'openai-compatible' && steps.cli.outputs.local != 'true'
332
+ env:
333
+ CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || 'latest' }}
334
+ run: |
335
+ set -euo pipefail
336
+ CORE_VERSION="$CORE_CLI_VERSION"
337
+ if [ "$CORE_VERSION" = "latest" ]; then
338
+ CORE_VERSION="$(npm view @agent-native/core@latest version)"
339
+ fi
340
+ npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION"
341
+ echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV"
342
+ echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV"
343
+
344
+ - name: Start visual recap check
345
+ id: recap_check
346
+ continue-on-error: true
347
+ run: |
348
+ set -uo pipefail
349
+ $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
350
+
351
+ - name: Fetch pull request head
352
+ env:
353
+ PR_NUMBER_ENV: ${{ github.event.pull_request.number }}
354
+ run: |
355
+ set -euo pipefail
356
+ if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
357
+ git update-ref refs/recap/pr-head "$HEAD_SHA"
358
+ else
359
+ AUTH_B64="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
360
+ git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"
361
+ fi
362
+ FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"
363
+ if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then
364
+ echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"
365
+ exit 1
366
+ fi
367
+
368
+ - name: Collect bounded diff
369
+ id: diff
370
+ env:
371
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
372
+ run: |
373
+ set -euo pipefail
374
+ $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat
375
+
376
+ - name: Probe plan-app auth
377
+ id: auth_probe
378
+ if: steps.diff.outputs.tiny != 'true'
379
+ continue-on-error: true
380
+ run: |
381
+ set -uo pipefail
382
+ # Hit the plan app's action surface with the publish token. A 401 means
383
+ # the token is expired/revoked; surface it in the sticky comment so the
384
+ # repo owner knows to re-mint it instead of seeing a generic failure.
385
+ HTTP_STATUS=$(node -e '
386
+ const https = require("https");
387
+ const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");
388
+ const req = https.request(url, { method: "POST", headers: { "authorization": "Bearer " + process.env.PLAN_RECAP_TOKEN, "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });
389
+ req.on("error", () => process.stdout.write("0"));
390
+ req.end(JSON.stringify({ planId: "__probe__" }));
391
+ ' 2>/dev/null || echo "0")
392
+ if [ "$HTTP_STATUS" = "401" ]; then
393
+ echo "auth_failed=true" >> "$GITHUB_OUTPUT"
394
+ else
395
+ echo "auth_failed=false" >> "$GITHUB_OUTPUT"
396
+ fi
397
+
398
+ - name: Probe plan-app route health
399
+ id: route_health
400
+ if: steps.diff.outputs.tiny != 'true'
401
+ continue-on-error: true
402
+ run: |
403
+ set -uo pipefail
404
+ # Pre-publish health gate: confirm the plan app's recap action routes
405
+ # are actually deployed BEFORE the agent runs. A 404 from
406
+ # create-visual-recap (POST) or get-plan-blocks (GET) means the
407
+ # plan-app deploy has not propagated yet (the client is ahead of the
408
+ # deployed server). Say that plainly here instead of letting the agent
409
+ # run and then fail confusingly at publish time. A 401 or 200 is
410
+ # healthy — the route exists, it just rejected/accepted the probe.
411
+ probe_status() {
412
+ ROUTE="$1" METHOD="$2" node -e '
413
+ const https = require("https");
414
+ const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";
415
+ const url = new URL(process.env.ROUTE, base);
416
+ if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");
417
+ const req = https.request(url, { method: process.env.METHOD, headers: { "authorization": "Bearer " + (process.env.PLAN_RECAP_TOKEN || ""), "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });
418
+ req.on("error", () => process.stdout.write("0"));
419
+ req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });
420
+ if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }
421
+ ' 2>/dev/null || echo "0"
422
+ }
423
+ CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"
424
+ BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"
425
+ REASON=""
426
+ if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then
427
+ REASON="Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating."
428
+ echo "::error::$REASON"
429
+ echo "unhealthy=true" >> "$GITHUB_OUTPUT"
430
+ else
431
+ echo "unhealthy=false" >> "$GITHUB_OUTPUT"
432
+ fi
433
+ {
434
+ echo 'reason<<__RECAP_ROUTE_HEALTH_EOF__'
435
+ echo "$REASON"
436
+ echo '__RECAP_ROUTE_HEALTH_EOF__'
437
+ } >> "$GITHUB_OUTPUT"
438
+
439
+ - name: Secret scan
440
+ id: scan
441
+ if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true'
442
+ run: |
443
+ set -uo pipefail
444
+ # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a
445
+ # credential-bearing diff is never handed to the agent / plan service.
446
+ if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then
447
+ SCAN_JSON='{"suppressed":true,"reason":"secret scan failed to run; failing closed"}'
448
+ fi
449
+ {
450
+ echo 'json<<__RECAP_SCAN_EOF__'
451
+ echo "$SCAN_JSON"
452
+ echo '__RECAP_SCAN_EOF__'
453
+ } >> "$GITHUB_OUTPUT"
454
+ SUPPRESSED=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}' "$SCAN_JSON")
455
+ echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"
456
+
457
+ - name: Read previous plan id
458
+ id: prev
459
+ if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true'
460
+ continue-on-error: true
461
+ run: |
462
+ set -euo pipefail
463
+ PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"
464
+ echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"
465
+
466
+ - name: Fetch plan block reference
467
+ id: block_reference
468
+ if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
469
+ continue-on-error: true
470
+ run: |
471
+ set -uo pipefail
472
+ if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then
473
+ echo "ok=true" >> "$GITHUB_OUTPUT"
474
+ else
475
+ echo "ok=false" >> "$GITHUB_OUTPUT"
476
+ {
477
+ echo 'summary<<__RECAP_BLOCK_REFERENCE_EOF__'
478
+ echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the hosted Plan action will validate the final MDX."
479
+ echo '__RECAP_BLOCK_REFERENCE_EOF__'
480
+ } >> "$GITHUB_OUTPUT"
481
+ cat > recap-blocks.md <<'EOF'
482
+ Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.
483
+ EOF
484
+ fi
485
+
486
+ - name: Build recap prompt
487
+ id: prompt
488
+ if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
489
+ env:
490
+ # Pass step outputs via env, NOT ${{ }} interpolation into the run body:
491
+ # the prev plan id is parsed from a PR comment and could inject shell.
492
+ PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
493
+ DIFF_HUGE: ${{ steps.diff.outputs.huge }}
494
+ IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
495
+ run: |
496
+ set -euo pipefail
497
+ ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --head "$HEAD_SHA" --app-url "$PLAN_RECAP_APP_URL" --skill-source "$VISUAL_RECAP_SKILL_SOURCE" --out recap-prompt.md)
498
+ if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi
499
+ if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi
500
+ if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi
501
+ $RECAP_CLI recap build-prompt "${ARGS[@]}"
502
+
503
+ - name: Run agent (Claude Code)
504
+ id: claude
505
+ if: needs.gate.outputs.agent == 'claude' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
506
+ continue-on-error: true
507
+ env:
508
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
509
+ run: |
510
+ set -uo pipefail
511
+ CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"
512
+ CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)
513
+ CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")
514
+ rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log
515
+ run_claude() {
516
+ set +e
517
+ npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log
518
+ CLAUDE_STATUS="$?"
519
+ set -e
520
+ echo "$CLAUDE_STATUS" > claude-exit-code.txt
521
+ }
522
+ run_claude
523
+ # A clean agent exit WITHOUT recap-source.json is the strongest
524
+ # "retry me" signal — the deterministic publisher needs that file, and
525
+ # the agent occasionally finishes a turn without writing it. Retry once.
526
+ if [ ! -s recap-source.json ]; then
527
+ if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' claude-result.json claude-stderr.log 2>/dev/null; then
528
+ echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."
529
+ else
530
+ echo "::warning::recap-source.json missing after the agent run; retrying the agent once."
531
+ sleep 5
532
+ run_claude
533
+ fi
534
+ fi
535
+
536
+ - name: Run agent (Codex)
537
+ id: codex
538
+ if: needs.gate.outputs.agent == 'codex' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
539
+ continue-on-error: true
540
+ env:
541
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
542
+ run: |
543
+ set -uo pipefail
544
+ # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on
545
+ # the gpt-5.5 wss transport); stdin keeps the key out of process args.
546
+ printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true
547
+ # The runner is itself an ephemeral sandbox; bypass Codex's own sandbox
548
+ # (bubblewrap can't init here) and approval gate (cancels the MCP write).
549
+ CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)
550
+ if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi
551
+ # Validate reasoning against the enum before embedding it in the TOML override.
552
+ case "${VISUAL_RECAP_REASONING:-}" in
553
+ none|minimal|low|medium|high|xhigh)
554
+ CODEX_ARGS+=(-c "model_reasoning_effort=\"$VISUAL_RECAP_REASONING\"") ;;
555
+ "") ;;
556
+ *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;
557
+ esac
558
+ rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log
559
+ run_codex() {
560
+ set +e
561
+ npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl
562
+ CODEX_STATUS="${PIPESTATUS[0]}"
563
+ set -e
564
+ echo "$CODEX_STATUS" > codex-exit-code.txt
565
+ }
566
+ run_codex
567
+ # Retry once if the agent exited without writing recap-source.json
568
+ # (see the Claude step) — the publisher needs that file.
569
+ if [ ! -s recap-source.json ]; then
570
+ if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' codex-events.jsonl codex-stderr.log 2>/dev/null; then
571
+ echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."
572
+ else
573
+ echo "::warning::recap-source.json missing after the agent run; retrying the agent once."
574
+ sleep 5
575
+ run_codex
576
+ fi
577
+ fi
578
+
579
+ - name: Run agent (OpenAI-compatible)
580
+ id: openai_compatible
581
+ if: needs.gate.outputs.agent == 'openai-compatible' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
582
+ continue-on-error: true
583
+ env:
584
+ OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}
585
+ OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}
586
+ AGENT_ENGINE: ai-sdk:openai
587
+ AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}
588
+ AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json
589
+ AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source
590
+ run: |
591
+ set -uo pipefail
592
+ rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log
593
+ run_openai_compatible() {
594
+ set +e
595
+ $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log
596
+ OPENAI_COMPATIBLE_STATUS="$?"
597
+ set -e
598
+ echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt
599
+ }
600
+ run_openai_compatible
601
+ if [ ! -s recap-source.json ]; then
602
+ if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then
603
+ echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."
604
+ else
605
+ echo "::warning::recap-source.json missing after the agent run; retrying the agent once."
606
+ sleep 5
607
+ run_openai_compatible
608
+ fi
609
+ fi
610
+
611
+ - name: Check recap source
612
+ id: source_status
613
+ if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
614
+ env:
615
+ RECAP_AGENT: ${{ needs.gate.outputs.agent }}
616
+ run: |
617
+ set -uo pipefail
618
+ if [ -s recap-source.json ]; then
619
+ echo "ready=true" >> "$GITHUB_OUTPUT"
620
+ exit 0
621
+ fi
622
+ case "$RECAP_AGENT" in
623
+ codex) AGENT_LABEL="Codex" ;;
624
+ claude) AGENT_LABEL="Claude" ;;
625
+ openai-compatible) AGENT_LABEL="OpenAI-compatible agent" ;;
626
+ *) AGENT_LABEL="Recap agent" ;;
627
+ esac
628
+ if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit' codex-events.jsonl codex-stderr.log claude-result.json claude-stderr.log openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then
629
+ case "$RECAP_AGENT" in
630
+ codex) REASON="Codex could not author recap-source.json because the OpenAI API project's quota/budget is exhausted. Add API credits or raise that project's monthly budget, then rerun the workflow." ;;
631
+ claude) REASON="Claude could not author recap-source.json because the Anthropic provider quota is exhausted. Restore its quota or billing, then rerun the workflow." ;;
632
+ *) REASON="$AGENT_LABEL could not author recap-source.json because its provider quota was exceeded." ;;
633
+ esac
634
+ elif grep -Eiq -- 'invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' codex-events.jsonl codex-stderr.log claude-result.json claude-stderr.log openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then
635
+ REASON="$AGENT_LABEL could not author recap-source.json because provider authentication failed."
636
+ else
637
+ REASON="$AGENT_LABEL did not produce recap-source.json before source authoring completed."
638
+ fi
639
+ printf '%s\n' "$REASON" > recap-url-reason.txt
640
+ echo "ready=false" >> "$GITHUB_OUTPUT"
641
+ {
642
+ echo 'reason<<__RECAP_SOURCE_REASON_EOF__'
643
+ echo "$REASON"
644
+ echo '__RECAP_SOURCE_REASON_EOF__'
645
+ } >> "$GITHUB_OUTPUT"
646
+ echo "::warning::$REASON Skipping deterministic publish."
647
+
648
+ - name: Publish recap source
649
+ id: publish
650
+ if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' && steps.source_status.outputs.ready == 'true'
651
+ continue-on-error: true
652
+ env:
653
+ PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
654
+ run: |
655
+ set -uo pipefail
656
+ ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")
657
+ if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi
658
+ ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")
659
+ if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then
660
+ ARGS+=(--source-pr-state merged)
661
+ elif [ -n "${PR_STATE:-}" ]; then
662
+ ARGS+=(--source-pr-state "$PR_STATE")
663
+ fi
664
+ if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi
665
+ $RECAP_CLI recap publish "${ARGS[@]}"
666
+
667
+ - name: Build one-shot recap repair prompt
668
+ id: repair_prompt
669
+ if: steps.publish.outputs.repairable == 'true'
670
+ run: |
671
+ set -euo pipefail
672
+ cp recap-source.json recap-source.initial.json
673
+ $RECAP_CLI recap repair-prompt --source recap-source.json --reason-file recap-url-reason.txt --out recap-repair-prompt.md
674
+
675
+ - name: Repair recap source (Claude Code)
676
+ id: claude_repair
677
+ if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'claude'
678
+ continue-on-error: true
679
+ env:
680
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
681
+ run: |
682
+ set -uo pipefail
683
+ CLAUDE_ALLOWED_TOOLS="Read,Write"
684
+ CLAUDE_ARGS=(-p "$(cat recap-repair-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)
685
+ CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")
686
+ rm -f claude-repair-result.json claude-repair-stderr.log
687
+ set +e
688
+ npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-repair-result.json 2> claude-repair-stderr.log
689
+ CLAUDE_REPAIR_STATUS="$?"
690
+ set -e
691
+ echo "$CLAUDE_REPAIR_STATUS" > claude-repair-exit-code.txt
692
+ if [ "$CLAUDE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi
693
+
694
+ - name: Repair recap source (Codex)
695
+ id: codex_repair
696
+ if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'codex'
697
+ continue-on-error: true
698
+ env:
699
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
700
+ run: |
701
+ set -uo pipefail
702
+ printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true
703
+ CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)
704
+ if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi
705
+ case "${VISUAL_RECAP_REASONING:-}" in
706
+ none|minimal|low|medium|high|xhigh)
707
+ CODEX_ARGS+=(-c "model_reasoning_effort=\"$VISUAL_RECAP_REASONING\"") ;;
708
+ "") ;;
709
+ *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;
710
+ esac
711
+ rm -f codex-repair-events.jsonl codex-repair-stderr.log
712
+ set +e
713
+ npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-repair-prompt.md)" 2> codex-repair-stderr.log | tee codex-repair-events.jsonl
714
+ CODEX_REPAIR_STATUS="${PIPESTATUS[0]}"
715
+ set -e
716
+ echo "$CODEX_REPAIR_STATUS" > codex-repair-exit-code.txt
717
+ if [ "$CODEX_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi
718
+
719
+ - name: Repair recap source (OpenAI-compatible)
720
+ id: openai_compatible_repair
721
+ if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'openai-compatible'
722
+ continue-on-error: true
723
+ env:
724
+ OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}
725
+ OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}
726
+ AGENT_ENGINE: ai-sdk:openai
727
+ AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}
728
+ AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-repair-usage.json
729
+ AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source
730
+ run: |
731
+ set -uo pipefail
732
+ rm -f openai-compatible-repair-result.txt openai-compatible-repair-usage.json openai-compatible-repair-stderr.log
733
+ set +e
734
+ $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-repair-prompt.md)" > openai-compatible-repair-result.txt 2> openai-compatible-repair-stderr.log
735
+ OPENAI_COMPATIBLE_REPAIR_STATUS="$?"
736
+ set -e
737
+ echo "$OPENAI_COMPATIBLE_REPAIR_STATUS" > openai-compatible-repair-exit-code.txt
738
+ if [ "$OPENAI_COMPATIBLE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi
739
+
740
+ - name: Validate repaired recap source
741
+ id: repaired_source
742
+ if: steps.publish.outputs.repairable == 'true'
743
+ env:
744
+ REPAIR_AGENT_OK: ${{ steps.claude_repair.outputs.ok || steps.codex_repair.outputs.ok || steps.openai_compatible_repair.outputs.ok }}
745
+ run: |
746
+ set -uo pipefail
747
+ REPAIR_REASON=""
748
+ if [ "${REPAIR_AGENT_OK:-false}" != "true" ]; then
749
+ echo "ok=false" >> "$GITHUB_OUTPUT"
750
+ REPAIR_REASON="Repair agent exited unsuccessfully; repaired source was not published."
751
+ else
752
+ VALIDATION_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap validate-repair --original recap-source.initial.json --source recap-source.json --reason-file recap-url-reason.txt || true)"
753
+ REPAIR_OK="$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}' "$VALIDATION_JSON")"
754
+ REPAIR_REASON="$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("Repair validation returned invalid output.")}' "$VALIDATION_JSON")"
755
+ echo "ok=$REPAIR_OK" >> "$GITHUB_OUTPUT"
756
+ fi
757
+ if [ -n "$REPAIR_REASON" ]; then
758
+ echo "$REPAIR_REASON" > recap-url-reason.txt
759
+ fi
760
+ {
761
+ echo 'reason<<__RECAP_REPAIR_REASON_EOF__'
762
+ echo "$REPAIR_REASON"
763
+ echo '__RECAP_REPAIR_REASON_EOF__'
764
+ } >> "$GITHUB_OUTPUT"
765
+
766
+ - name: Publish repaired recap source
767
+ id: publish_repair
768
+ if: steps.repaired_source.outputs.ok == 'true'
769
+ continue-on-error: true
770
+ env:
771
+ PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
772
+ run: |
773
+ set -uo pipefail
774
+ ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")
775
+ if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi
776
+ ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")
777
+ if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then
778
+ ARGS+=(--source-pr-state merged)
779
+ elif [ -n "${PR_STATE:-}" ]; then
780
+ ARGS+=(--source-pr-state "$PR_STATE")
781
+ fi
782
+ if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi
783
+ $RECAP_CLI recap publish "${ARGS[@]}"
784
+
785
+ - name: Read plan URL
786
+ id: url
787
+ if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
788
+ run: |
789
+ set -uo pipefail
790
+ PLAN_URL=""
791
+ URL_REASON=""
792
+ if [ -f recap-url.txt ]; then
793
+ PLAN_URL="$(tr -d '\r\n' < recap-url.txt | tr -d ' ')"
794
+ elif [ -f recap-url-reason.txt ]; then
795
+ URL_REASON="$(cat recap-url-reason.txt)"
796
+ else
797
+ URL_REASON="recap-url.txt was not created."
798
+ fi
799
+ # recap-url.txt is agent-written -> untrusted. Rebuild a canonical
800
+ # recap URL from the trusted app base and a strictly validated plan id,
801
+ # preserving path-prefixed self-hosted mounts.
802
+ if [ -z "$URL_REASON" ]; then
803
+ URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<'NODE'
804
+ const emit = (value) => process.stdout.write(JSON.stringify(value));
805
+ try {
806
+ const raw = process.env.PLAN_URL || "";
807
+ if (!raw) {
808
+ emit({ url: "", reason: "recap-url.txt was empty" });
809
+ process.exit(0);
810
+ }
811
+ const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");
812
+ const parsed = /^https?:\/\//i.test(raw)
813
+ ? new URL(raw)
814
+ : new URL(raw, trusted);
815
+ if (parsed.origin !== trusted.origin) {
816
+ emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });
817
+ process.exit(0);
818
+ }
819
+
820
+ const base = trusted.pathname.replace(/\/$/, "");
821
+ const paths = [parsed.pathname];
822
+ if (base && parsed.pathname.startsWith(`${base}/`)) {
823
+ paths.push(parsed.pathname.slice(base.length) || "/");
824
+ }
825
+
826
+ for (const path of paths) {
827
+ const match = path.match(/^\/(?:plans|recaps)\/([A-Za-z0-9_-]+)\/?$/);
828
+ if (match) {
829
+ emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });
830
+ process.exit(0);
831
+ }
832
+ }
833
+ emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app" });
834
+ } catch {
835
+ emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });
836
+ }
837
+ NODE
838
+ )
839
+ CANONICAL_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}' "$URL_RESULT")
840
+ URL_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("recap-url.txt URL validation failed")}' "$URL_RESULT")
841
+ else
842
+ CANONICAL_URL=""
843
+ fi
844
+ if [ -n "$CANONICAL_URL" ]; then
845
+ echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"
846
+ else
847
+ echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"
848
+ fi
849
+ {
850
+ echo 'reason<<__RECAP_URL_REASON_EOF__'
851
+ echo "$URL_REASON"
852
+ echo '__RECAP_URL_REASON_EOF__'
853
+ } >> "$GITHUB_OUTPUT"
854
+
855
+ - name: Summarize agent failure
856
+ id: agent_summary
857
+ if: steps.url.outputs.ok != 'true' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'
858
+ continue-on-error: true
859
+ env:
860
+ RECAP_AGENT: ${{ needs.gate.outputs.agent }}
861
+ RECAP_REPAIR_ATTEMPTED: ${{ steps.repair_prompt.outcome == 'success' }}
862
+ RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}
863
+ RECAP_PUBLISH_REASON: ${{ steps.repaired_source.outputs.reason || steps.publish_repair.outputs.reason || steps.publish.outputs.reason }}
864
+ run: |
865
+ set -uo pipefail
866
+ RESULT=claude-result.json
867
+ STDERR=claude-stderr.log
868
+ EXIT_CODE=claude-exit-code.txt
869
+ if [ "$RECAP_AGENT" = "codex" ]; then
870
+ RESULT=codex-events.jsonl
871
+ STDERR=codex-stderr.log
872
+ EXIT_CODE=codex-exit-code.txt
873
+ elif [ "$RECAP_AGENT" = "openai-compatible" ]; then
874
+ RESULT=openai-compatible-result.txt
875
+ STDERR=openai-compatible-stderr.log
876
+ EXIT_CODE=openai-compatible-exit-code.txt
877
+ fi
878
+ if [ "$RECAP_REPAIR_ATTEMPTED" = "true" ]; then
879
+ RESULT=claude-repair-result.json
880
+ STDERR=claude-repair-stderr.log
881
+ EXIT_CODE=claude-repair-exit-code.txt
882
+ if [ "$RECAP_AGENT" = "codex" ]; then
883
+ RESULT=codex-repair-events.jsonl
884
+ STDERR=codex-repair-stderr.log
885
+ EXIT_CODE=codex-repair-exit-code.txt
886
+ elif [ "$RECAP_AGENT" = "openai-compatible" ]; then
887
+ RESULT=openai-compatible-repair-result.txt
888
+ STDERR=openai-compatible-repair-stderr.log
889
+ EXIT_CODE=openai-compatible-repair-exit-code.txt
890
+ fi
891
+ fi
892
+ SUMMARY_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || echo '{}')"
893
+ SUMMARY="$(node -e 'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}' "$SUMMARY_JSON")"
894
+ if [ -n "$SUMMARY" ]; then
895
+ {
896
+ echo 'summary<<__RECAP_AGENT_SUMMARY_EOF__'
897
+ echo "$SUMMARY"
898
+ echo '__RECAP_AGENT_SUMMARY_EOF__'
899
+ } >> "$GITHUB_OUTPUT"
900
+ elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then
901
+ {
902
+ echo 'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__'
903
+ echo "$RECAP_BLOCK_REFERENCE_SUMMARY"
904
+ echo '__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__'
905
+ } >> "$GITHUB_OUTPUT"
906
+ elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then
907
+ {
908
+ echo 'summary<<__RECAP_PUBLISH_SUMMARY_EOF__'
909
+ echo "$RECAP_PUBLISH_REASON"
910
+ echo '__RECAP_PUBLISH_SUMMARY_EOF__'
911
+ } >> "$GITHUB_OUTPUT"
912
+ fi
913
+
914
+ - name: Attach usage
915
+ if: steps.url.outputs.ok == 'true'
916
+ continue-on-error: true
917
+ env:
918
+ PLAN_URL: ${{ steps.url.outputs.plan_url }}
919
+ # Use the gate-normalized agent so "Codex" still selects the right file.
920
+ RECAP_AGENT: ${{ needs.gate.outputs.agent }}
921
+ RECAP_REPAIR_SUCCEEDED: ${{ steps.publish_repair.outcome == 'success' }}
922
+ run: |
923
+ set -uo pipefail
924
+ RESULT=claude-result.json
925
+ if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi
926
+ if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi
927
+ if [ "$RECAP_REPAIR_SUCCEEDED" = "true" ]; then
928
+ RESULT=claude-repair-result.json
929
+ if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-repair-events.jsonl; fi
930
+ if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-repair-usage.json; fi
931
+ fi
932
+ if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi
933
+
934
+ - name: Cache Playwright browsers
935
+ if: steps.url.outputs.ok == 'true'
936
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
937
+ with:
938
+ path: ~/.cache/ms-playwright
939
+ key: playwright-1-${{ runner.os }}
940
+
941
+ - name: Screenshot + upload
942
+ id: shot
943
+ if: steps.url.outputs.ok == 'true'
944
+ continue-on-error: true
945
+ env:
946
+ # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.
947
+ PLAN_URL: ${{ steps.url.outputs.plan_url }}
948
+ run: |
949
+ set -uo pipefail
950
+ if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then
951
+ "$RECAP_PLAYWRIGHT" install --with-deps chromium || true
952
+ elif command -v pnpm >/dev/null 2>&1; then
953
+ pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true
954
+ else
955
+ npx -y playwright@1 install --with-deps chromium || true
956
+ fi
957
+ IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
958
+ LIGHT_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png --theme light --image-cache-key "$IMAGE_CACHE_KEY" || echo '{}')"
959
+ DARK_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap-dark.png --theme dark --image-cache-key "$IMAGE_CACHE_KEY" || echo '{}')"
960
+ for SHOT_LABEL in light dark; do
961
+ if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi
962
+ SHOT_LABEL="$SHOT_LABEL" SHOT_JSON="$SHOT_JSON" node -e 'const label = process.env.SHOT_LABEL || "shot"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || "{}"); } catch { parsed = { ok: false, reason: "invalid shot JSON" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? "[present]" : "", out: typeof parsed.out === "string" ? parsed.out : "", reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 500) : "" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);'
963
+ done
964
+ IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}' "$LIGHT_SHOT_JSON")
965
+ DARK_IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}' "$DARK_SHOT_JSON")
966
+ SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<'NODE'
967
+ const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } };
968
+ const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]];
969
+ const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim());
970
+ const reasons = shots.flatMap(([label, shot]) => {
971
+ if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];
972
+ if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];
973
+ return [];
974
+ });
975
+ process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? "" : reasons.join("; ").slice(0, 1000) }));
976
+ NODE
977
+ )
978
+ SHOT_OK=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}' "$SHOT_STATUS")
979
+ SHOT_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}' "$SHOT_STATUS")
980
+ if [ "$SHOT_OK" != "true" ]; then
981
+ echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON"
982
+ fi
983
+ echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"
984
+ echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"
985
+ echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"
986
+ echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT"
987
+ {
988
+ echo 'shot_reason<<__RECAP_SHOT_REASON_EOF__'
989
+ echo "$SHOT_REASON"
990
+ echo '__RECAP_SHOT_REASON_EOF__'
991
+ } >> "$GITHUB_OUTPUT"
992
+ if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi
993
+
994
+ - name: Upload recap screenshot artifact
995
+ if: steps.shot.outputs.captured == 'true'
996
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
997
+ with:
998
+ name: pr-visual-recap-${{ github.event.pull_request.number }}
999
+ path: |
1000
+ recap.png
1001
+ recap-dark.png
1002
+ if-no-files-found: ignore
1003
+ retention-days: 14
1004
+
1005
+ - name: Upload recap source artifact
1006
+ if: always() && !cancelled()
1007
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
1008
+ with:
1009
+ # recap-source.json + the agent transcript (claude-result.json /
1010
+ # codex-events.jsonl + stderr) are the only window into WHAT the agent
1011
+ # did when a publish fails (no plan URL) — INCLUDING the case where it
1012
+ # finished without writing recap-source.json at all. The sticky comment
1013
+ # only shows the screenshot, so without these a failed recap is
1014
+ # undebuggable. Uploaded on success + failure; tolerant when absent.
1015
+ name: pr-visual-recap-source-${{ github.event.pull_request.number }}
1016
+ path: |
1017
+ recap-source.json
1018
+ recap-source.initial.json
1019
+ recap-url-reason.txt
1020
+ recap-repair-prompt.md
1021
+ claude-result.json
1022
+ claude-stderr.log
1023
+ claude-repair-result.json
1024
+ claude-repair-stderr.log
1025
+ claude-repair-exit-code.txt
1026
+ codex-events.jsonl
1027
+ codex-stderr.log
1028
+ codex-repair-events.jsonl
1029
+ codex-repair-stderr.log
1030
+ codex-repair-exit-code.txt
1031
+ openai-compatible-result.txt
1032
+ openai-compatible-usage.json
1033
+ openai-compatible-stderr.log
1034
+ openai-compatible-repair-result.txt
1035
+ openai-compatible-repair-usage.json
1036
+ openai-compatible-repair-stderr.log
1037
+ openai-compatible-repair-exit-code.txt
1038
+ if-no-files-found: ignore
1039
+ retention-days: 14
1040
+
1041
+ - name: Upsert sticky comment
1042
+ if: always() && !cancelled()
1043
+ continue-on-error: true
1044
+ env:
1045
+ PLAN_URL: ${{ steps.url.outputs.plan_url }}
1046
+ RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}
1047
+ RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}
1048
+ RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}
1049
+ RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}
1050
+ RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}
1051
+ SUPPRESSED: ${{ steps.scan.outputs.suppressed }}
1052
+ SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}
1053
+ DIFF_HUGE: ${{ steps.diff.outputs.huge }}
1054
+ DIFF_TINY: ${{ steps.diff.outputs.tiny }}
1055
+ PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
1056
+ RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}
1057
+ RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}
1058
+ # Prefer the route-health diagnostic when the plan app routes are not
1059
+ # yet deployed so the comment explains the 404 instead of a generic
1060
+ # "recap-url.txt was not created" message.
1061
+ RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.source_status.outputs.reason || steps.url.outputs.reason }}
1062
+ run: |
1063
+ set -euo pipefail
1064
+ $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"
1065
+
1066
+ - name: Complete visual recap check
1067
+ if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != ''
1068
+ continue-on-error: true
1069
+ env:
1070
+ # Untrusted/step values via env (NOT ${{ }}-interpolated into the run
1071
+ # body): the agent-written plan URL and the scan JSON could inject shell.
1072
+ CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}
1073
+ PLAN_OK: ${{ steps.url.outputs.ok }}
1074
+ PLAN_URL: ${{ steps.url.outputs.plan_url }}
1075
+ SUPPRESSED: ${{ steps.scan.outputs.suppressed }}
1076
+ SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}
1077
+ DIFF_HUGE: ${{ steps.diff.outputs.huge }}
1078
+ DIFF_TINY: ${{ steps.diff.outputs.tiny }}
1079
+ RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}
1080
+ RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.source_status.outputs.reason || steps.url.outputs.reason }}
1081
+ run: |
1082
+ set -uo pipefail
1083
+ $RECAP_CLI recap check complete \
1084
+ --check-run-id "$CHECK_RUN_ID" \
1085
+ --plan-ok "$PLAN_OK" \
1086
+ --plan-url "$PLAN_URL" \
1087
+ --suppressed "$SUPPRESSED" \
1088
+ --suppressed-json "$SUPPRESSED_JSON" \
1089
+ --huge "$DIFF_HUGE" \
1090
+ --tiny "$DIFF_TINY" \
1091
+ --failure-summary "$RECAP_AGENT_SUMMARY" \
1092
+ --url-reason "$RECAP_URL_REASON" \
1093
+ --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"