@agent-native/core 0.68.1 → 0.68.2

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 (111) hide show
  1. package/README.md +4 -4
  2. package/corpus/README.md +2 -2
  3. package/corpus/core/CHANGELOG.md +10 -0
  4. package/corpus/core/docs/content/authentication.md +16 -2
  5. package/corpus/core/docs/content/template-analytics.md +14 -9
  6. package/corpus/core/docs/content/template-clips.md +30 -2
  7. package/corpus/core/package.json +1 -1
  8. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
  9. package/corpus/core/src/client/CommandMenu.tsx +98 -3
  10. package/corpus/core/src/client/ConnectBuilderCard.tsx +11 -2
  11. package/corpus/core/src/client/index.ts +2 -0
  12. package/corpus/core/src/oauth-tokens/google-refresh.ts +32 -17
  13. package/corpus/core/src/provider-api/index.ts +37 -17
  14. package/corpus/core/src/server/auth.ts +6 -8
  15. package/corpus/core/src/server/better-auth-instance.ts +19 -4
  16. package/corpus/core/src/server/core-routes-plugin.ts +157 -5
  17. package/corpus/core/src/server/google-oauth-credentials.ts +51 -0
  18. package/corpus/core/src/server/index.ts +8 -0
  19. package/corpus/core/src/server/onboarding-html.ts +2 -1
  20. package/corpus/templates/analytics/app/routes/$.tsx +9 -0
  21. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +33 -1
  22. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +11 -3
  23. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +25 -2
  24. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +12 -0
  25. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +7 -2
  26. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +37 -78
  27. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +33 -1
  28. package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +46 -3
  29. package/corpus/templates/calendar/app/hooks/use-google-auth.ts +203 -2
  30. package/corpus/templates/calendar/app/lib/event-colors.ts +1 -4
  31. package/corpus/templates/calendar/app/lib/rsvp-status.tsx +6 -0
  32. package/corpus/templates/calendar/app/pages/CalendarView.tsx +9 -4
  33. package/corpus/templates/calendar/app/pages/NotFound.tsx +1 -1
  34. package/corpus/templates/calendar/app/pages/Settings.tsx +25 -1
  35. package/corpus/templates/calendar/app/routes/_app.$.tsx +9 -0
  36. package/corpus/templates/calendar/server/handlers/google-auth.ts +152 -12
  37. package/corpus/templates/calendar/server/lib/google-calendar.ts +63 -14
  38. package/corpus/templates/calendar/server/plugins/auth.ts +4 -20
  39. package/corpus/templates/calendar/shared/api.ts +4 -0
  40. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +17 -0
  41. package/corpus/templates/clips/actions/sync-calendars.ts +6 -7
  42. package/corpus/templates/clips/app/components/capture-install-options.tsx +157 -0
  43. package/corpus/templates/clips/app/components/library/library-layout.tsx +9 -8
  44. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +4 -6
  45. package/corpus/templates/clips/app/lib/capture-install-options.ts +13 -0
  46. package/corpus/templates/clips/app/root.tsx +54 -2
  47. package/corpus/templates/clips/app/routes/_app.dictate.tsx +9 -13
  48. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +5 -7
  49. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +5 -7
  50. package/corpus/templates/clips/app/routes/download.tsx +49 -0
  51. package/corpus/templates/clips/app/routes/record.tsx +4 -4
  52. package/corpus/templates/clips/app/routes/share.$shareId.tsx +5 -6
  53. package/corpus/templates/clips/chrome-extension/STORE_DRAFT.md +26 -0
  54. package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +6 -7
  55. package/corpus/templates/clips/server/lib/google-calendar-client.ts +61 -0
  56. package/corpus/templates/clips/server/routes/_agent-native/google/auth-url.get.ts +19 -12
  57. package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +6 -6
  58. package/corpus/templates/design/app/pages/NotFound.tsx +1 -1
  59. package/corpus/templates/design/app/routes/$.tsx +9 -0
  60. package/corpus/templates/mail/app/pages/NotFound.tsx +1 -1
  61. package/corpus/templates/mail/app/routes/$.tsx +9 -0
  62. package/corpus/templates/plan/app/global.css +2 -2
  63. package/corpus/templates/slides/app/pages/NotFound.tsx +1 -1
  64. package/corpus/templates/slides/app/routes/$.tsx +9 -0
  65. package/corpus/templates/slides/server/lib/google-docs-oauth.ts +81 -25
  66. package/corpus/templates/videos/app/pages/NotFound.tsx +1 -1
  67. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  68. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  69. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  70. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  71. package/dist/client/CommandMenu.d.ts +15 -2
  72. package/dist/client/CommandMenu.d.ts.map +1 -1
  73. package/dist/client/CommandMenu.js +46 -3
  74. package/dist/client/CommandMenu.js.map +1 -1
  75. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  76. package/dist/client/ConnectBuilderCard.js +11 -2
  77. package/dist/client/ConnectBuilderCard.js.map +1 -1
  78. package/dist/client/index.d.ts +1 -1
  79. package/dist/client/index.d.ts.map +1 -1
  80. package/dist/client/index.js.map +1 -1
  81. package/dist/oauth-tokens/google-refresh.d.ts.map +1 -1
  82. package/dist/oauth-tokens/google-refresh.js +29 -17
  83. package/dist/oauth-tokens/google-refresh.js.map +1 -1
  84. package/dist/provider-api/index.d.ts.map +1 -1
  85. package/dist/provider-api/index.js +28 -16
  86. package/dist/provider-api/index.js.map +1 -1
  87. package/dist/server/auth.d.ts.map +1 -1
  88. package/dist/server/auth.js +6 -6
  89. package/dist/server/auth.js.map +1 -1
  90. package/dist/server/better-auth-instance.d.ts.map +1 -1
  91. package/dist/server/better-auth-instance.js +18 -4
  92. package/dist/server/better-auth-instance.js.map +1 -1
  93. package/dist/server/core-routes-plugin.d.ts +5 -0
  94. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  95. package/dist/server/core-routes-plugin.js +107 -5
  96. package/dist/server/core-routes-plugin.js.map +1 -1
  97. package/dist/server/google-oauth-credentials.d.ts +15 -0
  98. package/dist/server/google-oauth-credentials.d.ts.map +1 -0
  99. package/dist/server/google-oauth-credentials.js +34 -0
  100. package/dist/server/google-oauth-credentials.js.map +1 -0
  101. package/dist/server/index.d.ts +1 -0
  102. package/dist/server/index.d.ts.map +1 -1
  103. package/dist/server/index.js +1 -0
  104. package/dist/server/index.js.map +1 -1
  105. package/dist/server/onboarding-html.d.ts.map +1 -1
  106. package/dist/server/onboarding-html.js +2 -1
  107. package/dist/server/onboarding-html.js.map +1 -1
  108. package/docs/content/authentication.md +16 -2
  109. package/docs/content/template-analytics.md +14 -9
  110. package/docs/content/template-clips.md +30 -2
  111. package/package.json +1 -1
package/README.md CHANGED
@@ -63,13 +63,13 @@ Install `/visual-plan` and `/visual-recap` so your coding agent can plan before
63
63
  </td>
64
64
  <td width="33%" align="center" valign="top">
65
65
 
66
- **Calendar**
66
+ **Design**
67
67
 
68
- <a href="https://agent-native.com/templates/calendar"><img src="https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2Ffb6c3b483ca24ab3b6c3a758aeceef4c?format=webp&width=800" alt="Calendar template" width="100%" /></a>
68
+ <a href="https://agent-native.com/templates/design"><img src="https://cdn.builder.io/api/v1/image/assets%2F348da13fcd8b414c87de9066196f7266%2F961bedb713a94463b834c1f2f4643bcf?format=webp&width=800" alt="Design template" width="100%" /></a>
69
69
 
70
- **Agent-Native Google Calendar, Calendly**
70
+ **Agent-Native design prototyping**
71
71
 
72
- Manage events, sync with Google Calendar, and share a public booking page with AI scheduling.
72
+ Generate interactive HTML prototypes, compare variants, refine controls, and export the result.
73
73
 
74
74
  </td>
75
75
  </tr>
package/corpus/README.md CHANGED
@@ -27,5 +27,5 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
27
27
 
28
28
  ## Generated Counts
29
29
 
30
- - core files: 1137
31
- - template files: 3937
30
+ - core files: 1138
31
+ - template files: 3944
@@ -1,5 +1,15 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.68.2
4
+
5
+ ### Patch Changes
6
+
7
+ - feaf633: Add a reusable CommandMenu docs group so apps can surface relevant Agent Native documentation from Cmd+K.
8
+ - feaf633: Fix PR visual recap head fetching in private fork workflows without persisting checkout credentials.
9
+ - feaf633: Submit Builder branch waitlist requests to the configured Forms endpoint on hosted Agent Native deployments.
10
+ - feaf633: Try legacy Google OAuth client credentials during refresh after rotating product OAuth clients.
11
+ - feaf633: Allow deployments to configure identity-only Google sign-in credentials separately from product Google OAuth credentials.
12
+
3
13
  ## 0.68.1
4
14
 
5
15
  ### Patch Changes
@@ -104,6 +104,11 @@ Set environment variables to enable social login. Better Auth auto-detects them:
104
104
 
105
105
  ```bash
106
106
  # Google OAuth
107
+ GOOGLE_SIGN_IN_CLIENT_ID=your-low-scope-sign-in-client-id
108
+ GOOGLE_SIGN_IN_CLIENT_SECRET=your-low-scope-sign-in-client-secret
109
+
110
+ # Backwards-compatible fallback, and provider OAuth credentials for templates
111
+ # that connect to Google APIs such as Gmail or Calendar.
107
112
  GOOGLE_CLIENT_ID=your-client-id
108
113
  GOOGLE_CLIENT_SECRET=your-client-secret
109
114
 
@@ -114,6 +119,13 @@ GITHUB_CLIENT_SECRET=your-client-secret
114
119
 
115
120
  Templates that use `createGoogleAuthPlugin()` show a "Sign in with Google" page. The Google OAuth callback handles mobile deep linking for native apps automatically.
116
121
 
122
+ Prefer `GOOGLE_SIGN_IN_CLIENT_ID` / `GOOGLE_SIGN_IN_CLIENT_SECRET` for normal
123
+ app login. That client should request only identity scopes. Keep
124
+ `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` for product integrations that need
125
+ Google API scopes, or as the legacy fallback when a deploy has not been split
126
+ yet. Mail and Calendar-style apps should use their own provider OAuth clients so
127
+ high-scope consent screens do not affect generic app sign-in.
128
+
117
129
  ### OAuth State Signing {#oauth-state-secret}
118
130
 
119
131
  Set `OAUTH_STATE_SECRET` to a random 32+ char value in production so OAuth state envelopes (Google, Atlassian, Zoom) are HMAC-signed with a dedicated key independent of any third-party secret. See [Security — OAuth State Signing](/docs/security#oauth-state) for the full requirements and threat model.
@@ -309,8 +321,10 @@ The default `/_agent-native/google/auth-url` route does this automatically — o
309
321
  | `AGENT_NATIVE_WORKSPACE` | `1` runs in workspace mode — one shared session realm across workspace apps |
310
322
  | `AGENT_NATIVE_SHARE_COOKIE_DOMAIN` | Set with `COOKIE_DOMAIN` to share one auth database across first-party subdomains |
311
323
  | `OAUTH_STATE_SECRET` | Dedicated HMAC key for OAuth state envelopes (see [Security — OAuth State Signing](/docs/security#oauth-state)) |
312
- | `GOOGLE_CLIENT_ID` | Enable Google OAuth |
313
- | `GOOGLE_CLIENT_SECRET` | Google OAuth secret |
324
+ | `GOOGLE_SIGN_IN_CLIENT_ID` | Preferred low-scope Google OAuth client ID for app login |
325
+ | `GOOGLE_SIGN_IN_CLIENT_SECRET` | Preferred low-scope Google OAuth secret for app login |
326
+ | `GOOGLE_CLIENT_ID` | Legacy Google login fallback, and provider OAuth client ID for Google API integrations |
327
+ | `GOOGLE_CLIENT_SECRET` | Legacy Google login fallback, and provider OAuth secret for Google API integrations |
314
328
  | `GITHUB_CLIENT_ID` | Enable GitHub OAuth |
315
329
  | `GITHUB_CLIENT_SECRET` | GitHub OAuth secret |
316
330
  | `ACCESS_TOKEN` | Static bearer fallback for MCP/connect clients; not browser auth |
@@ -140,18 +140,23 @@ truth choices, metric definitions, dashboards, and analyses.
140
140
 
141
141
  Credentials are stored via the framework's settings/env layer — no secrets in git. Production requires:
142
142
 
143
- | Variable | Purpose |
144
- | ---------------------------------------- | ----------------------------- |
145
- | `DATABASE_URL` | Persistent SQL connection URL |
146
- | `BETTER_AUTH_SECRET` / `BETTER_AUTH_URL` | Auth |
147
- | `GOOGLE_CLIENT_ID` / `_SECRET` | Google sign-in (OAuth 2.0) |
148
- | `BIGQUERY_PROJECT_ID` | BigQuery project |
149
- | `GOOGLE_APPLICATION_CREDENTIALS_JSON` | BigQuery service-account JSON |
150
- | `ANTHROPIC_API_KEY` | Agent chat |
143
+ | Variable | Purpose |
144
+ | ---------------------------------------- | ------------------------------------------------------- |
145
+ | `DATABASE_URL` | Persistent SQL connection URL |
146
+ | `BETTER_AUTH_SECRET` / `BETTER_AUTH_URL` | Auth |
147
+ | `GOOGLE_SIGN_IN_CLIENT_ID` / `_SECRET` | Preferred Google sign-in client (OAuth 2.0) |
148
+ | `GOOGLE_CLIENT_ID` / `_SECRET` | Legacy sign-in fallback / Google API integration client |
149
+ | `BIGQUERY_PROJECT_ID` | BigQuery project |
150
+ | `GOOGLE_APPLICATION_CREDENTIALS_JSON` | BigQuery service-account JSON |
151
+ | `ANTHROPIC_API_KEY` | Agent chat |
151
152
 
152
153
  Provider-specific keys (HubSpot, Jira, Gong, Pylon, etc.) are documented in each source's walkthrough on the Data Sources page. If you add a new action that needs an API key, it appears as a new source on that page via the template's onboarding registration.
153
154
 
154
- Note: the BigQuery OAuth credential for Google sign-in is a **separate** credential from the BigQuery service account JSON. Create the sign-in client at GCP Console → APIs & Services → Credentials → OAuth client ID.
155
+ Note: the BigQuery OAuth credential for Google sign-in is a **separate**
156
+ credential from the BigQuery service account JSON. Create the sign-in client at
157
+ GCP Console → APIs & Services → Credentials → OAuth client ID, and prefer the
158
+ `GOOGLE_SIGN_IN_CLIENT_ID` / `GOOGLE_SIGN_IN_CLIENT_SECRET` env names so this
159
+ low-scope login client stays separate from Google API integration clients.
155
160
 
156
161
  ### Data model
157
162
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  title: "Clips"
3
3
  description: "Async screen recording, calendar-synced meeting notes, and push-to-talk voice dictation — paste Clips links into agents and they can read transcripts, visuals, and summaries."
4
+ search: "Clips browser logs developer logs console logs network logs fetch XHR Chrome extension diagnostics recorder desktop app"
4
5
  ---
5
6
 
6
7
  # Clips
@@ -33,10 +34,31 @@ Think along the lines of Loom + Granola + Wispr Flow rolled into one app — but
33
34
  - **Share clips** with per-clip permissions (public, team, private). Link tracking and threaded comments work too.
34
35
  - **Preview public clips in Slack** with a Loom-style playable unfurl after the
35
36
  workspace installs your Clips Slack app.
37
+ - **Capture browser logs with the Chrome extension.** Browser recordings can
38
+ attach redacted console logs and fetch/XHR metadata, which is helpful for
39
+ product bugs and browser-only repros.
36
40
  - **Paste Clips links into agents** so they can discover the agent-readable context: metadata, transcript segments, recommended frames, and timestamped frame images without receiving the raw video file.
37
41
  - **Smart library views.** Group by project, filter by speaker, auto-tag based on content.
38
42
  - **Edit the transcript through chat.** "Fix the mis-transcribed word at 1:42." "Pull three quotes for a blog post." The agent edits the transcript and the UI updates live.
39
43
 
44
+ ## Browser logs and developer diagnostics
45
+
46
+ Use the Clips Chrome extension when you need a recording plus browser logs from
47
+ the tab you are debugging. The extension starts an active-tab recording and can
48
+ save redacted console logs, JavaScript exceptions, and fetch/XHR network
49
+ metadata such as method, redacted URL, status, duration, and failure text. It
50
+ does not save request bodies, response bodies, or headers.
51
+
52
+ The regular browser recorder page can save diagnostics from the recorder page
53
+ itself. The Chrome extension is the path for active-tab developer logs and
54
+ browser-only repros. In the Clips UI, use the Chrome option for browser logs and
55
+ the desktop app for the most seamless everyday capture path.
56
+
57
+ If you host your own Clips server, keep the Chrome extension option hidden until
58
+ your Web Store listing is live. Set `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1` and
59
+ `VITE_CLIPS_CHROME_EXTENSION_URL=<chrome-web-store-url>` after approval to show
60
+ the extension beside desktop-app download prompts.
61
+
40
62
  ## Agent-readable clips
41
63
 
42
64
  Paste a normal public Clips share link into an agent. The share page advertises
@@ -143,7 +165,7 @@ Clips is a larger template with a native recorder (it ships a desktop companion
143
165
 
144
166
  1. **Video storage (required).** Connect a storage backend through the onboarding wizard. The easiest path is Builder.io (free during beta, one-click). For self-hosted storage, set `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and optionally `S3_REGION` and `S3_PUBLIC_BASE_URL`. Cloudflare R2 and DigitalOcean Spaces use the same env vars with the `R2_*` prefix.
145
167
  2. **Google Calendar (optional).** To sync upcoming meetings, connect a Google Calendar account from Settings. The OAuth callback URL in dev is `http://localhost:8094/_agent-native/google/callback`. Set up a Google OAuth client in [Google Cloud Console](https://console.cloud.google.com/) with the Gmail and Google Calendar APIs enabled.
146
- 3. **Screen-capture permissions.** On macOS, grant Screen Recording permission to the browser (or the desktop companion app) in System Settings → Privacy & Security → Screen Recording.
168
+ 3. **Screen-capture permissions.** On macOS, grant Screen Recording permission to the browser (or the desktop companion app) in System Settings → Privacy & Security → Screen Recording. Browser recordings can save redacted console and fetch/XHR diagnostics from the recorder page. Once a deployment has a published Clips Chrome extension URL, enable `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1` and set `VITE_CLIPS_CHROME_EXTENSION_URL` so users can choose the extension for active-tab browser logs or the desktop app for the smoothest native capture path.
147
169
  4. **Slack previews (optional).** Create a Slack app with `links:read`, `links:write`, and `links.embed:write`; subscribe to `link_shared`; add your Clips share domain under **App Unfurl Domains**; set the Request URL to `https://your-clips.example.com/api/slack/unfurl`; and add the OAuth redirect URL `https://your-clips.example.com/api/slack/oauth/callback`. Configure `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, and `SLACK_SIGNING_SECRET`, then connect workspaces from Clips Settings.
148
170
 
149
171
  ### Host your own Clips server
@@ -185,7 +207,13 @@ tray app at your deployment.
185
207
  include that path, such as `https://example.com/clips`. Click **Connect**,
186
208
  then sign in with an account on that Clips server.
187
209
 
188
- 5. **Connect optional integrations.** Google Calendar powers the Meetings tab,
210
+ 5. **Enable the Chrome extension after publishing.** Keep
211
+ `VITE_CLIPS_CHROME_EXTENSION_ENABLED` unset until the Chrome Web Store listing
212
+ is approved and you have a public extension URL. Then set it to `1` and set
213
+ `VITE_CLIPS_CHROME_EXTENSION_URL` to reveal the browser-log option beside the
214
+ desktop app prompts.
215
+
216
+ 6. **Connect optional integrations.** Google Calendar powers the Meetings tab,
189
217
  `GEMINI_API_KEY` or Builder.io Connect powers transcript cleanup and titles,
190
218
  `GROQ_API_KEY` can provide speech-to-text fallback, and the Slack OAuth
191
219
  connection in Settings enables playable Slack unfurls.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.68.1",
3
+ "version": "0.68.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -7,4 +7,4 @@
7
7
  */
8
8
 
9
9
  export const PR_VISUAL_RECAP_WORKFLOW_YML =
10
- 'name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo\'s visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || \'claude\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n\njobs:\n gate:\n name: Gate\n runs-on: ubuntu-latest\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals — never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push(\'no pull_request payload\');\n if (pr && pr.draft) reasons.push(\'draft PR\');\n if (pr && context.payload.action === \'closed\' && !pr.merged) {\n reasons.push(\'closed without merge\');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub\'s "Send secrets to workflows from pull requests" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip — with an actionable hint — those that don\'t.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || \'\').toUpperCase();\n const trustedAssociations = [\'OWNER\', \'MEMBER\', \'COLLABORATOR\'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n if (isFork && process.env.HAS_PLAN !== \'true\') {\n 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`);\n }\n\n const login = (pr && pr.user && pr.user.login || \'\').toLowerCase();\n const botAuthors = [\'dependabot[bot]\', \'dependabot\', \'renovate[bot]\', \'renovate\'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === \'Bot\') reasons.push(\'bot author (type=Bot)\');\n\n if (!isFork && process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // Normalize + validate the agent so a mis-cased value can\'t pass the\n // gate and then match neither agent step below.\n const agent = (process.env.AGENT || \'claude\').toLowerCase();\n if (agent !== \'claude\' && agent !== \'codex\') {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude" or "codex")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || \'\';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || \'auto\').toLowerCase();\n if (![\'auto\', \'latest\', \'repo\'].includes(skillSource)) {\n reasons.push(\'invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")\');\n }\n const usesRepoSkill = skillSource === \'repo\';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can\'t rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for forks and untrusted public-repo PRs. Trusted\n // same-repo authors may edit AGENTS.md/CLAUDE.md as normal reviewable\n // content; the risk acceptance should not depend on repo visibility.\n if (pr && (isFork || (!isPrivate && !isTrustedAuthor))) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n /(^|\\/)\\.claude\\//.test(p) ||\n /(^|\\/)CLAUDE\\.md$/.test(p) ||\n /(^|\\/)AGENTS\\.md$/.test(p) ||\n /(^|\\/)\\.mcp\\.json$/.test(p);\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can\'t be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput(\'run\', run ? \'true\' : \'false\');\n core.setOutput(\'agent\', agent);\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it\'s clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join(\'; \')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = \'<!-- pr-visual-recap -->\';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === \'Bot\' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || \'\').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : \'latest push\';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith(\'could not list PR files for the self-modifying guard\')\n )[0] || reasons[0] || \'skipped\';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n 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.`;\n const withoutPrev = (existing && existing.body ? existing.body : baseBody)\n .split(\'\\n\')\n .filter((l) => !/_Recap skipped for .+_$/.test(l.trim()))\n .join(\'\\n\')\n .trimEnd();\n const updatedBody = `${withoutPrev}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ubuntu-latest\n timeout-minutes: 30\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || \'https://plan.agent-native.com\' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don\'t leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to\n # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == \'true\'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == \'true\'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != \'true\'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n VERSION="$RECAP_CLI_VERSION"\n if [ "$VERSION" = "latest" ]; then\n VERSION="$(npm view @agent-native/core@latest version)"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund "@agent-native/core@$VERSION"; then\n break\n fi\n if [ "$attempt" = "3" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n git fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"\n FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"\n if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then\n echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app\'s action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e \'\n const https = require("https");\n const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n 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(); });\n req.on("error", () => process.stdout.write("0"));\n req.end(JSON.stringify({ planId: "__probe__" }));\n \' 2>/dev/null || echo "0")\n if [ "$HTTP_STATUS" = "401" ]; then\n echo "auth_failed=true" >> "$GITHUB_OUTPUT"\n else\n echo "auth_failed=false" >> "$GITHUB_OUTPUT"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app\'s recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy — the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE="$1" METHOD="$2" node -e \'\n const https = require("https");\n const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");\n 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(); });\n req.on("error", () => process.stdout.write("0"));\n req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });\n if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n \' 2>/dev/null || echo "0"\n }\n CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"\n BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"\n REASON=""\n if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then\n 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."\n echo "::error::$REASON"\n echo "unhealthy=true" >> "$GITHUB_OUTPUT"\n else\n echo "unhealthy=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_ROUTE_HEALTH_EOF__\'\n echo "$REASON"\n echo \'__RECAP_ROUTE_HEALTH_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n {\n echo \'json<<__RECAP_SCAN_EOF__\'\n echo "$SCAN_JSON"\n echo \'__RECAP_SCAN_EOF__\'\n } >> "$GITHUB_OUTPUT"\n SUPPRESSED=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}\' "$SCAN_JSON")\n echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"\n echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then\n echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "ok=false" >> "$GITHUB_OUTPUT"\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_EOF__\'\n echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the publisher will validate the final MDX."\n echo \'__RECAP_BLOCK_REFERENCE_EOF__\'\n } >> "$GITHUB_OUTPUT"\n cat > recap-blocks.md <<\'EOF\'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n 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)\n if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi\n if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n $RECAP_CLI recap build-prompt "${ARGS[@]}"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CLAUDE_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS="$?"\n set -e\n echo "$CLAUDE_STATUS" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # "retry me" signal — the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_claude\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex\'s own sandbox\n # (bubblewrap can\'t init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_STATUS" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) — the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_codex\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n 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")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n URL_REASON=""\n if [ -f recap-url.txt ]; then\n PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON="$(cat recap-url-reason.txt)"\n else\n URL_REASON="recap-url.txt was not created."\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z "$URL_REASON" ]; then\n URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<\'NODE\'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || "";\n if (!raw) {\n emit({ url: "", reason: "recap-url.txt was empty" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, "");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || "/");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });\n process.exit(0);\n }\n }\n emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app" });\n } catch {\n emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}\' "$URL_RESULT")\n 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")\n else\n CANONICAL_URL=""\n fi\n if [ -n "$CANONICAL_URL" ]; then\n echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_URL_REASON_EOF__\'\n echo "$URL_REASON"\n echo \'__RECAP_URL_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != \'true\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n echo "$RECAP_BLOCK_REFERENCE_SUMMARY"\n echo \'__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n node -e \'process.stdout.write(JSON.stringify({ ok: true, summary: process.env.RECAP_BLOCK_REFERENCE_SUMMARY || "" }) + "\\n")\'\n exit 0\n fi\n if [ -n "${RECAP_PUBLISH_REASON:-}" ]; then\n {\n echo \'summary<<__RECAP_PUBLISH_SUMMARY_EOF__\'\n echo "$RECAP_PUBLISH_REASON"\n echo \'__RECAP_PUBLISH_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n node -e \'process.stdout.write(JSON.stringify({ ok: true, summary: process.env.RECAP_PUBLISH_REASON || "" }) + "\\n")\'\n exit 0\n fi\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n fi\n $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || true\n\n - name: Attach usage\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so "Codex" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n 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\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == \'true\'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then\n "$RECAP_PLAYWRIGHT" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n 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 || echo \'{}\')"\n 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 || echo \'{}\')"\n for SHOT_LABEL in light dark; do\n if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi\n 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)}`);\'\n done\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$LIGHT_SHOT_JSON")\n DARK_IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$DARK_SHOT_JSON")\n if [ -z "$IMAGE_URL" ] && [ -z "$DARK_IMAGE_URL" ]; then\n echo "::warning::Visual recap screenshot unavailable; posting link-only recap comment."\n fi\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == \'true\'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) — INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n claude-result.json\n claude-stderr.log\n codex-events.jsonl\n codex-stderr.log\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # "recap-url.txt was not created" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != \'\'\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id "$CHECK_RUN_ID" \\\n --plan-ok "$PLAN_OK" \\\n --plan-url "$PLAN_URL" \\\n --suppressed "$SUPPRESSED" \\\n --suppressed-json "$SUPPRESSED_JSON" \\\n --huge "$DIFF_HUGE" \\\n --tiny "$DIFF_TINY" \\\n --failure-summary "$RECAP_AGENT_SUMMARY" \\\n --url-reason "$RECAP_URL_REASON" \\\n --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n';
10
+ 'name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo\'s visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || \'claude\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n\njobs:\n gate:\n name: Gate\n runs-on: ubuntu-latest\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals — never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push(\'no pull_request payload\');\n if (pr && pr.draft) reasons.push(\'draft PR\');\n if (pr && context.payload.action === \'closed\' && !pr.merged) {\n reasons.push(\'closed without merge\');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub\'s "Send secrets to workflows from pull requests" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip — with an actionable hint — those that don\'t.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || \'\').toUpperCase();\n const trustedAssociations = [\'OWNER\', \'MEMBER\', \'COLLABORATOR\'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n if (isFork && process.env.HAS_PLAN !== \'true\') {\n 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`);\n }\n\n const login = (pr && pr.user && pr.user.login || \'\').toLowerCase();\n const botAuthors = [\'dependabot[bot]\', \'dependabot\', \'renovate[bot]\', \'renovate\'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === \'Bot\') reasons.push(\'bot author (type=Bot)\');\n\n if (!isFork && process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // Normalize + validate the agent so a mis-cased value can\'t pass the\n // gate and then match neither agent step below.\n const agent = (process.env.AGENT || \'claude\').toLowerCase();\n if (agent !== \'claude\' && agent !== \'codex\') {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude" or "codex")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || \'\';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || \'auto\').toLowerCase();\n if (![\'auto\', \'latest\', \'repo\'].includes(skillSource)) {\n reasons.push(\'invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")\');\n }\n const usesRepoSkill = skillSource === \'repo\';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can\'t rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for forks and untrusted public-repo PRs. Trusted\n // same-repo authors may edit AGENTS.md/CLAUDE.md as normal reviewable\n // content; the risk acceptance should not depend on repo visibility.\n if (pr && (isFork || (!isPrivate && !isTrustedAuthor))) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n /(^|\\/)\\.claude\\//.test(p) ||\n /(^|\\/)CLAUDE\\.md$/.test(p) ||\n /(^|\\/)AGENTS\\.md$/.test(p) ||\n /(^|\\/)\\.mcp\\.json$/.test(p);\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can\'t be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput(\'run\', run ? \'true\' : \'false\');\n core.setOutput(\'agent\', agent);\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it\'s clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join(\'; \')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = \'<!-- pr-visual-recap -->\';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === \'Bot\' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || \'\').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : \'latest push\';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith(\'could not list PR files for the self-modifying guard\')\n )[0] || reasons[0] || \'skipped\';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n 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.`;\n const withoutPrev = (existing && existing.body ? existing.body : baseBody)\n .split(\'\\n\')\n .filter((l) => !/_Recap skipped for .+_$/.test(l.trim()))\n .join(\'\\n\')\n .trimEnd();\n const updatedBody = `${withoutPrev}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ubuntu-latest\n timeout-minutes: 30\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || \'https://plan.agent-native.com\' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don\'t leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to\n # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == \'true\'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == \'true\'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != \'true\'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n VERSION="$RECAP_CLI_VERSION"\n if [ "$VERSION" = "latest" ]; then\n VERSION="$(npm view @agent-native/core@latest version)"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund "@agent-native/core@$VERSION"; then\n break\n fi\n if [ "$attempt" = "3" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then\n git update-ref refs/recap/pr-head "$HEAD_SHA"\n else\n AUTH_B64="$(printf \'x-access-token:%s\' "$GH_TOKEN" | base64 | tr -d \'\\n\')"\n git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"\n fi\n FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"\n if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then\n echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app\'s action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e \'\n const https = require("https");\n const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n 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(); });\n req.on("error", () => process.stdout.write("0"));\n req.end(JSON.stringify({ planId: "__probe__" }));\n \' 2>/dev/null || echo "0")\n if [ "$HTTP_STATUS" = "401" ]; then\n echo "auth_failed=true" >> "$GITHUB_OUTPUT"\n else\n echo "auth_failed=false" >> "$GITHUB_OUTPUT"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app\'s recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy — the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE="$1" METHOD="$2" node -e \'\n const https = require("https");\n const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");\n 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(); });\n req.on("error", () => process.stdout.write("0"));\n req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });\n if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n \' 2>/dev/null || echo "0"\n }\n CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"\n BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"\n REASON=""\n if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then\n 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."\n echo "::error::$REASON"\n echo "unhealthy=true" >> "$GITHUB_OUTPUT"\n else\n echo "unhealthy=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_ROUTE_HEALTH_EOF__\'\n echo "$REASON"\n echo \'__RECAP_ROUTE_HEALTH_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n {\n echo \'json<<__RECAP_SCAN_EOF__\'\n echo "$SCAN_JSON"\n echo \'__RECAP_SCAN_EOF__\'\n } >> "$GITHUB_OUTPUT"\n SUPPRESSED=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}\' "$SCAN_JSON")\n echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"\n echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then\n echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "ok=false" >> "$GITHUB_OUTPUT"\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_EOF__\'\n echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the publisher will validate the final MDX."\n echo \'__RECAP_BLOCK_REFERENCE_EOF__\'\n } >> "$GITHUB_OUTPUT"\n cat > recap-blocks.md <<\'EOF\'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n 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)\n if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi\n if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n $RECAP_CLI recap build-prompt "${ARGS[@]}"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CLAUDE_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS="$?"\n set -e\n echo "$CLAUDE_STATUS" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # "retry me" signal — the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_claude\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex\'s own sandbox\n # (bubblewrap can\'t init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_STATUS" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) — the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_codex\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n 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")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n URL_REASON=""\n if [ -f recap-url.txt ]; then\n PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON="$(cat recap-url-reason.txt)"\n else\n URL_REASON="recap-url.txt was not created."\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z "$URL_REASON" ]; then\n URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<\'NODE\'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || "";\n if (!raw) {\n emit({ url: "", reason: "recap-url.txt was empty" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, "");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || "/");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });\n process.exit(0);\n }\n }\n emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app" });\n } catch {\n emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}\' "$URL_RESULT")\n 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")\n else\n CANONICAL_URL=""\n fi\n if [ -n "$CANONICAL_URL" ]; then\n echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_URL_REASON_EOF__\'\n echo "$URL_REASON"\n echo \'__RECAP_URL_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != \'true\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n echo "$RECAP_BLOCK_REFERENCE_SUMMARY"\n echo \'__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n node -e \'process.stdout.write(JSON.stringify({ ok: true, summary: process.env.RECAP_BLOCK_REFERENCE_SUMMARY || "" }) + "\\n")\'\n exit 0\n fi\n if [ -n "${RECAP_PUBLISH_REASON:-}" ]; then\n {\n echo \'summary<<__RECAP_PUBLISH_SUMMARY_EOF__\'\n echo "$RECAP_PUBLISH_REASON"\n echo \'__RECAP_PUBLISH_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n node -e \'process.stdout.write(JSON.stringify({ ok: true, summary: process.env.RECAP_PUBLISH_REASON || "" }) + "\\n")\'\n exit 0\n fi\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n fi\n $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || true\n\n - name: Attach usage\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so "Codex" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n 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\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == \'true\'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then\n "$RECAP_PLAYWRIGHT" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n 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 || echo \'{}\')"\n 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 || echo \'{}\')"\n for SHOT_LABEL in light dark; do\n if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi\n 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)}`);\'\n done\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$LIGHT_SHOT_JSON")\n DARK_IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$DARK_SHOT_JSON")\n if [ -z "$IMAGE_URL" ] && [ -z "$DARK_IMAGE_URL" ]; then\n echo "::warning::Visual recap screenshot unavailable; posting link-only recap comment."\n fi\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == \'true\'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) — INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n claude-result.json\n claude-stderr.log\n codex-events.jsonl\n codex-stderr.log\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # "recap-url.txt was not created" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != \'\'\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id "$CHECK_RUN_ID" \\\n --plan-ok "$PLAN_OK" \\\n --plan-url "$PLAN_URL" \\\n --suppressed "$SUPPRESSED" \\\n --suppressed-json "$SUPPRESSED_JSON" \\\n --huge "$DIFF_HUGE" \\\n --tiny "$DIFF_TINY" \\\n --failure-summary "$RECAP_AGENT_SUMMARY" \\\n --url-reason "$RECAP_URL_REASON" \\\n --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n';
@@ -24,7 +24,12 @@ import React, {
24
24
  useState,
25
25
  type ReactNode,
26
26
  } from "react";
27
- import { IconSearch, IconMessage } from "@tabler/icons-react";
27
+ import {
28
+ IconBook2,
29
+ IconExternalLink,
30
+ IconSearch,
31
+ IconMessage,
32
+ } from "@tabler/icons-react";
28
33
  import { sendToAgentChat } from "./agent-chat.js";
29
34
  import { cn } from "./utils.js";
30
35
 
@@ -96,6 +101,7 @@ interface CommandItemProps {
96
101
  children: ReactNode;
97
102
  keywords?: string[];
98
103
  className?: string;
104
+ deferSelect?: boolean;
99
105
  }
100
106
 
101
107
  function CommandItem({
@@ -103,12 +109,19 @@ function CommandItem({
103
109
  children,
104
110
  keywords: _keywords,
105
111
  className,
112
+ deferSelect = true,
106
113
  }: CommandItemProps) {
107
114
  const { onOpenChange, containerRef, setSelectedIndex } =
108
115
  useCommandMenuContext();
109
116
  const itemRef = useRef<HTMLDivElement>(null);
110
117
 
111
118
  const handleSelect = () => {
119
+ if (!deferSelect) {
120
+ onSelect();
121
+ onOpenChange(false);
122
+ return;
123
+ }
124
+
112
125
  onOpenChange(false);
113
126
  // Small delay to let dialog close animation start
114
127
  setTimeout(onSelect, 50);
@@ -159,6 +172,68 @@ function CommandSeparator({ className }: { className?: string }) {
159
172
  return <div className={cn("-mx-1 my-1 h-px bg-border", className)} />;
160
173
  }
161
174
 
175
+ export interface CommandMenuDoc {
176
+ title: string;
177
+ href: string;
178
+ description?: string;
179
+ keywords?: string[];
180
+ }
181
+
182
+ interface CommandDocsGroupProps {
183
+ docs: CommandMenuDoc[];
184
+ heading?: string;
185
+ }
186
+
187
+ function commandDocSearchText(doc: CommandMenuDoc): string {
188
+ return [doc.title, doc.description, ...(doc.keywords ?? [])]
189
+ .filter(Boolean)
190
+ .join(" ")
191
+ .toLowerCase();
192
+ }
193
+
194
+ function filterCommandDocs(docs: CommandMenuDoc[], search: string) {
195
+ const searchLower = search.trim().toLowerCase();
196
+ if (!searchLower) return docs;
197
+ return docs.filter((doc) => commandDocSearchText(doc).includes(searchLower));
198
+ }
199
+
200
+ function openDocsHref(href: string) {
201
+ if (/^https?:\/\//i.test(href)) {
202
+ window.open(href, "_blank", "noopener,noreferrer");
203
+ return;
204
+ }
205
+ window.location.assign(href);
206
+ }
207
+
208
+ function CommandDocsGroup({ docs, heading = "Docs" }: CommandDocsGroupProps) {
209
+ if (docs.length === 0) return null;
210
+
211
+ return (
212
+ <CommandGroup heading={heading}>
213
+ {docs.map((doc) => (
214
+ <CommandItem
215
+ key={doc.href}
216
+ onSelect={() => openDocsHref(doc.href)}
217
+ keywords={[doc.title, doc.description ?? "", ...(doc.keywords ?? [])]}
218
+ deferSelect={false}
219
+ className="items-start py-2"
220
+ >
221
+ <IconBook2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
222
+ <span className="min-w-0 flex-1">
223
+ <span className="block truncate font-medium">{doc.title}</span>
224
+ {doc.description ? (
225
+ <span className="mt-0.5 block line-clamp-2 text-xs leading-snug text-muted-foreground">
226
+ {doc.description}
227
+ </span>
228
+ ) : null}
229
+ </span>
230
+ <IconExternalLink className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
231
+ </CommandItem>
232
+ ))}
233
+ </CommandGroup>
234
+ );
235
+ }
236
+
162
237
  // ─── Main Component ─────────────────────────────────────────────────────────
163
238
 
164
239
  export interface CommandMenuProps {
@@ -305,6 +380,18 @@ export function CommandMenu({
305
380
  } as Record<string, unknown>);
306
381
  }
307
382
 
383
+ if (child.type === CommandDocsGroup) {
384
+ const docs = Array.isArray(props.docs)
385
+ ? (props.docs as CommandMenuDoc[])
386
+ : [];
387
+ const filteredDocs = filterCommandDocs(docs, search);
388
+ if (filteredDocs.length === 0) return null;
389
+ return React.cloneElement(child, {
390
+ ...props,
391
+ docs: filteredDocs,
392
+ } as Record<string, unknown>);
393
+ }
394
+
308
395
  // If it's a CommandItem, check if it matches search
309
396
  if (child.type === CommandItem) {
310
397
  if (!search) return child;
@@ -330,7 +417,9 @@ export function CommandMenu({
330
417
 
331
418
  const filteredChildren = filterChildren(children);
332
419
  const hasResults = React.Children.toArray(filteredChildren).some(
333
- (child) => React.isValidElement(child) && child.type === CommandGroup,
420
+ (child) =>
421
+ React.isValidElement(child) &&
422
+ (child.type === CommandGroup || child.type === CommandDocsGroup),
334
423
  );
335
424
 
336
425
  return (
@@ -435,6 +524,7 @@ function getTextContent(children: ReactNode): string {
435
524
  // Attach sub-components
436
525
  CommandMenu.Group = CommandGroup;
437
526
  CommandMenu.Item = CommandItem;
527
+ CommandMenu.DocsGroup = CommandDocsGroup;
438
528
  CommandMenu.Shortcut = CommandShortcut;
439
529
  CommandMenu.Separator = CommandSeparator;
440
530
 
@@ -465,4 +555,9 @@ export function useCommandMenuShortcut(onOpen: () => void) {
465
555
  }, [onOpen]);
466
556
  }
467
557
 
468
- export type { CommandGroupProps, CommandItemProps, CommandShortcutProps };
558
+ export type {
559
+ CommandDocsGroupProps,
560
+ CommandGroupProps,
561
+ CommandItemProps,
562
+ CommandShortcutProps,
563
+ };
@@ -142,7 +142,16 @@ export function ConnectBuilderCard({
142
142
  agentNativePath("/_agent-native/builder/branch-waitlist"),
143
143
  origin,
144
144
  ).href,
145
- { method: "POST" },
145
+ {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify({
149
+ prompt,
150
+ orgName,
151
+ pageUrl: window.location.href,
152
+ source: "connect_builder_card",
153
+ }),
154
+ },
146
155
  );
147
156
  if (!res.ok) {
148
157
  const data = await res.json().catch(() => ({}));
@@ -160,7 +169,7 @@ export function ConnectBuilderCard({
160
169
  setWaitlistErr(e instanceof Error ? e.message : "Couldn't join waitlist");
161
170
  setJoiningWaitlist(false);
162
171
  }
163
- }, []);
172
+ }, [orgName, prompt]);
164
173
 
165
174
  // Combine connect-flow errors, send errors, and waitlist errors.
166
175
  const err = sendErr ?? waitlistErr ?? flow.error;
@@ -707,6 +707,8 @@ export {
707
707
  openAgentSidebar,
708
708
  submitToAgent,
709
709
  type CommandMenuProps,
710
+ type CommandMenuDoc,
711
+ type CommandDocsGroupProps,
710
712
  type CommandGroupProps,
711
713
  type CommandItemProps,
712
714
  type CommandShortcutProps,