@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
@@ -82,6 +82,35 @@ export interface ExchangeCodeArgs {
82
82
  redirectUri: string;
83
83
  }
84
84
 
85
+ export interface GoogleOAuthClientCredentials {
86
+ clientId: string;
87
+ clientSecret: string;
88
+ }
89
+
90
+ function readCredentialPair(
91
+ clientIdKey: string,
92
+ clientSecretKey: string,
93
+ ): GoogleOAuthClientCredentials | null {
94
+ const clientId = process.env[clientIdKey];
95
+ const clientSecret = process.env[clientSecretKey];
96
+ if (!clientId || !clientSecret) return null;
97
+ return { clientId, clientSecret };
98
+ }
99
+
100
+ export function resolveGoogleOAuthCredentialCandidates(): GoogleOAuthClientCredentials[] {
101
+ const primary = readCredentialPair(
102
+ "GOOGLE_CLIENT_ID",
103
+ "GOOGLE_CLIENT_SECRET",
104
+ );
105
+ const legacy = readCredentialPair(
106
+ "GOOGLE_LEGACY_CLIENT_ID",
107
+ "GOOGLE_LEGACY_CLIENT_SECRET",
108
+ );
109
+ if (!primary) return legacy ? [legacy] : [];
110
+ if (!legacy || legacy.clientId === primary.clientId) return [primary];
111
+ return [primary, legacy];
112
+ }
113
+
85
114
  /** Exchange an authorization code for tokens. Throws on non-2xx. */
86
115
  export async function exchangeCode(
87
116
  args: ExchangeCodeArgs,
@@ -129,6 +158,38 @@ export async function refreshAccessToken(args: {
129
158
  return (await res.json()) as GoogleTokenResponse;
130
159
  }
131
160
 
161
+ function isPermanentRefreshFailure(error: unknown): boolean {
162
+ const message = error instanceof Error ? error.message : String(error || "");
163
+ const lower = message.toLowerCase();
164
+ return (
165
+ lower.includes("invalid_grant") ||
166
+ lower.includes("unauthorized_client") ||
167
+ lower.includes("invalid_client")
168
+ );
169
+ }
170
+
171
+ export async function refreshAccessTokenWithFallback(args: {
172
+ refreshToken: string;
173
+ credentials: GoogleOAuthClientCredentials[];
174
+ }): Promise<GoogleTokenResponse> {
175
+ let lastError: unknown;
176
+ for (const credentials of args.credentials) {
177
+ try {
178
+ return await refreshAccessToken({
179
+ refreshToken: args.refreshToken,
180
+ clientId: credentials.clientId,
181
+ clientSecret: credentials.clientSecret,
182
+ });
183
+ } catch (error) {
184
+ lastError = error;
185
+ if (!isPermanentRefreshFailure(error)) throw error;
186
+ }
187
+ }
188
+ throw lastError instanceof Error
189
+ ? lastError
190
+ : new Error("Google token refresh failed.");
191
+ }
192
+
132
193
  /** Best-effort revoke. Returns true if Google returned 2xx, false otherwise. */
133
194
  export async function revokeToken(token: string): Promise<boolean> {
134
195
  try {
@@ -8,6 +8,7 @@ import {
8
8
  encodeOAuthState,
9
9
  getSession,
10
10
  isElectron,
11
+ resolveGoogleSignInCredentials,
11
12
  resolveOAuthRedirectUri,
12
13
  safeReturnPath,
13
14
  } from "@agent-native/core/server";
@@ -34,17 +35,6 @@ function oauthRedirectResponse(url: string) {
34
35
  }
35
36
 
36
37
  export default defineEventHandler(async (event: H3Event) => {
37
- const clientId = process.env.GOOGLE_CLIENT_ID;
38
- const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
39
- if (!clientId || !clientSecret) {
40
- setResponseStatus(event, 422);
41
- return {
42
- error: "missing_credentials",
43
- message:
44
- "Google OAuth credentials are not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.",
45
- };
46
- }
47
-
48
38
  try {
49
39
  const q = getQuery(event);
50
40
  const redirectUri = resolveOAuthRedirectUri(event);
@@ -67,6 +57,23 @@ export default defineEventHandler(async (event: H3Event) => {
67
57
  const returnUrl = requestedReturn !== "/" ? requestedReturn : undefined;
68
58
  const calendarConnect =
69
59
  q.calendar === "1" || q.calendar === "true" || q.product === "calendar";
60
+ const credentials = calendarConnect
61
+ ? process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
62
+ ? {
63
+ clientId: process.env.GOOGLE_CLIENT_ID,
64
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
65
+ }
66
+ : null
67
+ : resolveGoogleSignInCredentials();
68
+ if (!credentials) {
69
+ setResponseStatus(event, 422);
70
+ return {
71
+ error: "missing_credentials",
72
+ message: calendarConnect
73
+ ? "Google Calendar OAuth credentials are not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET."
74
+ : "Google sign-in credentials are not configured. Set GOOGLE_SIGN_IN_CLIENT_ID and GOOGLE_SIGN_IN_CLIENT_SECRET.",
75
+ };
76
+ }
70
77
 
71
78
  if (calendarConnect && !owner) {
72
79
  setResponseStatus(event, 401);
@@ -87,7 +94,7 @@ export default defineEventHandler(async (event: H3Event) => {
87
94
  });
88
95
 
89
96
  const params = new URLSearchParams({
90
- client_id: clientId,
97
+ client_id: credentials.clientId,
91
98
  redirect_uri: redirectUri,
92
99
  response_type: "code",
93
100
  state,
@@ -10,6 +10,7 @@ import {
10
10
  getAppUrl,
11
11
  oauthCallbackResponse,
12
12
  oauthErrorPage,
13
+ resolveGoogleSignInCredentials,
13
14
  resolveOAuthOwner,
14
15
  setDesktopExchange,
15
16
  type OAuthStatePayload,
@@ -51,11 +52,10 @@ async function handleGoogleSignInCallback(
51
52
  return { error: "Missing authorization code" };
52
53
  }
53
54
 
54
- const clientId = process.env.GOOGLE_CLIENT_ID;
55
- const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
56
- if (!clientId || !clientSecret) {
55
+ const credentials = resolveGoogleSignInCredentials();
56
+ if (!credentials) {
57
57
  return oauthErrorPage(
58
- "Google OAuth is not configured (missing client id/secret).",
58
+ "Google sign-in is not configured (missing client id/secret).",
59
59
  );
60
60
  }
61
61
 
@@ -66,8 +66,8 @@ async function handleGoogleSignInCallback(
66
66
  },
67
67
  body: new URLSearchParams({
68
68
  code,
69
- client_id: clientId,
70
- client_secret: clientSecret,
69
+ client_id: credentials.clientId,
70
+ client_secret: credentials.clientSecret,
71
71
  redirect_uri: state.redirectUri,
72
72
  grant_type: "authorization_code",
73
73
  }),
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
4
4
 
5
5
  export default function NotFound() {
6
6
  return (
7
- <div className="min-h-screen bg-background flex flex-col items-center justify-center">
7
+ <div className="flex min-h-full w-full flex-col items-center justify-center bg-background px-4 py-12">
8
8
  <h1 className="text-6xl font-bold text-muted-foreground/60 mb-4">404</h1>
9
9
  <p className="text-sm text-muted-foreground mb-6">
10
10
  The page you are looking for does not exist.
@@ -0,0 +1,9 @@
1
+ import NotFound from "@/pages/NotFound";
2
+
3
+ export function meta() {
4
+ return [{ title: "Not Found - Design" }];
5
+ }
6
+
7
+ export default function CatchAllRoute() {
8
+ return <NotFound />;
9
+ }
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
4
4
 
5
5
  export function NotFound() {
6
6
  return (
7
- <div className="flex h-screen items-center justify-center bg-background">
7
+ <div className="flex min-h-full w-full items-center justify-center bg-background px-4 py-12">
8
8
  <div className="text-center">
9
9
  <IconMail className="h-12 w-12 text-muted-foreground/40 mx-auto mb-4" />
10
10
  <h1 className="text-2xl font-semibold text-foreground">404</h1>
@@ -0,0 +1,9 @@
1
+ import { NotFound } from "@/pages/NotFound";
2
+
3
+ export function meta() {
4
+ return [{ title: "Not Found - Mail" }];
5
+ }
6
+
7
+ export default function CatchAllRoute() {
8
+ return <NotFound />;
9
+ }
@@ -380,9 +380,9 @@
380
380
  --rail-gap: 2rem;
381
381
  }
382
382
 
383
- @media (min-width: 1024px) {
383
+ @container plan-doc (min-width: 64rem) {
384
384
  .plan-content-surface[data-plan-layout="wide"] .plan-document-shell {
385
- --plan-wide-component-width: min(1560px, calc(100vw - 5rem));
385
+ --plan-wide-component-width: min(1560px, calc(100cqw - 5rem));
386
386
  }
387
387
 
388
388
  .plan-content-surface[data-plan-layout="wide"] .plan-document-flow-stack {
@@ -10,7 +10,7 @@ export default function NotFound() {
10
10
  }, [location.pathname]);
11
11
 
12
12
  return (
13
- <div className="min-h-screen flex items-center justify-center bg-background">
13
+ <div className="flex min-h-0 w-full flex-1 items-center justify-center bg-background px-4 py-12">
14
14
  <div className="text-center">
15
15
  <h1 className="text-6xl font-bold text-muted-foreground/40 mb-2">
16
16
  404
@@ -0,0 +1,9 @@
1
+ import NotFound from "@/pages/NotFound";
2
+
3
+ export function meta() {
4
+ return [{ title: "Not Found - Slides" }];
5
+ }
6
+
7
+ export default function CatchAllRoute() {
8
+ return <NotFound />;
9
+ }
@@ -30,19 +30,55 @@ interface GoogleUserInfo {
30
30
  verified_email?: boolean;
31
31
  }
32
32
 
33
+ interface GoogleOAuthCredentials {
34
+ clientId: string;
35
+ clientSecret: string;
36
+ }
37
+
38
+ function readCredentialPair(
39
+ clientIdKey: string,
40
+ clientSecretKey: string,
41
+ ): GoogleOAuthCredentials | null {
42
+ const clientId = process.env[clientIdKey];
43
+ const clientSecret = process.env[clientSecretKey];
44
+ if (!clientId || !clientSecret) return null;
45
+ return { clientId, clientSecret };
46
+ }
47
+
48
+ function resolveGoogleProviderCredentialCandidates(): GoogleOAuthCredentials[] {
49
+ const primary = readCredentialPair(
50
+ "GOOGLE_CLIENT_ID",
51
+ "GOOGLE_CLIENT_SECRET",
52
+ );
53
+ const legacy = readCredentialPair(
54
+ "GOOGLE_LEGACY_CLIENT_ID",
55
+ "GOOGLE_LEGACY_CLIENT_SECRET",
56
+ );
57
+ if (!primary) return legacy ? [legacy] : [];
58
+ if (!legacy || legacy.clientId === primary.clientId) return [primary];
59
+ return [primary, legacy];
60
+ }
61
+
33
62
  function getOAuthCredentials(): { clientId: string; clientSecret: string } {
34
- const clientId = process.env.GOOGLE_CLIENT_ID;
35
- const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
36
- if (!clientId || !clientSecret) {
63
+ const credentials = resolveGoogleProviderCredentialCandidates()[0];
64
+ if (!credentials) {
37
65
  throw new Error(
38
66
  "Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.",
39
67
  );
40
68
  }
41
- return { clientId, clientSecret };
69
+ return credentials;
42
70
  }
43
71
 
44
72
  export function isGoogleDocsOAuthConfigured(): boolean {
45
- return !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
73
+ return resolveGoogleProviderCredentialCandidates().length > 0;
74
+ }
75
+
76
+ function isPermanentGoogleRefreshError(error: string | undefined): boolean {
77
+ return (
78
+ error === "invalid_grant" ||
79
+ error === "unauthorized_client" ||
80
+ error === "invalid_client"
81
+ );
46
82
  }
47
83
 
48
84
  export function getGooglePickerConfig(): {
@@ -98,35 +134,55 @@ async function refreshGoogleDocsToken(
98
134
  throw new Error("Google Docs connection expired. Please reconnect.");
99
135
  }
100
136
 
101
- const { clientId, clientSecret } = getOAuthCredentials();
102
- const response = await fetch(TOKEN_URL, {
103
- method: "POST",
104
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
105
- body: new URLSearchParams({
106
- refresh_token: tokens.refresh_token,
107
- client_id: clientId,
108
- client_secret: clientSecret,
109
- grant_type: "refresh_token",
110
- }),
111
- });
112
- const data = (await response.json()) as {
137
+ const credentialCandidates = resolveGoogleProviderCredentialCandidates();
138
+ let data: {
113
139
  access_token?: string;
114
140
  expires_in?: number;
115
141
  token_type?: string;
116
142
  scope?: string;
117
143
  error?: string;
118
144
  error_description?: string;
119
- };
120
- if (!response.ok || !data.access_token) {
121
- if (
122
- data.error === "invalid_grant" ||
123
- data.error === "unauthorized_client" ||
124
- data.error === "invalid_client"
125
- ) {
145
+ } | null = null;
146
+ let lastStatusText = "Could not refresh Google token.";
147
+ for (const credentials of credentialCandidates) {
148
+ const response = await fetch(TOKEN_URL, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
151
+ body: new URLSearchParams({
152
+ refresh_token: tokens.refresh_token,
153
+ client_id: credentials.clientId,
154
+ client_secret: credentials.clientSecret,
155
+ grant_type: "refresh_token",
156
+ }),
157
+ });
158
+ lastStatusText = response.statusText;
159
+ data = (await response.json()) as {
160
+ access_token?: string;
161
+ expires_in?: number;
162
+ token_type?: string;
163
+ scope?: string;
164
+ error?: string;
165
+ error_description?: string;
166
+ };
167
+ if (response.ok && data.access_token) break;
168
+ if (!isPermanentGoogleRefreshError(data.error)) {
169
+ throw new Error(
170
+ data.error_description ||
171
+ data.error ||
172
+ "Could not refresh Google token.",
173
+ );
174
+ }
175
+ }
176
+
177
+ if (!data?.access_token) {
178
+ if (isPermanentGoogleRefreshError(data?.error)) {
126
179
  await deleteOAuthTokens(GOOGLE_DOCS_PROVIDER, accountId);
127
180
  }
128
181
  throw new Error(
129
- data.error_description || data.error || "Could not refresh Google token.",
182
+ data?.error_description ||
183
+ data?.error ||
184
+ lastStatusText ||
185
+ "Could not refresh Google token.",
130
186
  );
131
187
  }
132
188
 
@@ -12,7 +12,7 @@ const NotFound = () => {
12
12
  }, [location.pathname]);
13
13
 
14
14
  return (
15
- <div className="min-h-screen flex items-center justify-center bg-background">
15
+ <div className="flex min-h-full w-full items-center justify-center bg-background px-4 py-12">
16
16
  <div className="text-center">
17
17
  <h1 className="text-5xl font-bold text-muted-foreground/30 mb-3">
18
18
  404
@@ -5,5 +5,5 @@
5
5
  * This file is generated from the workflow source; tests assert the exported
6
6
  * string stays in sync.
7
7
  */
8
- export declare const PR_VISUAL_RECAP_WORKFLOW_YML = "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 \u2014 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 \u2014 with an actionable hint \u2014 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 \u2014 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 ? ', \u2026' : ''}) \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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) \u2014 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) \u2014 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";
8
+ export declare const PR_VISUAL_RECAP_WORKFLOW_YML = "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 \u2014 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 \u2014 with an actionable hint \u2014 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 \u2014 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 ? ', \u2026' : ''}) \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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) \u2014 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) \u2014 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";
9
9
  //# sourceMappingURL=pr-visual-recap-workflow.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pr-visual-recap-workflow.d.ts","sourceRoot":"","sources":["../../src/cli/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,eAAO,MAAM,4BAA4B,sixCACyowC,CAAC"}
1
+ {"version":3,"file":"pr-visual-recap-workflow.d.ts","sourceRoot":"","sources":["../../src/cli/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,eAAO,MAAM,4BAA4B,o3xCACi9wC,CAAC"}