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