@agent-native/core 0.98.1 → 0.98.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 (122) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/docs/content/locales/ar-SA/pr-visual-recap.mdx +25 -1
  4. package/corpus/core/docs/content/locales/ar-SA/template-plan.mdx +1 -1
  5. package/corpus/core/docs/content/locales/de-DE/pr-visual-recap.mdx +25 -1
  6. package/corpus/core/docs/content/locales/de-DE/template-plan.mdx +1 -1
  7. package/corpus/core/docs/content/locales/es-ES/pr-visual-recap.mdx +25 -1
  8. package/corpus/core/docs/content/locales/es-ES/template-plan.mdx +1 -1
  9. package/corpus/core/docs/content/locales/fr-FR/pr-visual-recap.mdx +25 -1
  10. package/corpus/core/docs/content/locales/fr-FR/template-plan.mdx +1 -1
  11. package/corpus/core/docs/content/locales/hi-IN/pr-visual-recap.mdx +25 -1
  12. package/corpus/core/docs/content/locales/hi-IN/template-plan.mdx +1 -1
  13. package/corpus/core/docs/content/locales/ja-JP/pr-visual-recap.mdx +25 -1
  14. package/corpus/core/docs/content/locales/ja-JP/template-plan.mdx +1 -1
  15. package/corpus/core/docs/content/locales/ko-KR/pr-visual-recap.mdx +25 -1
  16. package/corpus/core/docs/content/locales/ko-KR/template-plan.mdx +1 -1
  17. package/corpus/core/docs/content/locales/pt-BR/pr-visual-recap.mdx +25 -1
  18. package/corpus/core/docs/content/locales/pt-BR/template-plan.mdx +1 -1
  19. package/corpus/core/docs/content/locales/zh-CN/pr-visual-recap.mdx +25 -1
  20. package/corpus/core/docs/content/locales/zh-CN/template-plan.mdx +1 -1
  21. package/corpus/core/docs/content/locales/zh-TW/pr-visual-recap.mdx +25 -1
  22. package/corpus/core/docs/content/locales/zh-TW/template-plan.mdx +1 -1
  23. package/corpus/core/docs/content/pr-visual-recap.mdx +50 -1
  24. package/corpus/core/docs/content/template-plan.mdx +1 -1
  25. package/corpus/core/package.json +1 -1
  26. package/corpus/core/src/cli/plan-local.ts +43 -11
  27. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +2 -9
  28. package/corpus/core/src/cli/recap.ts +356 -4
  29. package/corpus/core/src/cli/skills-content/local-files.ts +17 -12
  30. package/corpus/core/src/client/analytics.ts +96 -14
  31. package/corpus/core/src/client/index.ts +5 -0
  32. package/corpus/core/src/client/session-replay.ts +10 -0
  33. package/corpus/core/src/client/settings/DemoModeSection.tsx +4 -28
  34. package/corpus/core/src/client/url-scrub.ts +13 -0
  35. package/corpus/core/src/client/use-demo-mode-status.ts +44 -0
  36. package/corpus/templates/analytics/AGENTS.md +8 -0
  37. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +18 -4
  38. package/corpus/templates/analytics/app/lib/demo-chart-trend.ts +156 -0
  39. package/corpus/templates/analytics/changelog/2026-07-12-daily-dashboard-emails-reliably-include-the-complete-dashboa.md +6 -0
  40. package/corpus/templates/analytics/changelog/2026-07-12-demo-mode-charts-trend-upward.md +6 -0
  41. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +2 -0
  42. package/corpus/templates/analytics/server/lib/dashboard-report.ts +17 -33
  43. package/corpus/templates/clips/app/components/player/video-player.tsx +5 -1
  44. package/corpus/templates/clips/changelog/2026-07-11-clips-no-longer-stay-stuck-on-preparing-clip-when-they-are-r.md +6 -0
  45. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +2 -2
  46. package/corpus/templates/content/changelog/2026-07-12-the-left-sidebar-stays-fixed-horizontally-while-scrolling-on.md +6 -0
  47. package/corpus/templates/plan/.agents/skills/visual-plan/references/local-files.md +17 -12
  48. package/corpus/templates/plan/.agents/skills/visual-recap/references/local-files.md +17 -12
  49. package/corpus/templates/plan/actions/validate-local-plan-source.ts +4 -4
  50. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +2 -1
  51. package/corpus/templates/plan/app/lib/plan-local-bridge.ts +41 -0
  52. package/corpus/templates/plan/app/lib/plan-tracking.ts +3 -0
  53. package/corpus/templates/plan/app/pages/PlansPage.tsx +9 -9
  54. package/corpus/templates/plan/app/root.tsx +13 -3
  55. package/corpus/templates/plan/changelog/2026-07-12-local-plan-source-now-stays-on-device.md +6 -0
  56. package/dist/cli/plan-local.d.ts +6 -6
  57. package/dist/cli/plan-local.d.ts.map +1 -1
  58. package/dist/cli/plan-local.js +36 -7
  59. package/dist/cli/plan-local.js.map +1 -1
  60. package/dist/cli/pr-visual-recap-workflow.d.ts +2 -8
  61. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  62. package/dist/cli/pr-visual-recap-workflow.js +2 -8
  63. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  64. package/dist/cli/recap.d.ts +22 -1
  65. package/dist/cli/recap.d.ts.map +1 -1
  66. package/dist/cli/recap.js +286 -2
  67. package/dist/cli/recap.js.map +1 -1
  68. package/dist/cli/skills-content/local-files.d.ts +1 -1
  69. package/dist/cli/skills-content/local-files.d.ts.map +1 -1
  70. package/dist/cli/skills-content/local-files.js +17 -12
  71. package/dist/cli/skills-content/local-files.js.map +1 -1
  72. package/dist/client/analytics.d.ts +8 -0
  73. package/dist/client/analytics.d.ts.map +1 -1
  74. package/dist/client/analytics.js +77 -13
  75. package/dist/client/analytics.js.map +1 -1
  76. package/dist/client/index.d.ts +2 -1
  77. package/dist/client/index.d.ts.map +1 -1
  78. package/dist/client/index.js +2 -1
  79. package/dist/client/index.js.map +1 -1
  80. package/dist/client/session-replay.d.ts +2 -0
  81. package/dist/client/session-replay.d.ts.map +1 -1
  82. package/dist/client/session-replay.js +7 -0
  83. package/dist/client/session-replay.js.map +1 -1
  84. package/dist/client/settings/DemoModeSection.d.ts.map +1 -1
  85. package/dist/client/settings/DemoModeSection.js +4 -23
  86. package/dist/client/settings/DemoModeSection.js.map +1 -1
  87. package/dist/client/url-scrub.d.ts.map +1 -1
  88. package/dist/client/url-scrub.js +14 -0
  89. package/dist/client/url-scrub.js.map +1 -1
  90. package/dist/client/use-demo-mode-status.d.ts +12 -0
  91. package/dist/client/use-demo-mode-status.d.ts.map +1 -0
  92. package/dist/client/use-demo-mode-status.js +30 -0
  93. package/dist/client/use-demo-mode-status.js.map +1 -0
  94. package/dist/collab/struct-routes.d.ts +1 -1
  95. package/dist/notifications/routes.d.ts +3 -3
  96. package/dist/observability/routes.d.ts +5 -5
  97. package/dist/progress/routes.d.ts +1 -1
  98. package/dist/resources/handlers.d.ts +3 -3
  99. package/dist/secrets/routes.d.ts +9 -9
  100. package/docs/content/locales/ar-SA/pr-visual-recap.mdx +25 -1
  101. package/docs/content/locales/ar-SA/template-plan.mdx +1 -1
  102. package/docs/content/locales/de-DE/pr-visual-recap.mdx +25 -1
  103. package/docs/content/locales/de-DE/template-plan.mdx +1 -1
  104. package/docs/content/locales/es-ES/pr-visual-recap.mdx +25 -1
  105. package/docs/content/locales/es-ES/template-plan.mdx +1 -1
  106. package/docs/content/locales/fr-FR/pr-visual-recap.mdx +25 -1
  107. package/docs/content/locales/fr-FR/template-plan.mdx +1 -1
  108. package/docs/content/locales/hi-IN/pr-visual-recap.mdx +25 -1
  109. package/docs/content/locales/hi-IN/template-plan.mdx +1 -1
  110. package/docs/content/locales/ja-JP/pr-visual-recap.mdx +25 -1
  111. package/docs/content/locales/ja-JP/template-plan.mdx +1 -1
  112. package/docs/content/locales/ko-KR/pr-visual-recap.mdx +25 -1
  113. package/docs/content/locales/ko-KR/template-plan.mdx +1 -1
  114. package/docs/content/locales/pt-BR/pr-visual-recap.mdx +25 -1
  115. package/docs/content/locales/pt-BR/template-plan.mdx +1 -1
  116. package/docs/content/locales/zh-CN/pr-visual-recap.mdx +25 -1
  117. package/docs/content/locales/zh-CN/template-plan.mdx +1 -1
  118. package/docs/content/locales/zh-TW/pr-visual-recap.mdx +25 -1
  119. package/docs/content/locales/zh-TW/template-plan.mdx +1 -1
  120. package/docs/content/pr-visual-recap.mdx +50 -1
  121. package/docs/content/template-plan.mdx +1 -1
  122. package/package.json +1 -1
@@ -103,6 +103,8 @@ export const PR_VISUAL_RECAP_SETUP: string[] = [
103
103
  " VISUAL_RECAP_API_KEY (secret) + VISUAL_RECAP_AGENT=openai-compatible + VISUAL_RECAP_BASE_URL (variable) — use DeepSeek, Kimi, or any OpenAI-compatible API",
104
104
  " VISUAL_RECAP_MODEL (variable, required for openai-compatible) — provider model id; optional override for Claude/Codex",
105
105
  " VISUAL_RECAP_REASONING (variable) — reasoning depth (none|minimal|low|medium|high|xhigh; Codex only)",
106
+ ' VISUAL_RECAP_RUNS_ON (variable) — JSON hosted label or self-hosted label array; defaults to "ubuntu-latest"',
107
+ " VISUAL_RECAP_GATE_RUNS_ON (variable) — trusted same-repo authors only; plain single gate label; defaults to ubuntu-latest",
106
108
  " VISUAL_RECAP_SKILL_SOURCE=repo (variable) — pin CI to the repo-local visual-recap skill instead of latest bundled guidance",
107
109
  " VISUAL_RECAP_SECRET_SCAN=off|high-confidence|strict (variable) — default high-confidence; strict restores generic TOKEN/SECRET assignment suppression",
108
110
  " PLAN_RECAP_APP_URL (secret) — only when self-hosting the plan app (defaults to https://plan.agent-native.com)",
@@ -170,12 +172,22 @@ export function buildReusableCallerWorkflow(
170
172
  ref?: string;
171
173
  agent?: RecapAgentValue;
172
174
  model?: string;
175
+ runsOn?: string;
176
+ gateRunsOn?: string;
173
177
  } = {},
174
178
  ): string {
175
179
  const ref = (options.ref ?? "main").replace(/^@/, "");
176
180
  const agentValue =
177
181
  options.agent ?? "${{ vars.VISUAL_RECAP_AGENT || 'claude' }}";
178
182
  const modelValue = options.model ?? "${{ vars.VISUAL_RECAP_MODEL || '' }}";
183
+ const runsOnValue =
184
+ options.runsOn === undefined
185
+ ? "${{ vars.VISUAL_RECAP_RUNS_ON || '\"ubuntu-latest\"' }}"
186
+ : JSON.stringify(options.runsOn);
187
+ const gateRunsOnValue =
188
+ options.gateRunsOn === undefined
189
+ ? "${{ vars.VISUAL_RECAP_GATE_RUNS_ON || 'ubuntu-latest' }}"
190
+ : JSON.stringify(options.gateRunsOn);
179
191
  return (
180
192
  `name: PR Visual Recap\n` +
181
193
  `\n` +
@@ -210,6 +222,8 @@ export function buildReusableCallerWorkflow(
210
222
  ` reasoning: \${{ vars.VISUAL_RECAP_REASONING || '' }}\n` +
211
223
  ` skill-source: \${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}\n` +
212
224
  ` secret-scan: \${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}\n` +
225
+ ` runs-on: ${runsOnValue}\n` +
226
+ ` gate-runs-on: ${gateRunsOnValue}\n` +
213
227
  ` # cli-version: "latest" # pin to a specific @agent-native/core version\n` +
214
228
  ``
215
229
  );
@@ -226,6 +240,8 @@ export function writePrVisualRecapReusableCallerWorkflow(
226
240
  ref?: string;
227
241
  agent?: RecapAgentValue;
228
242
  model?: string;
243
+ runsOn?: string;
244
+ gateRunsOn?: string;
229
245
  } = {},
230
246
  ): WriteWorkflowResult {
231
247
  const dir = path.resolve(baseDir, ".github", "workflows");
@@ -236,6 +252,8 @@ export function writePrVisualRecapReusableCallerWorkflow(
236
252
  ref: options.ref,
237
253
  agent: options.agent,
238
254
  model: options.model,
255
+ runsOn: options.runsOn,
256
+ gateRunsOn: options.gateRunsOn,
239
257
  });
240
258
  if (fs.existsSync(file)) {
241
259
  const current = fs.readFileSync(file, "utf8");
@@ -414,6 +432,40 @@ function listGithubVariables(repo?: string): Map<string, string> | null {
414
432
  }
415
433
  }
416
434
 
435
+ function listGithubOrganizationVariables(
436
+ repo: string,
437
+ ): Map<string, string> | null {
438
+ const result = gh([
439
+ "api",
440
+ "--paginate",
441
+ "--slurp",
442
+ `repos/${repo}/actions/organization-variables?per_page=30`,
443
+ ]);
444
+ if (!result.ok) return null;
445
+ try {
446
+ const parsed = JSON.parse(result.stdout) as unknown;
447
+ const pages = Array.isArray(parsed) ? parsed : [parsed];
448
+ const out = new Map<string, string>();
449
+ for (const page of pages) {
450
+ if (!page || typeof page !== "object") continue;
451
+ const variables = (page as Record<string, unknown>).variables;
452
+ if (!Array.isArray(variables)) continue;
453
+ for (const variable of variables) {
454
+ if (!variable || typeof variable !== "object") continue;
455
+ const record = variable as Record<string, unknown>;
456
+ if (typeof record.name !== "string") continue;
457
+ out.set(
458
+ record.name,
459
+ typeof record.value === "string" ? record.value : "",
460
+ );
461
+ }
462
+ }
463
+ return out;
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+
417
469
  function setGithubSecret(
418
470
  name: string,
419
471
  value: string | undefined,
@@ -440,6 +492,63 @@ function setGithubVariable(
440
492
  return gh(args).ok ? "set" : "failed";
441
493
  }
442
494
 
495
+ export interface RecapRunner {
496
+ name: string;
497
+ status: string;
498
+ labels: string[];
499
+ }
500
+
501
+ function listGithubRunners(repo: string): RecapRunner[] | null {
502
+ const result = gh([
503
+ "api",
504
+ "--paginate",
505
+ "--slurp",
506
+ `repos/${repo}/actions/runners?per_page=100`,
507
+ ]);
508
+ if (!result.ok) return null;
509
+ try {
510
+ const parsed = JSON.parse(result.stdout) as unknown;
511
+ const pages = Array.isArray(parsed) ? parsed : [parsed];
512
+ const runners: RecapRunner[] = [];
513
+ for (const page of pages) {
514
+ if (!page || typeof page !== "object") continue;
515
+ const entries = (page as Record<string, unknown>).runners;
516
+ if (!Array.isArray(entries)) continue;
517
+ for (const entry of entries) {
518
+ if (!entry || typeof entry !== "object") continue;
519
+ const record = entry as Record<string, unknown>;
520
+ if (typeof record.name !== "string") continue;
521
+ const rawLabels = Array.isArray(record.labels) ? record.labels : [];
522
+ runners.push({
523
+ name: record.name,
524
+ status: typeof record.status === "string" ? record.status : "",
525
+ labels: rawLabels.flatMap((label) => {
526
+ if (typeof label === "string") return [label];
527
+ if (!label || typeof label !== "object") return [];
528
+ const name = (label as Record<string, unknown>).name;
529
+ return typeof name === "string" ? [name] : [];
530
+ }),
531
+ });
532
+ }
533
+ }
534
+ return runners;
535
+ } catch {
536
+ return null;
537
+ }
538
+ }
539
+
540
+ export function matchingRecapRunners(
541
+ runners: RecapRunner[],
542
+ requiredLabels: string[],
543
+ ): RecapRunner[] {
544
+ const required = requiredLabels.map((label) => label.toLowerCase());
545
+ return runners.filter((runner) => {
546
+ if (runner.status.toLowerCase() !== "online") return false;
547
+ const labels = new Set(runner.labels.map((label) => label.toLowerCase()));
548
+ return required.every((label) => labels.has(label));
549
+ });
550
+ }
551
+
443
552
  export interface RecapSetupPlan {
444
553
  agent: RecapAgent;
445
554
  appUrl: string;
@@ -454,7 +563,11 @@ export interface RecapSetupPlan {
454
563
  }
455
564
 
456
565
  export interface RecapVariableRequirement {
457
- name: "VISUAL_RECAP_BASE_URL" | "VISUAL_RECAP_MODEL";
566
+ name:
567
+ | "VISUAL_RECAP_BASE_URL"
568
+ | "VISUAL_RECAP_MODEL"
569
+ | "VISUAL_RECAP_RUNS_ON"
570
+ | "VISUAL_RECAP_GATE_RUNS_ON";
458
571
  example: string;
459
572
  }
460
573
 
@@ -471,6 +584,113 @@ const OPENAI_COMPATIBLE_VARIABLE_REQUIREMENTS = [
471
584
  { name: "VISUAL_RECAP_MODEL", example: "provider-model-id" },
472
585
  ] as const satisfies readonly RecapVariableRequirement[];
473
586
 
587
+ const RECAP_RUNS_ON_REQUIREMENT = {
588
+ name: "VISUAL_RECAP_RUNS_ON",
589
+ example: '["self-hosted","linux","x64","visual-recap"]',
590
+ } as const satisfies RecapVariableRequirement;
591
+
592
+ const RECAP_GATE_RUNS_ON_REQUIREMENT = {
593
+ name: "VISUAL_RECAP_GATE_RUNS_ON",
594
+ example: "visual-recap-gate",
595
+ } as const satisfies RecapVariableRequirement;
596
+
597
+ export interface RecapRunsOnConfig {
598
+ json: string;
599
+ labels: string[];
600
+ selfHosted: boolean;
601
+ }
602
+
603
+ /** Parse the JSON consumed by GitHub Actions `fromJSON(...)` for `runs-on`. */
604
+ export function parseRecapRunsOn(value: string): RecapRunsOnConfig {
605
+ let parsed: unknown;
606
+ try {
607
+ parsed = JSON.parse(value);
608
+ } catch {
609
+ throw new Error(
610
+ 'VISUAL_RECAP_RUNS_ON must be valid JSON, such as "ubuntu-latest" or ["self-hosted","linux","x64"]',
611
+ );
612
+ }
613
+
614
+ if (typeof parsed === "string") {
615
+ if (!/^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(parsed)) {
616
+ throw new Error(
617
+ "VISUAL_RECAP_RUNS_ON JSON strings must name a standard GitHub-hosted ubuntu-, windows-, or macos- runner",
618
+ );
619
+ }
620
+ return {
621
+ json: JSON.stringify(parsed),
622
+ labels: [parsed],
623
+ selfHosted: false,
624
+ };
625
+ }
626
+
627
+ if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > 20) {
628
+ throw new Error(
629
+ "VISUAL_RECAP_RUNS_ON must be a hosted runner JSON string or an array of 1-20 self-hosted labels",
630
+ );
631
+ }
632
+ if (
633
+ parsed.some(
634
+ (label) =>
635
+ typeof label !== "string" ||
636
+ label.length === 0 ||
637
+ label.length > 100 ||
638
+ /[\p{C}]/u.test(label),
639
+ )
640
+ ) {
641
+ throw new Error(
642
+ "VISUAL_RECAP_RUNS_ON labels must be non-empty strings up to 100 characters without control characters",
643
+ );
644
+ }
645
+ const labels = parsed as string[];
646
+ if (!labels.includes("self-hosted")) {
647
+ throw new Error(
648
+ 'VISUAL_RECAP_RUNS_ON label arrays must include the exact "self-hosted" label',
649
+ );
650
+ }
651
+ if (
652
+ new Set(labels.map((label) => label.toLowerCase())).size !== labels.length
653
+ ) {
654
+ throw new Error("VISUAL_RECAP_RUNS_ON labels must be unique");
655
+ }
656
+ return { json: JSON.stringify(labels), labels, selfHosted: true };
657
+ }
658
+
659
+ /** Validate the plain label used directly by the gate job's `runs-on`. */
660
+ export function parseRecapGateRunsOn(value: string): string {
661
+ const label = value.trim();
662
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(label)) {
663
+ throw new Error(
664
+ "VISUAL_RECAP_GATE_RUNS_ON must be one plain runner label (1-100 letters, numbers, dots, underscores, or hyphens)",
665
+ );
666
+ }
667
+ return label;
668
+ }
669
+
670
+ function recapRunsOnProblem(value: string): RecapVariableProblem | null {
671
+ try {
672
+ parseRecapRunsOn(value);
673
+ return null;
674
+ } catch (error) {
675
+ return {
676
+ requirement: RECAP_RUNS_ON_REQUIREMENT,
677
+ reason: error instanceof Error ? error.message : String(error),
678
+ };
679
+ }
680
+ }
681
+
682
+ function recapGateRunsOnProblem(value: string): RecapVariableProblem | null {
683
+ try {
684
+ parseRecapGateRunsOn(value);
685
+ return null;
686
+ } catch (error) {
687
+ return {
688
+ requirement: RECAP_GATE_RUNS_ON_REQUIREMENT,
689
+ reason: error instanceof Error ? error.message : String(error),
690
+ };
691
+ }
692
+ }
693
+
474
694
  const RECAP_MODEL_PATTERN = /^[a-zA-Z0-9._-]{1,80}$/;
475
695
  const OPENAI_COMPATIBLE_RECAP_MODEL_PATTERN = /^[^\p{C}\p{Z}]{1,200}$/u;
476
696
 
@@ -517,6 +737,8 @@ export function buildRecapSetupPlan(input: {
517
737
  appUrl?: string;
518
738
  agent?: string;
519
739
  repo?: string;
740
+ runsOn?: string;
741
+ gateRunsOn?: string;
520
742
  env?: NodeJS.ProcessEnv;
521
743
  }): RecapSetupPlan {
522
744
  const env = input.env ?? process.env;
@@ -551,13 +773,28 @@ export function buildRecapSetupPlan(input: {
551
773
  const baseUrl = envValue(env, "VISUAL_RECAP_BASE_URL");
552
774
  if (baseUrl) variableValues.VISUAL_RECAP_BASE_URL = baseUrl;
553
775
  }
554
- const variableProblems =
776
+ const variableProblems: RecapVariableProblem[] =
555
777
  agent === "openai-compatible"
556
778
  ? validateOpenAiCompatibleRecapVariables({
557
779
  baseUrl: variableValues.VISUAL_RECAP_BASE_URL,
558
780
  model: variableValues.VISUAL_RECAP_MODEL,
559
781
  })
560
782
  : [];
783
+ const runsOn = input.runsOn ?? envValue(env, "VISUAL_RECAP_RUNS_ON");
784
+ if (runsOn) {
785
+ const problem = recapRunsOnProblem(runsOn);
786
+ if (problem) variableProblems.push(problem);
787
+ else variableValues.VISUAL_RECAP_RUNS_ON = parseRecapRunsOn(runsOn).json;
788
+ }
789
+ const gateRunsOn =
790
+ input.gateRunsOn ?? envValue(env, "VISUAL_RECAP_GATE_RUNS_ON");
791
+ if (gateRunsOn) {
792
+ const problem = recapGateRunsOnProblem(gateRunsOn);
793
+ if (problem) variableProblems.push(problem);
794
+ else
795
+ variableValues.VISUAL_RECAP_GATE_RUNS_ON =
796
+ parseRecapGateRunsOn(gateRunsOn);
797
+ }
561
798
  return {
562
799
  agent,
563
800
  appUrl,
@@ -596,7 +833,19 @@ function runSetup(args: Record<string, string | boolean>): void {
596
833
  appUrl: optionalArg(args, "app-url"),
597
834
  agent: optionalArg(args, "agent"),
598
835
  repo,
836
+ runsOn: optionalArg(args, "runs-on"),
837
+ gateRunsOn: optionalArg(args, "gate-runs-on"),
599
838
  });
839
+ const runnerProblem = plan.variableProblems.find(
840
+ (problem) =>
841
+ problem.requirement.name === "VISUAL_RECAP_RUNS_ON" ||
842
+ problem.requirement.name === "VISUAL_RECAP_GATE_RUNS_ON",
843
+ );
844
+ if (runnerProblem) {
845
+ process.stderr.write(`recap setup: ${runnerProblem.reason}.\n`);
846
+ process.exitCode = 1;
847
+ return;
848
+ }
600
849
  const lines = [
601
850
  reusable
602
851
  ? "PR Visual Recap setup (reusable workflow)"
@@ -616,6 +865,8 @@ function runSetup(args: Record<string, string | boolean>): void {
616
865
  force,
617
866
  ref: optionalArg(args, "ref") ?? "main",
618
867
  agent: plan.agent !== "claude" ? plan.agent : undefined,
868
+ runsOn: plan.variableValues.VISUAL_RECAP_RUNS_ON,
869
+ gateRunsOn: plan.variableValues.VISUAL_RECAP_GATE_RUNS_ON,
619
870
  });
620
871
  if (result.status === "refused") {
621
872
  process.stderr.write(`recap setup: ${result.message}\n`);
@@ -736,6 +987,12 @@ function runDoctor(args: Record<string, string | boolean>): void {
736
987
  const baseDir = process.cwd();
737
988
  const repo = resolveGithubRepo(optionalArg(args, "repo"));
738
989
  const variables = listGithubVariables(repo);
990
+ if (variables && repo) {
991
+ const organizationVariables = listGithubOrganizationVariables(repo);
992
+ for (const [name, value] of organizationVariables ?? []) {
993
+ if (!variables.has(name)) variables.set(name, value);
994
+ }
995
+ }
739
996
  const agent = normalizeRecapAgent(
740
997
  optionalArg(args, "agent") ??
741
998
  variables?.get("VISUAL_RECAP_AGENT") ??
@@ -843,6 +1100,90 @@ function runDoctor(args: Record<string, string | boolean>): void {
843
1100
  );
844
1101
  }
845
1102
  }
1103
+
1104
+ const configuredRunsOn = variables.get("VISUAL_RECAP_RUNS_ON")?.trim();
1105
+ if (!configuredRunsOn) {
1106
+ lines.push('[ok] Recap runner: "ubuntu-latest".');
1107
+ } else {
1108
+ const problem = recapRunsOnProblem(configuredRunsOn);
1109
+ if (problem) {
1110
+ ok = false;
1111
+ lines.push(`[invalid] ${problem.reason}.`);
1112
+ lines.push(
1113
+ ` Set it with: ${commandForMissingVariable(
1114
+ RECAP_RUNS_ON_REQUIREMENT.name,
1115
+ RECAP_RUNS_ON_REQUIREMENT.example,
1116
+ repo,
1117
+ )}`,
1118
+ );
1119
+ } else {
1120
+ const runsOn = parseRecapRunsOn(configuredRunsOn);
1121
+ lines.push(`[ok] Recap runner configuration: ${runsOn.json}.`);
1122
+ if (runsOn.selfHosted && repo) {
1123
+ const runners = listGithubRunners(repo);
1124
+ if (!runners) {
1125
+ lines.push(
1126
+ "[warn] Could not verify self-hosted runners with gh; repository Administration read access is required.",
1127
+ );
1128
+ } else {
1129
+ const matches = matchingRecapRunners(runners, runsOn.labels);
1130
+ if (matches.length === 0) {
1131
+ ok = false;
1132
+ lines.push(
1133
+ `[missing] No online self-hosted runner matches: ${runsOn.labels.join(", ")}.`,
1134
+ );
1135
+ } else {
1136
+ lines.push(
1137
+ `[ok] Matching online self-hosted runner: ${matches.map((runner) => runner.name).join(", ")}.`,
1138
+ );
1139
+ }
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ const configuredGateRunsOn =
1146
+ variables.get("VISUAL_RECAP_GATE_RUNS_ON")?.trim() || "ubuntu-latest";
1147
+ const gateProblem = recapGateRunsOnProblem(configuredGateRunsOn);
1148
+ if (gateProblem) {
1149
+ ok = false;
1150
+ lines.push(`[invalid] ${gateProblem.reason}.`);
1151
+ lines.push(
1152
+ ` Set it with: ${commandForMissingVariable(
1153
+ RECAP_GATE_RUNS_ON_REQUIREMENT.name,
1154
+ RECAP_GATE_RUNS_ON_REQUIREMENT.example,
1155
+ repo,
1156
+ )}`,
1157
+ );
1158
+ } else {
1159
+ const gateLabel = parseRecapGateRunsOn(configuredGateRunsOn);
1160
+ lines.push(
1161
+ `[ok] Gate runner label for trusted same-repo authors: ${gateLabel}.`,
1162
+ );
1163
+ if (
1164
+ !/^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(gateLabel) &&
1165
+ repo
1166
+ ) {
1167
+ const runners = listGithubRunners(repo);
1168
+ if (!runners) {
1169
+ lines.push(
1170
+ "[warn] Could not verify the gate runner with gh; repository Administration read access is required.",
1171
+ );
1172
+ } else {
1173
+ const matches = matchingRecapRunners(runners, [gateLabel]);
1174
+ if (matches.length === 0) {
1175
+ ok = false;
1176
+ lines.push(
1177
+ `[missing] No online self-hosted gate runner matches: ${gateLabel}.`,
1178
+ );
1179
+ } else {
1180
+ lines.push(
1181
+ `[ok] Matching online gate runner: ${matches.map((runner) => runner.name).join(", ")}.`,
1182
+ );
1183
+ }
1184
+ }
1185
+ }
1186
+ }
846
1187
  }
847
1188
 
848
1189
  process.stdout.write(`${lines.join("\n")}\n`);
@@ -4466,7 +4807,7 @@ function runAgentSummary(args: Record<string, string | boolean>): void {
4466
4807
  const HELP = `npx @agent-native/core@latest recap — PR visual recap helpers (used by the GitHub Action)
4467
4808
 
4468
4809
  Usage:
4469
- npx @agent-native/core@latest recap setup [--repo owner/name] [--agent claude|codex|openai-compatible] [--app-url <url>] [--skip-secrets] [--dry-run] [--force]
4810
+ npx @agent-native/core@latest recap setup [--repo owner/name] [--agent claude|codex|openai-compatible] [--app-url <url>] [--runs-on <json>] [--gate-runs-on <label>] [--skip-secrets] [--dry-run] [--force]
4470
4811
  npx @agent-native/core@latest recap doctor [--repo owner/name] [--agent claude|codex|openai-compatible] [--app-url <url>]
4471
4812
  npx @agent-native/core@latest recap collect-diff --base <baseSha> --head <headSha> [--out recap.diff] [--stat recap.stat]
4472
4813
  npx @agent-native/core@latest recap block-reference [--app-url <url>] [--out recap-blocks.md]
@@ -4519,11 +4860,22 @@ Usage:
4519
4860
  Write/refresh .github/workflows/pr-visual-recap.yml, then configure GitHub
4520
4861
  Actions secrets and variables with gh when values are available from env or
4521
4862
  the local Plans publish-token store. Missing values are printed as exact next
4522
- commands; secret values are sent to gh through stdin, never argv.
4863
+ commands; secret values are sent to gh through stdin, never argv. Pass
4864
+ --runs-on '["self-hosted","linux","x64","visual-recap"]' to opt into a
4865
+ trusted self-hosted runner label set. In self-hosted-only repos, pass
4866
+ --gate-runs-on visual-recap-gate for a dedicated, preferably ephemeral,
4867
+ single-label gate runner. It is used only for trusted same-repo OWNER,
4868
+ MEMBER, or COLLABORATOR authors. The stock gate does not check out the PR
4869
+ tree; it evaluates workflow logic and PR metadata. Fork and untrusted PRs
4870
+ use GitHub-hosted ubuntu-latest instead, so they may remain unscheduled when
4871
+ a repository disables GitHub-hosted runners.
4523
4872
  npx @agent-native/core@latest recap doctor
4524
4873
  Check workflow presence/drift, local Plans publish-token availability, gh
4525
4874
  repo access, and required GitHub Actions secrets and variables for the
4526
4875
  selected backend, including provider-variable validity.
4876
+ Self-hosted runner JSON and the plain gate runner label are validated, and
4877
+ matching online runners are checked when the GitHub token has repository
4878
+ Administration read access.
4527
4879
  `;
4528
4880
 
4529
4881
  export async function runRecap(argv: string[]): Promise<void> {
@@ -36,9 +36,9 @@ The local-files contract:
36
36
  — encode multiline code as JSON string attributes such as \`code={"const x =\\n y"}\`
37
37
  (a static template literal is accepted only when it has no \`\${...}\`
38
38
  interpolation). \`plan local check\` is a quick OFFLINE lint (a subset of the
39
- renderer schema), so a green \`check\` does not guarantee the plan renders;
40
- \`plan local verify\` is the authoritative validation against the real renderer
41
- schema.
39
+ renderer schema), so a green \`check\` does not guarantee the plan renders.
40
+ \`plan local verify\` also stays on-device: it uses the offline lint unless it
41
+ can reach a Plan renderer on an explicit loopback \`--app-url\`.
42
42
  - **Write a local MDX folder.** Use \`plans/<slug>/\` to check the artifact into the
43
43
  repo, or a repo-ignored/temporary folder such as \`.agent-native/plans/<slug>/\`
44
44
  or \`/tmp/agent-native-plans/<slug>/\` when it should not be checked in. The
@@ -53,7 +53,10 @@ The local-files contract:
53
53
  (use \`--kind plan\` for plans, \`--kind recap\` for recaps). Report the local
54
54
  bridge URL from stdout or \`<plan-dir>/.plan-url\`; treat \`.plan-url\` as a local
55
55
  token file and do not commit it. The URL opens the hosted Plan UI but reads from
56
- the localhost bridge on this machine, so it is not shareable across machines. On
56
+ the localhost bridge on this machine, so it is not shareable across machines.
57
+ The token is carried in the URL fragment (which is not sent to the hosted
58
+ origin), and the local-plan route disables DOM autocapture and session replay
59
+ while retaining sanitized pageviews and error monitoring. On
57
60
  macOS \`--open\` prefers Chromium browsers; if Safari opens, switch to
58
61
  Chrome/Chromium because Safari can block the hosted HTTPS page from fetching the
59
62
  HTTP localhost bridge. If the Plan app itself is running locally with the same
@@ -63,14 +66,16 @@ The local-files contract:
63
66
  running local Plan app.
64
67
  - **Headless verify.** Run
65
68
  \`npx @agent-native/core@latest plan local verify --dir <plan-dir> --kind <plan|recap>\`.
66
- It starts the bridge, checks the private-network preflight and JSON payload, AND
67
- validates the content against the real renderer schema via the Plan app's
68
- \`validate-local-plan-source\` action. A non-\`ok\` result with
69
+ It starts the bridge and checks the private-network preflight and JSON payload
70
+ entirely on loopback. It never sends MDX or assets to a remote validation
71
+ action. When \`--app-url\` points to a loopback Plan app, verify also validates
72
+ against that local app's real renderer schema via \`validate-local-plan-source\`.
73
+ A non-\`ok\` result with
69
74
  \`validation.valid: false\` lists the renderer's exact schema-path issues (e.g.
70
75
  \`blocks[1].data.tabs[0]...\`); fix those before handing off. If \`validation.ran\`
71
- is \`false\`, the Plan app did not expose the validate endpoint (older/unreachable
72
- deploy) point \`--app-url\` at a current Plan app (e.g. a local
73
- \`http://localhost:8096\`) for the authoritative check. If the browser hangs on
76
+ is \`false\`, verify used the offline lint because the app URL was remote or the
77
+ local Plan app was unavailable. Run a local Plan app and pass
78
+ \`--app-url http://localhost:8096\` for the authoritative check. If the browser hangs on
74
79
  "Loading plan", fetch the \`bridgeUrl\` from the verify/serve JSON to read the
75
80
  concrete validation error.
76
81
  - **Never call hosted tools for that plan/recap.** Do not call
@@ -86,8 +91,8 @@ The local-files contract:
86
91
  attachment, and publish/export receipts are unavailable until the user
87
92
  explicitly opts into publishing.
88
93
 
89
- Local-files mode only prevents plan/recap content from reaching the Agent-Native
90
- Plan database. It does not by itself make the coding agent's language model local;
94
+ Local-files mode prevents plan/recap content from being uploaded to the
95
+ Agent-Native Plan server or database. It does not by itself make the coding agent's language model local;
91
96
  for that stronger boundary the host agent/model must also be local or otherwise
92
97
  approved by the user.
93
98