@agent-native/recap-cli 0.0.0-beta-20260820202238

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