@agent-native/recap-cli 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"pr-visual-recap-workflow.js","sourceRoot":"","sources":["../src/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,MAAM,CAAC,MAAM,4BAA4B,GACvC,4mjDAA4mjD,CAAC","sourcesContent":["/** Canonical PR Visual Recap workflow bundled by the CLI installer. */\nexport const PR_VISUAL_RECAP_WORKFLOW_YML =\n '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_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \\'\\' }}\\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 # A custom plain-label runner is allowed only for trusted same-repo authors.\\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\\n # any step starts. The only fromJSON input is this static association list.\\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON(\\'[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]\\'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || \\'ubuntu-latest\\') || \\'ubuntu-latest\\' }}\\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 runs_on: ${{ steps.decide.outputs.runs_on }}\\n steps:\\n - id: decide\\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\\n env:\\n # Presence-only signals — never expose secret VALUES to the gate.\\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \\'\\' }}\\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \\'\\' }}\\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \\'\\' }}\\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != \\'\\' }}\\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || \\'\"ubuntu-latest\"\\' }}\\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\\n with:\\n script: |\\n const pr = context.payload.pull_request;\\n const reasons = [];\\n\\n if (!pr) reasons.push(\\'no pull_request payload\\');\\n if (pr && pr.draft) reasons.push(\\'draft PR\\');\\n if (pr && context.payload.action === \\'closed\\' && !pr.merged) {\\n reasons.push(\\'closed without merge\\');\\n }\\n\\n // Fork PRs only receive repo secrets when the org/repo opts into\\n // GitHub\\'s \"Send secrets to workflows from pull requests\" setting\\n // (common in private orgs that use forks heavily). Gate on secret\\n // availability, not fork-ness: run on forks that have the token,\\n // and skip — with an actionable hint — those that don\\'t.\\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\\n const association = (pr && pr.author_association || \\'\\').toUpperCase();\\n const trustedAssociations = [\\'OWNER\\', \\'MEMBER\\', \\'COLLABORATOR\\'];\\n const isTrustedAuthor = trustedAssociations.includes(association);\\n let configuredRunner = \\'ubuntu-latest\\';\\n let usesSelfHostedRunner = false;\\n try {\\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || \\'\"ubuntu-latest\"\\');\\n const hosted = typeof candidate === \\'string\\' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes(\\'self-hosted\\') && candidate.every((label) => typeof label === \\'string\\' && label.length >= 1 && label.length <= 100 && !/[\\\\u0000-\\\\u001f\\\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\\n if (!hosted && !selfHosted) throw new Error(\\'unsupported runner value\\');\\n configuredRunner = candidate;\\n usesSelfHostedRunner = selfHosted;\\n } catch {\\n reasons.push(\\'invalid VISUAL_RECAP_RUNS_ON JSON\\');\\n }\\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\\n reasons.push(\\'self-hosted runner mode requires a trusted same-repository PR author\\');\\n }\\n if (isFork && process.env.HAS_PLAN !== \\'true\\') {\\n reasons.push(`fork PR (${headRepo}) without secret access — enable \"Send secrets to workflows from pull requests\" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\\n }\\n\\n const login = (pr && pr.user && pr.user.login || \\'\\').toLowerCase();\\n const botAuthors = [\\'dependabot[bot]\\', \\'dependabot\\', \\'renovate[bot]\\', \\'renovate\\'];\\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\\n if (pr && pr.user && pr.user.type === \\'Bot\\') reasons.push(\\'bot author (type=Bot)\\');\\n\\n if (!isFork && process.env.HAS_PLAN !== \\'true\\') reasons.push(\\'PLAN_RECAP_TOKEN not configured\\');\\n\\n // Normalize + validate the agent so a mis-cased value can\\'t pass the\\n // gate and then match neither agent step below.\\n const rawAgent = (process.env.AGENT || \\'claude\\').toLowerCase();\\n const agent = [\\'deepseek\\', \\'kimi\\', \\'moonshot\\', \\'custom\\'].includes(rawAgent) ? \\'openai-compatible\\' : rawAgent;\\n if (![\\'claude\\', \\'codex\\', \\'openai-compatible\\'].includes(agent)) {\\n reasons.push(`unsupported VISUAL_RECAP_AGENT \"${process.env.AGENT}\" (expected \"claude\", \"codex\", or \"openai-compatible\")`);\\n } else if (agent === \\'codex\\') {\\n if (process.env.HAS_OPENAI !== \\'true\\') reasons.push(\\'OPENAI_API_KEY not configured (codex backend)\\');\\n } else if (agent === \\'claude\\') {\\n if (process.env.HAS_ANTHROPIC !== \\'true\\') reasons.push(\\'ANTHROPIC_API_KEY not configured (claude backend)\\');\\n } else {\\n if (process.env.HAS_COMPATIBLE !== \\'true\\') reasons.push(\\'VISUAL_RECAP_API_KEY not configured (openai-compatible backend)\\');\\n if (!(process.env.VISUAL_RECAP_MODEL || \\'\\').trim()) reasons.push(\\'VISUAL_RECAP_MODEL is required (openai-compatible backend)\\');\\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || \\'\\';\\n try {\\n const parsed = new URL(baseUrl);\\n if (![\\'http:\\', \\'https:\\'].includes(parsed.protocol) || parsed.username || parsed.password) {\\n reasons.push(\\'VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials\\');\\n }\\n } catch {\\n reasons.push(\\'VISUAL_RECAP_BASE_URL must be a valid http(s) URL\\');\\n }\\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 untrusted forks and untrusted public-repo PRs.\\n // Trusted write actors may edit recap-control files as normal\\n // reviewable content; running the recap is useful signal for those\\n // changes.\\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\\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 p.startsWith(\\'.claude/\\') ||\\n p === \\'CLAUDE.md\\' ||\\n p === \\'AGENTS.md\\' ||\\n p === \\'.mcp.json\\';\\n const hits = files.map((f) => f.filename).filter(isSensitive);\\n if (hits.length) {\\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\\', \\')}${hits.length > 3 ? \\', …\\' : \\'\\'}) — skipping so untrusted PR code never runs with secrets`);\\n }\\n } catch (e) {\\n // Fail closed: if the file list can\\'t be read, skip.\\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\\n }\\n }\\n\\n const run = reasons.length === 0;\\n core.setOutput(\\'run\\', run ? \\'true\\' : \\'false\\');\\n core.setOutput(\\'agent\\', agent);\\n core.setOutput(\\'runs_on\\', JSON.stringify(configuredRunner));\\n if (run) {\\n core.info(`Visual recap will run (${agent}).`);\\n } else {\\n // Surface the skip reason as a run-summary annotation, not just a\\n // buried info log, so it\\'s clear in the Actions UI why we skipped.\\n core.notice(`Visual recap skipped: ${reasons.join(\\'; \\')}`);\\n }\\n\\n // When skipping, upsert a sticky recap comment with a short skip\\n // line so the PR always explains why the recap job did not run.\\n if (!run && pr) {\\n try {\\n const MARKER = \\'<!-- pr-visual-recap -->\\';\\n const { data: comments } = await github.rest.issues.listComments({\\n owner: context.repo.owner,\\n repo: context.repo.repo,\\n issue_number: pr.number,\\n per_page: 100,\\n });\\n const existing = comments.find(\\n (c) => c.user && c.user.type === \\'Bot\\' && c.body && c.body.includes(MARKER)\\n );\\n const headShort = (process.env.HEAD_SHA || \\'\\').slice(0, 7);\\n const shaRef = headShort ? `\\\\`${headShort}\\\\`` : \\'latest push\\';\\n const primaryReason = reasons.filter(\\n (r) => !r.startsWith(\\'could not list PR files for the self-modifying guard\\')\\n )[0] || reasons[0] || \\'skipped\\';\\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\\n const baseBody = `${MARKER}\\\\n### Visual recap — skipped\\\\n\\\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\\n const planIdMatch = (existing && existing.body ? existing.body : \\'\\').match(/<!--\\\\s*plan-id:\\\\s*([A-Za-z0-9_-]{1,64})\\\\s*-->/);\\n const planIdMarker = planIdMatch ? `\\\\n\\\\n<!-- plan-id: ${planIdMatch[1]} -->` : \\'\\';\\n const updatedBody = `${baseBody}${planIdMarker}\\\\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: ${{ fromJSON(needs.gate.outputs.runs_on) }}\\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_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \\'\\' }}\\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 pnpm --filter @agent-native/recap-cli build\\n echo \"RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\\n echo \"CODE_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/recap-cli@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 --ignore-scripts \"@agent-native/recap-cli@$VERSION\"; then\\n break\\n fi\\n if [ \"$attempt\" = \"3\" ]; then exit 1; fi\\n sleep $((attempt * 10))\\n done\\n echo \"RECAP_CLI_VERSION=$VERSION\" >> \"$GITHUB_ENV\"\\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: Install OpenAI-compatible provider runtime\\n if: needs.gate.outputs.agent == \\'openai-compatible\\' && steps.cli.outputs.local != \\'true\\'\\n env:\\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || \\'latest\\' }}\\n run: |\\n set -euo pipefail\\n CORE_VERSION=\"$CORE_CLI_VERSION\"\\n if [ \"$CORE_VERSION\" = \"latest\" ]; then\\n CORE_VERSION=\"$(npm view @agent-native/core@latest version)\"\\n fi\\n npm install --prefix \"$RUNNER_TEMP/recap-code\" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai \"@agent-native/core@$CORE_VERSION\"\\n echo \"CORE_CLI_VERSION=$CORE_VERSION\" >> \"$GITHUB_ENV\"\\n echo \"CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native\" >> \"$GITHUB_ENV\"\\n\\n - name: Start visual recap check\\n id: recap_check\\n continue-on-error: true\\n run: |\\n set -uo pipefail\\n $RECAP_CLI recap check start --sha \"$HEAD_SHA\" --workflow-url \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\"\\n\\n - name: Fetch pull request head\\n env:\\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\\n run: |\\n set -euo pipefail\\n if git cat-file -e \"${HEAD_SHA}^{commit}\" 2>/dev/null; then\\n git update-ref refs/recap/pr-head \"$HEAD_SHA\"\\n else\\n AUTH_B64=\"$(printf \\'x-access-token:%s\\' \"$GH_TOKEN\" | base64 | tr -d \\'\\\\n\\')\"\\n git -c \"http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64\" fetch origin \"pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head\"\\n fi\\n FETCHED_SHA=\"$(git rev-parse refs/recap/pr-head)\"\\n if [ \"$FETCHED_SHA\" != \"$HEAD_SHA\" ]; then\\n echo \"FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit\"\\n exit 1\\n fi\\n\\n - name: Collect bounded diff\\n id: diff\\n env:\\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\\n run: |\\n set -euo pipefail\\n $RECAP_CLI recap collect-diff --base \"$BASE_SHA\" --head refs/recap/pr-head --out recap.diff --stat recap.stat\\n\\n - name: Probe plan-app auth\\n id: auth_probe\\n if: steps.diff.outputs.tiny != \\'true\\'\\n continue-on-error: true\\n run: |\\n set -uo pipefail\\n # Hit the plan app\\'s action surface with the publish token. A 401 means\\n # the token is expired/revoked; surface it in the sticky comment so the\\n # repo owner knows to re-mint it instead of seeing a generic failure.\\n HTTP_STATUS=$(node -e \\'\\n const https = require(\"https\");\\n const url = new URL(\"/_agent-native/actions/record-recap-usage\", process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\");\\n const req = https.request(url, { method: \"POST\", headers: { \"authorization\": \"Bearer \" + process.env.PLAN_RECAP_TOKEN, \"content-type\": \"application/json\" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\\n req.on(\"error\", () => process.stdout.write(\"0\"));\\n req.end(JSON.stringify({ planId: \"__probe__\" }));\\n \\' 2>/dev/null || echo \"0\")\\n if [ \"$HTTP_STATUS\" = \"401\" ]; then\\n echo \"auth_failed=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"auth_failed=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n\\n - name: Probe plan-app route health\\n id: route_health\\n if: steps.diff.outputs.tiny != \\'true\\'\\n continue-on-error: true\\n run: |\\n set -uo pipefail\\n # Pre-publish health gate: confirm the plan app\\'s recap action routes\\n # are actually deployed BEFORE the agent runs. A 404 from\\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\\n # plan-app deploy has not propagated yet (the client is ahead of the\\n # deployed server). Say that plainly here instead of letting the agent\\n # run and then fail confusingly at publish time. A 401 or 200 is\\n # healthy — the route exists, it just rejected/accepted the probe.\\n probe_status() {\\n ROUTE=\"$1\" METHOD=\"$2\" node -e \\'\\n const https = require(\"https\");\\n const base = process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\";\\n const url = new URL(process.env.ROUTE, base);\\n if (process.env.METHOD === \"GET\") url.searchParams.set(\"format\", \"reference\");\\n const req = https.request(url, { method: process.env.METHOD, headers: { \"authorization\": \"Bearer \" + (process.env.PLAN_RECAP_TOKEN || \"\"), \"content-type\": \"application/json\" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\\n req.on(\"error\", () => process.stdout.write(\"0\"));\\n req.on(\"timeout\", () => { process.stdout.write(\"0\"); req.destroy(); });\\n if (process.env.METHOD === \"POST\") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\\n \\' 2>/dev/null || echo \"0\"\\n }\\n CREATE_STATUS=\"$(probe_status /_agent-native/actions/create-visual-recap POST)\"\\n BLOCKS_STATUS=\"$(probe_status /_agent-native/actions/get-plan-blocks GET)\"\\n REASON=\"\"\\n if [ \"$CREATE_STATUS\" = \"404\" ] || [ \"$BLOCKS_STATUS\" = \"404\" ]; then\\n REASON=\"Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating.\"\\n echo \"::error::$REASON\"\\n echo \"unhealthy=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"unhealthy=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n {\\n echo \\'reason<<__RECAP_ROUTE_HEALTH_EOF__\\'\\n echo \"$REASON\"\\n echo \\'__RECAP_ROUTE_HEALTH_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n\\n - name: Secret scan\\n id: scan\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\'\\n run: |\\n set -uo pipefail\\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\\n # credential-bearing diff is never handed to the agent / plan service.\\n if ! SCAN_JSON=\"$($RECAP_CLI recap scan --diff recap.diff --mode \"$VISUAL_RECAP_SECRET_SCAN\")\"; then\\n SCAN_JSON=\\'{\"suppressed\":true,\"reason\":\"secret scan failed to run; failing closed\"}\\'\\n fi\\n {\\n echo \\'json<<__RECAP_SCAN_EOF__\\'\\n echo \"$SCAN_JSON\"\\n echo \\'__RECAP_SCAN_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n SUPPRESSED=$(node -e \\'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?\"true\":\"false\")}catch{process.stdout.write(\"true\")}\\' \"$SCAN_JSON\")\\n echo \"suppressed=$SUPPRESSED\" >> \"$GITHUB_OUTPUT\"\\n\\n - name: Read previous plan id\\n id: prev\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\'\\n continue-on-error: true\\n run: |\\n set -euo pipefail\\n PLAN_ID=\"$($RECAP_CLI recap comment find-plan-id --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\")\"\\n echo \"plan_id=$PLAN_ID\" >> \"$GITHUB_OUTPUT\"\\n\\n - name: Fetch plan block reference\\n id: block_reference\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n continue-on-error: true\\n run: |\\n set -uo pipefail\\n if $RECAP_CLI recap block-reference --app-url \"$PLAN_RECAP_APP_URL\" --out recap-blocks.md; then\\n echo \"ok=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"ok=false\" >> \"$GITHUB_OUTPUT\"\\n {\\n echo \\'summary<<__RECAP_BLOCK_REFERENCE_EOF__\\'\\n echo \"Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the publisher will validate the final MDX.\"\\n echo \\'__RECAP_BLOCK_REFERENCE_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n cat > recap-blocks.md <<\\'EOF\\'\\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\\n EOF\\n fi\\n\\n - name: Build recap prompt\\n id: prompt\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n env:\\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\\n # the prev plan id is parsed from a PR comment and could inject shell.\\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\\n run: |\\n set -euo pipefail\\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr \"$PR_NUMBER\" --repo \"$GITHUB_REPOSITORY\" --head \"$HEAD_SHA\" --app-url \"$PLAN_RECAP_APP_URL\" --skill-source \"$VISUAL_RECAP_SKILL_SOURCE\" --out recap-prompt.md)\\n if [ \"${DIFF_HUGE:-}\" = \"true\" ]; then ARGS+=(--huge); fi\\n if [ \"${IS_FORK:-}\" = \"true\" ]; then ARGS+=(--fork-pr true); fi\\n if [ -n \"${PREV_PLAN_ID:-}\" ]; then ARGS+=(--prev-plan-id \"$PREV_PLAN_ID\"); fi\\n $RECAP_CLI recap build-prompt \"${ARGS[@]}\"\\n\\n - name: Run agent (Claude Code)\\n id: claude\\n if: needs.gate.outputs.agent == \\'claude\\' && steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n continue-on-error: true\\n env:\\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\\n run: |\\n set -uo pipefail\\n CLAUDE_ALLOWED_TOOLS=\"Read,Write,Bash(git diff:*)\"\\n CLAUDE_ARGS=(-p \"$(cat recap-prompt.md)\" --allowedTools \"$CLAUDE_ALLOWED_TOOLS\" --permission-mode dontAsk --output-format json)\\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CLAUDE_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\\n run_claude() {\\n set +e\\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-result.json 2> claude-stderr.log\\n CLAUDE_STATUS=\"$?\"\\n set -e\\n echo \"$CLAUDE_STATUS\" > claude-exit-code.txt\\n }\\n run_claude\\n # A clean agent exit WITHOUT recap-source.json is the strongest\\n # \"retry me\" signal — the deterministic publisher needs that file, and\\n # the agent occasionally finishes a turn without writing it. Retry once.\\n if [ ! -s recap-source.json ]; then\\n if grep -Eiq -- \\'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\\' claude-result.json claude-stderr.log 2>/dev/null; then\\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\\n else\\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\\n sleep 5\\n run_claude\\n fi\\n fi\\n\\n - name: Run agent (Codex)\\n id: codex\\n if: needs.gate.outputs.agent == \\'codex\\' && steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n continue-on-error: true\\n env:\\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\\n run: |\\n set -uo pipefail\\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\\n # The runner is itself an ephemeral sandbox; bypass Codex\\'s own sandbox\\n # (bubblewrap can\\'t init here) and approval gate (cancels the MCP write).\\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CODEX_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\\n # Validate reasoning against the enum before embedding it in the TOML override.\\n case \"${VISUAL_RECAP_REASONING:-}\" in\\n none|minimal|low|medium|high|xhigh)\\n CODEX_ARGS+=(-c \"model_reasoning_effort=\\\\\"$VISUAL_RECAP_REASONING\\\\\"\") ;;\\n \"\") ;;\\n *) echo \"Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING\" ;;\\n esac\\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\\n run_codex() {\\n set +e\\n npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-prompt.md)\" 2> codex-stderr.log | tee codex-events.jsonl\\n CODEX_STATUS=\"${PIPESTATUS[0]}\"\\n set -e\\n echo \"$CODEX_STATUS\" > codex-exit-code.txt\\n }\\n run_codex\\n # Retry once if the agent exited without writing recap-source.json\\n # (see the Claude step) — the publisher needs that file.\\n if [ ! -s recap-source.json ]; then\\n if grep -Eiq -- \\'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\\' codex-events.jsonl codex-stderr.log 2>/dev/null; then\\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\\n else\\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\\n sleep 5\\n run_codex\\n fi\\n fi\\n\\n - name: Run agent (OpenAI-compatible)\\n id: openai_compatible\\n if: needs.gate.outputs.agent == \\'openai-compatible\\' && 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.VISUAL_RECAP_API_KEY }}\\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\\n AGENT_ENGINE: ai-sdk:openai\\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\\n run: |\\n set -uo pipefail\\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\\n run_openai_compatible() {\\n set +e\\n $CODE_CLI code exec --permission-mode auto-edit \"$(cat recap-prompt.md)\" > openai-compatible-result.txt 2> openai-compatible-stderr.log\\n OPENAI_COMPATIBLE_STATUS=\"$?\"\\n set -e\\n echo \"$OPENAI_COMPATIBLE_STATUS\" > openai-compatible-exit-code.txt\\n }\\n run_openai_compatible\\n if [ ! -s recap-source.json ]; then\\n if grep -Eiq -- \\'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\\' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\\n else\\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\\n sleep 5\\n run_openai_compatible\\n fi\\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 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 elif [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then\\n RESULT=openai-compatible-result.txt\\n STDERR=openai-compatible-stderr.log\\n EXIT_CODE=openai-compatible-exit-code.txt\\n fi\\n SUMMARY_JSON=\"$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent \"$RECAP_AGENT\" --result-file \"$RESULT\" --stderr-file \"$STDERR\" --exit-code-file \"$EXIT_CODE\" || echo \\'{}\\')\"\\n SUMMARY=\"$(node -e \\'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === \"string\" ? value : \"\"); } catch {}\\' \"$SUMMARY_JSON\")\"\\n if [ -n \"$SUMMARY\" ]; then\\n {\\n echo \\'summary<<__RECAP_AGENT_SUMMARY_EOF__\\'\\n echo \"$SUMMARY\"\\n echo \\'__RECAP_AGENT_SUMMARY_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n elif [ -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 elif [ -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 fi\\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 [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then RESULT=openai-compatible-usage.json; 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 IMAGE_CACHE_KEY=\"$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT\"\\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 --image-cache-key \"$IMAGE_CACHE_KEY\" || 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 --image-cache-key \"$IMAGE_CACHE_KEY\" || 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 SHOT_STATUS=$(LIGHT_SHOT_JSON=\"$LIGHT_SHOT_JSON\" DARK_SHOT_JSON=\"$DARK_SHOT_JSON\" node <<\\'NODE\\'\\n const parse = (raw) => { try { return JSON.parse(raw || \"{}\"); } catch { return { ok: false, reason: \"invalid shot JSON\" }; } };\\n const shots = [[\"light\", parse(process.env.LIGHT_SHOT_JSON)], [\"dark\", parse(process.env.DARK_SHOT_JSON)]];\\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === \"string\" && shot.imageUrl.trim());\\n const reasons = shots.flatMap(([label, shot]) => {\\n if (typeof shot.reason === \"string\" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\\n if (!(typeof shot.imageUrl === \"string\" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\\n return [];\\n });\\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? \"\" : reasons.join(\"; \").slice(0, 1000) }));\\n NODE\\n )\\n SHOT_OK=$(node -e \\'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?\"true\":\"false\")}catch{process.stdout.write(\"false\")}\\' \"$SHOT_STATUS\")\\n SHOT_REASON=$(node -e \\'try{process.stdout.write(JSON.parse(process.argv[1]).reason||\"\")}catch{process.stdout.write(\"invalid shot status JSON\")}\\' \"$SHOT_STATUS\")\\n if [ \"$SHOT_OK\" != \"true\" ]; then\\n echo \"::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON\"\\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 echo \"shot_ok=$SHOT_OK\" >> \"$GITHUB_OUTPUT\"\\n {\\n echo \\'shot_reason<<__RECAP_SHOT_REASON_EOF__\\'\\n echo \"$SHOT_REASON\"\\n echo \\'__RECAP_SHOT_REASON_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo \"captured=true\" >> \"$GITHUB_OUTPUT\"; else echo \"captured=false\" >> \"$GITHUB_OUTPUT\"; fi\\n\\n - name: Upload recap screenshot artifact\\n if: steps.shot.outputs.captured == \\'true\\'\\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\\n with:\\n name: pr-visual-recap-${{ github.event.pull_request.number }}\\n path: |\\n recap.png\\n recap-dark.png\\n if-no-files-found: ignore\\n retention-days: 14\\n\\n - name: Upload recap source artifact\\n if: always() && !cancelled()\\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\\n with:\\n # recap-source.json + the agent transcript (claude-result.json /\\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\\n # did when a publish fails (no plan URL) — INCLUDING the case where it\\n # finished without writing recap-source.json at all. The sticky comment\\n # only shows the screenshot, so without these a failed recap is\\n # undebuggable. Uploaded on success + failure; tolerant when absent.\\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\\n path: |\\n recap-source.json\\n claude-result.json\\n claude-stderr.log\\n codex-events.jsonl\\n codex-stderr.log\\n openai-compatible-result.txt\\n openai-compatible-usage.json\\n openai-compatible-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 RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\\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';\n"]}
1
+ {"version":3,"file":"pr-visual-recap-workflow.js","sourceRoot":"","sources":["../src/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,MAAM,CAAC,MAAM,4BAA4B,GACvC,iljDAAiljD,CAAC","sourcesContent":["/** Canonical PR Visual Recap workflow bundled by the CLI installer. */\nexport const PR_VISUAL_RECAP_WORKFLOW_YML =\n '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_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \\'\\' }}\\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 # A custom plain-label runner is allowed only for trusted same-repo authors.\\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\\n # any step starts. The only fromJSON input is this static association list.\\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON(\\'[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]\\'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || \\'ubuntu-latest\\') || \\'ubuntu-latest\\' }}\\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 runs_on: ${{ steps.decide.outputs.runs_on }}\\n steps:\\n - id: decide\\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\\n env:\\n # Presence-only signals — never expose secret VALUES to the gate.\\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \\'\\' }}\\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \\'\\' }}\\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \\'\\' }}\\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != \\'\\' }}\\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || \\'\"ubuntu-latest\"\\' }}\\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\\n with:\\n script: |\\n const pr = context.payload.pull_request;\\n const reasons = [];\\n\\n if (!pr) reasons.push(\\'no pull_request payload\\');\\n if (pr && pr.draft) reasons.push(\\'draft PR\\');\\n if (pr && context.payload.action === \\'closed\\' && !pr.merged) {\\n reasons.push(\\'closed without merge\\');\\n }\\n\\n // Fork PRs only receive repo secrets when the org/repo opts into\\n // GitHub\\'s \"Send secrets to workflows from pull requests\" setting\\n // (common in private orgs that use forks heavily). Gate on secret\\n // availability, not fork-ness: run on forks that have the token,\\n // and skip — with an actionable hint — those that don\\'t.\\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\\n const association = (pr && pr.author_association || \\'\\').toUpperCase();\\n const trustedAssociations = [\\'OWNER\\', \\'MEMBER\\', \\'COLLABORATOR\\'];\\n const isTrustedAuthor = trustedAssociations.includes(association);\\n let configuredRunner = \\'ubuntu-latest\\';\\n let usesSelfHostedRunner = false;\\n try {\\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || \\'\"ubuntu-latest\"\\');\\n const hosted = typeof candidate === \\'string\\' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes(\\'self-hosted\\') && candidate.every((label) => typeof label === \\'string\\' && label.length >= 1 && label.length <= 100 && !/[\\\\u0000-\\\\u001f\\\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\\n if (!hosted && !selfHosted) throw new Error(\\'unsupported runner value\\');\\n configuredRunner = candidate;\\n usesSelfHostedRunner = selfHosted;\\n } catch {\\n reasons.push(\\'invalid VISUAL_RECAP_RUNS_ON JSON\\');\\n }\\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\\n reasons.push(\\'self-hosted runner mode requires a trusted same-repository PR author\\');\\n }\\n if (isFork && process.env.HAS_PLAN !== \\'true\\') {\\n reasons.push(`fork PR (${headRepo}) without secret access — enable \"Send secrets to workflows from pull requests\" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\\n }\\n\\n const login = (pr && pr.user && pr.user.login || \\'\\').toLowerCase();\\n const botAuthors = [\\'dependabot[bot]\\', \\'dependabot\\', \\'renovate[bot]\\', \\'renovate\\'];\\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\\n if (pr && pr.user && pr.user.type === \\'Bot\\') reasons.push(\\'bot author (type=Bot)\\');\\n\\n if (!isFork && process.env.HAS_PLAN !== \\'true\\') reasons.push(\\'PLAN_RECAP_TOKEN not configured\\');\\n\\n // Normalize + validate the agent so a mis-cased value can\\'t pass the\\n // gate and then match neither agent step below.\\n const rawAgent = (process.env.AGENT || \\'claude\\').toLowerCase();\\n const agent = [\\'deepseek\\', \\'kimi\\', \\'moonshot\\', \\'custom\\'].includes(rawAgent) ? \\'openai-compatible\\' : rawAgent;\\n if (![\\'claude\\', \\'codex\\', \\'openai-compatible\\'].includes(agent)) {\\n reasons.push(`unsupported VISUAL_RECAP_AGENT \"${process.env.AGENT}\" (expected \"claude\", \"codex\", or \"openai-compatible\")`);\\n } else if (agent === \\'codex\\') {\\n if (process.env.HAS_OPENAI !== \\'true\\') reasons.push(\\'OPENAI_API_KEY not configured (codex backend)\\');\\n } else if (agent === \\'claude\\') {\\n if (process.env.HAS_ANTHROPIC !== \\'true\\') reasons.push(\\'ANTHROPIC_API_KEY not configured (claude backend)\\');\\n } else {\\n if (process.env.HAS_COMPATIBLE !== \\'true\\') reasons.push(\\'VISUAL_RECAP_API_KEY not configured (openai-compatible backend)\\');\\n if (!(process.env.VISUAL_RECAP_MODEL || \\'\\').trim()) reasons.push(\\'VISUAL_RECAP_MODEL is required (openai-compatible backend)\\');\\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || \\'\\';\\n try {\\n const parsed = new URL(baseUrl);\\n if (![\\'http:\\', \\'https:\\'].includes(parsed.protocol) || parsed.username || parsed.password) {\\n reasons.push(\\'VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials\\');\\n }\\n } catch {\\n reasons.push(\\'VISUAL_RECAP_BASE_URL must be a valid http(s) URL\\');\\n }\\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 untrusted forks and untrusted public-repo PRs.\\n // Trusted write actors may edit recap-control files as normal\\n // reviewable content; running the recap is useful signal for those\\n // changes.\\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\\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 p.startsWith(\\'.claude/\\') ||\\n p === \\'CLAUDE.md\\' ||\\n p === \\'AGENTS.md\\' ||\\n p === \\'.mcp.json\\';\\n const hits = files.map((f) => f.filename).filter(isSensitive);\\n if (hits.length) {\\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\\', \\')}${hits.length > 3 ? \\', …\\' : \\'\\'}) — skipping so untrusted PR code never runs with secrets`);\\n }\\n } catch (e) {\\n // Fail closed: if the file list can\\'t be read, skip.\\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\\n }\\n }\\n\\n const run = reasons.length === 0;\\n core.setOutput(\\'run\\', run ? \\'true\\' : \\'false\\');\\n core.setOutput(\\'agent\\', agent);\\n core.setOutput(\\'runs_on\\', JSON.stringify(configuredRunner));\\n if (run) {\\n core.info(`Visual recap will run (${agent}).`);\\n } else {\\n // Surface the skip reason as a run-summary annotation, not just a\\n // buried info log, so it\\'s clear in the Actions UI why we skipped.\\n core.notice(`Visual recap skipped: ${reasons.join(\\'; \\')}`);\\n }\\n\\n // When skipping, upsert a sticky recap comment with a short skip\\n // line so the PR always explains why the recap job did not run.\\n if (!run && pr) {\\n try {\\n const MARKER = \\'<!-- pr-visual-recap -->\\';\\n const { data: comments } = await github.rest.issues.listComments({\\n owner: context.repo.owner,\\n repo: context.repo.repo,\\n issue_number: pr.number,\\n per_page: 100,\\n });\\n const existing = comments.find(\\n (c) => c.user && c.user.type === \\'Bot\\' && c.body && c.body.includes(MARKER)\\n );\\n const headShort = (process.env.HEAD_SHA || \\'\\').slice(0, 7);\\n const shaRef = headShort ? `\\\\`${headShort}\\\\`` : \\'latest push\\';\\n const primaryReason = reasons.filter(\\n (r) => !r.startsWith(\\'could not list PR files for the self-modifying guard\\')\\n )[0] || reasons[0] || \\'skipped\\';\\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\\n const baseBody = `${MARKER}\\\\n### Visual recap — skipped\\\\n\\\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\\n const planIdMatch = (existing && existing.body ? existing.body : \\'\\').match(/<!--\\\\s*plan-id:\\\\s*([A-Za-z0-9_-]{1,64})\\\\s*-->/);\\n const planIdMarker = planIdMatch ? `\\\\n\\\\n<!-- plan-id: ${planIdMatch[1]} -->` : \\'\\';\\n const updatedBody = `${baseBody}${planIdMarker}\\\\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: ${{ fromJSON(needs.gate.outputs.runs_on) }}\\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_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \\'\\' }}\\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 pnpm --filter @agent-native/recap-cli build\\n echo \"RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\\n echo \"CODE_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/recap-cli@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 --ignore-scripts \"@agent-native/recap-cli@$VERSION\"; then\\n break\\n fi\\n if [ \"$attempt\" = \"3\" ]; then exit 1; fi\\n sleep $((attempt * 10))\\n done\\n echo \"RECAP_CLI_VERSION=$VERSION\" >> \"$GITHUB_ENV\"\\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: Install OpenAI-compatible provider runtime\\n if: needs.gate.outputs.agent == \\'openai-compatible\\' && steps.cli.outputs.local != \\'true\\'\\n env:\\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || \\'latest\\' }}\\n run: |\\n set -euo pipefail\\n CORE_VERSION=\"$CORE_CLI_VERSION\"\\n if [ \"$CORE_VERSION\" = \"latest\" ]; then\\n CORE_VERSION=\"$(npm view @agent-native/core@latest version)\"\\n fi\\n npm install --prefix \"$RUNNER_TEMP/recap-code\" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai \"@agent-native/core@$CORE_VERSION\"\\n echo \"CORE_CLI_VERSION=$CORE_VERSION\" >> \"$GITHUB_ENV\"\\n echo \"CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native\" >> \"$GITHUB_ENV\"\\n\\n - name: Start visual recap check\\n id: recap_check\\n continue-on-error: true\\n run: |\\n set -uo pipefail\\n $RECAP_CLI recap check start --sha \"$HEAD_SHA\" --workflow-url \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\"\\n\\n - name: Fetch pull request head\\n env:\\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\\n run: |\\n set -euo pipefail\\n if git cat-file -e \"${HEAD_SHA}^{commit}\" 2>/dev/null; then\\n git update-ref refs/recap/pr-head \"$HEAD_SHA\"\\n else\\n AUTH_B64=\"$(printf \\'x-access-token:%s\\' \"$GH_TOKEN\" | base64 | tr -d \\'\\\\n\\')\"\\n git -c \"http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64\" fetch origin \"pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head\"\\n fi\\n FETCHED_SHA=\"$(git rev-parse refs/recap/pr-head)\"\\n if [ \"$FETCHED_SHA\" != \"$HEAD_SHA\" ]; then\\n echo \"FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit\"\\n exit 1\\n fi\\n\\n - name: Collect bounded diff\\n id: diff\\n env:\\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\\n run: |\\n set -euo pipefail\\n $RECAP_CLI recap collect-diff --base \"$BASE_SHA\" --head refs/recap/pr-head --out recap.diff --stat recap.stat\\n\\n - name: Probe plan-app auth\\n id: auth_probe\\n if: steps.diff.outputs.tiny != \\'true\\'\\n continue-on-error: true\\n run: |\\n set -uo pipefail\\n # Hit the plan app\\'s action surface with the publish token. A 401 means\\n # the token is expired/revoked; surface it in the sticky comment so the\\n # repo owner knows to re-mint it instead of seeing a generic failure.\\n HTTP_STATUS=$(node -e \\'\\n const https = require(\"https\");\\n const url = new URL(\"/_agent-native/actions/record-recap-usage\", process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\");\\n const req = https.request(url, { method: \"POST\", headers: { \"authorization\": \"Bearer \" + process.env.PLAN_RECAP_TOKEN, \"content-type\": \"application/json\" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\\n req.on(\"error\", () => process.stdout.write(\"0\"));\\n req.end(JSON.stringify({ planId: \"__probe__\" }));\\n \\' 2>/dev/null || echo \"0\")\\n if [ \"$HTTP_STATUS\" = \"401\" ]; then\\n echo \"auth_failed=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"auth_failed=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n\\n - name: Probe plan-app route health\\n id: route_health\\n if: steps.diff.outputs.tiny != \\'true\\'\\n continue-on-error: true\\n run: |\\n set -uo pipefail\\n # Pre-publish health gate: confirm the plan app\\'s recap action routes\\n # are actually deployed BEFORE the agent runs. A 404 from\\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\\n # plan-app deploy has not propagated yet (the client is ahead of the\\n # deployed server). Say that plainly here instead of letting the agent\\n # run and then fail confusingly at publish time. A 401 or 200 is\\n # healthy — the route exists, it just rejected/accepted the probe.\\n probe_status() {\\n ROUTE=\"$1\" METHOD=\"$2\" node -e \\'\\n const https = require(\"https\");\\n const base = process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\";\\n const url = new URL(process.env.ROUTE, base);\\n if (process.env.METHOD === \"GET\") url.searchParams.set(\"format\", \"reference\");\\n const req = https.request(url, { method: process.env.METHOD, headers: { \"authorization\": \"Bearer \" + (process.env.PLAN_RECAP_TOKEN || \"\"), \"content-type\": \"application/json\" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\\n req.on(\"error\", () => process.stdout.write(\"0\"));\\n req.on(\"timeout\", () => { process.stdout.write(\"0\"); req.destroy(); });\\n if (process.env.METHOD === \"POST\") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\\n \\' 2>/dev/null || echo \"0\"\\n }\\n CREATE_STATUS=\"$(probe_status /_agent-native/actions/create-visual-recap POST)\"\\n BLOCKS_STATUS=\"$(probe_status /_agent-native/actions/get-plan-blocks GET)\"\\n REASON=\"\"\\n if [ \"$CREATE_STATUS\" = \"404\" ] || [ \"$BLOCKS_STATUS\" = \"404\" ]; then\\n REASON=\"Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating.\"\\n echo \"::error::$REASON\"\\n echo \"unhealthy=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"unhealthy=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n {\\n echo \\'reason<<__RECAP_ROUTE_HEALTH_EOF__\\'\\n echo \"$REASON\"\\n echo \\'__RECAP_ROUTE_HEALTH_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n\\n - name: Secret scan\\n id: scan\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\'\\n run: |\\n set -uo pipefail\\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\\n # credential-bearing diff is never handed to the agent / plan service.\\n if ! SCAN_JSON=\"$($RECAP_CLI recap scan --diff recap.diff --mode \"$VISUAL_RECAP_SECRET_SCAN\")\"; then\\n SCAN_JSON=\\'{\"suppressed\":true,\"reason\":\"secret scan failed to run; failing closed\"}\\'\\n fi\\n {\\n echo \\'json<<__RECAP_SCAN_EOF__\\'\\n echo \"$SCAN_JSON\"\\n echo \\'__RECAP_SCAN_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n SUPPRESSED=$(node -e \\'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?\"true\":\"false\")}catch{process.stdout.write(\"true\")}\\' \"$SCAN_JSON\")\\n echo \"suppressed=$SUPPRESSED\" >> \"$GITHUB_OUTPUT\"\\n\\n - name: Read previous plan id\\n id: prev\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\'\\n continue-on-error: true\\n run: |\\n set -euo pipefail\\n PLAN_ID=\"$($RECAP_CLI recap comment find-plan-id --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\")\"\\n echo \"plan_id=$PLAN_ID\" >> \"$GITHUB_OUTPUT\"\\n\\n - name: Fetch plan block reference\\n id: block_reference\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n continue-on-error: true\\n run: |\\n set -uo pipefail\\n if $RECAP_CLI recap block-reference --app-url \"$PLAN_RECAP_APP_URL\" --out recap-blocks.md; then\\n echo \"ok=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"ok=false\" >> \"$GITHUB_OUTPUT\"\\n {\\n echo \\'summary<<__RECAP_BLOCK_REFERENCE_EOF__\\'\\n echo \"Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the publisher will validate the final MDX.\"\\n echo \\'__RECAP_BLOCK_REFERENCE_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n cat > recap-blocks.md <<\\'EOF\\'\\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\\n EOF\\n fi\\n\\n - name: Build recap prompt\\n id: prompt\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n env:\\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\\n # the prev plan id is parsed from a PR comment and could inject shell.\\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\\n run: |\\n set -euo pipefail\\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr \"$PR_NUMBER\" --repo \"$GITHUB_REPOSITORY\" --head \"$HEAD_SHA\" --app-url \"$PLAN_RECAP_APP_URL\" --skill-source \"$VISUAL_RECAP_SKILL_SOURCE\" --out recap-prompt.md)\\n if [ \"${DIFF_HUGE:-}\" = \"true\" ]; then ARGS+=(--huge); fi\\n if [ \"${IS_FORK:-}\" = \"true\" ]; then ARGS+=(--fork-pr true); fi\\n if [ -n \"${PREV_PLAN_ID:-}\" ]; then ARGS+=(--prev-plan-id \"$PREV_PLAN_ID\"); fi\\n $RECAP_CLI recap build-prompt \"${ARGS[@]}\"\\n\\n - name: Run agent (Claude Code)\\n id: claude\\n if: needs.gate.outputs.agent == \\'claude\\' && steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n continue-on-error: true\\n env:\\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\\n run: |\\n set -uo pipefail\\n CLAUDE_ALLOWED_TOOLS=\"Read,Write,Bash(git diff:*)\"\\n CLAUDE_ARGS=(-p \"$(cat recap-prompt.md)\" --allowedTools \"$CLAUDE_ALLOWED_TOOLS\" --permission-mode dontAsk --output-format json)\\n CLAUDE_ARGS+=(--model \"${VISUAL_RECAP_MODEL:-claude-sonnet-5}\")\\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\\n run_claude() {\\n set +e\\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-result.json 2> claude-stderr.log\\n CLAUDE_STATUS=\"$?\"\\n set -e\\n echo \"$CLAUDE_STATUS\" > claude-exit-code.txt\\n }\\n run_claude\\n # A clean agent exit WITHOUT recap-source.json is the strongest\\n # \"retry me\" signal — the deterministic publisher needs that file, and\\n # the agent occasionally finishes a turn without writing it. Retry once.\\n if [ ! -s recap-source.json ]; then\\n if grep -Eiq -- \\'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\\' claude-result.json claude-stderr.log 2>/dev/null; then\\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\\n else\\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\\n sleep 5\\n run_claude\\n fi\\n fi\\n\\n - name: Run agent (Codex)\\n id: codex\\n if: needs.gate.outputs.agent == \\'codex\\' && steps.diff.outputs.tiny != \\'true\\' && steps.route_health.outputs.unhealthy != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n continue-on-error: true\\n env:\\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\\n run: |\\n set -uo pipefail\\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\\n # The runner is itself an ephemeral sandbox; bypass Codex\\'s own sandbox\\n # (bubblewrap can\\'t init here) and approval gate (cancels the MCP write).\\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CODEX_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\\n # Validate reasoning against the enum before embedding it in the TOML override.\\n case \"${VISUAL_RECAP_REASONING:-}\" in\\n none|minimal|low|medium|high|xhigh)\\n CODEX_ARGS+=(-c \"model_reasoning_effort=\\\\\"$VISUAL_RECAP_REASONING\\\\\"\") ;;\\n \"\") ;;\\n *) echo \"Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING\" ;;\\n esac\\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\\n run_codex() {\\n set +e\\n npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-prompt.md)\" 2> codex-stderr.log | tee codex-events.jsonl\\n CODEX_STATUS=\"${PIPESTATUS[0]}\"\\n set -e\\n echo \"$CODEX_STATUS\" > codex-exit-code.txt\\n }\\n run_codex\\n # Retry once if the agent exited without writing recap-source.json\\n # (see the Claude step) — the publisher needs that file.\\n if [ ! -s recap-source.json ]; then\\n if grep -Eiq -- \\'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\\' codex-events.jsonl codex-stderr.log 2>/dev/null; then\\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\\n else\\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\\n sleep 5\\n run_codex\\n fi\\n fi\\n\\n - name: Run agent (OpenAI-compatible)\\n id: openai_compatible\\n if: needs.gate.outputs.agent == \\'openai-compatible\\' && 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.VISUAL_RECAP_API_KEY }}\\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\\n AGENT_ENGINE: ai-sdk:openai\\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\\n run: |\\n set -uo pipefail\\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\\n run_openai_compatible() {\\n set +e\\n $CODE_CLI code exec --permission-mode auto-edit \"$(cat recap-prompt.md)\" > openai-compatible-result.txt 2> openai-compatible-stderr.log\\n OPENAI_COMPATIBLE_STATUS=\"$?\"\\n set -e\\n echo \"$OPENAI_COMPATIBLE_STATUS\" > openai-compatible-exit-code.txt\\n }\\n run_openai_compatible\\n if [ ! -s recap-source.json ]; then\\n if grep -Eiq -- \\'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\\' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\\n else\\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\\n sleep 5\\n run_openai_compatible\\n fi\\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 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 elif [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then\\n RESULT=openai-compatible-result.txt\\n STDERR=openai-compatible-stderr.log\\n EXIT_CODE=openai-compatible-exit-code.txt\\n fi\\n SUMMARY_JSON=\"$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent \"$RECAP_AGENT\" --result-file \"$RESULT\" --stderr-file \"$STDERR\" --exit-code-file \"$EXIT_CODE\" || echo \\'{}\\')\"\\n SUMMARY=\"$(node -e \\'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === \"string\" ? value : \"\"); } catch {}\\' \"$SUMMARY_JSON\")\"\\n if [ -n \"$SUMMARY\" ]; then\\n {\\n echo \\'summary<<__RECAP_AGENT_SUMMARY_EOF__\\'\\n echo \"$SUMMARY\"\\n echo \\'__RECAP_AGENT_SUMMARY_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n elif [ -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 elif [ -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 fi\\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 [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then RESULT=openai-compatible-usage.json; 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 IMAGE_CACHE_KEY=\"$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT\"\\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 --image-cache-key \"$IMAGE_CACHE_KEY\" || 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 --image-cache-key \"$IMAGE_CACHE_KEY\" || 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 SHOT_STATUS=$(LIGHT_SHOT_JSON=\"$LIGHT_SHOT_JSON\" DARK_SHOT_JSON=\"$DARK_SHOT_JSON\" node <<\\'NODE\\'\\n const parse = (raw) => { try { return JSON.parse(raw || \"{}\"); } catch { return { ok: false, reason: \"invalid shot JSON\" }; } };\\n const shots = [[\"light\", parse(process.env.LIGHT_SHOT_JSON)], [\"dark\", parse(process.env.DARK_SHOT_JSON)]];\\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === \"string\" && shot.imageUrl.trim());\\n const reasons = shots.flatMap(([label, shot]) => {\\n if (typeof shot.reason === \"string\" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\\n if (!(typeof shot.imageUrl === \"string\" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\\n return [];\\n });\\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? \"\" : reasons.join(\"; \").slice(0, 1000) }));\\n NODE\\n )\\n SHOT_OK=$(node -e \\'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?\"true\":\"false\")}catch{process.stdout.write(\"false\")}\\' \"$SHOT_STATUS\")\\n SHOT_REASON=$(node -e \\'try{process.stdout.write(JSON.parse(process.argv[1]).reason||\"\")}catch{process.stdout.write(\"invalid shot status JSON\")}\\' \"$SHOT_STATUS\")\\n if [ \"$SHOT_OK\" != \"true\" ]; then\\n echo \"::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON\"\\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 echo \"shot_ok=$SHOT_OK\" >> \"$GITHUB_OUTPUT\"\\n {\\n echo \\'shot_reason<<__RECAP_SHOT_REASON_EOF__\\'\\n echo \"$SHOT_REASON\"\\n echo \\'__RECAP_SHOT_REASON_EOF__\\'\\n } >> \"$GITHUB_OUTPUT\"\\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo \"captured=true\" >> \"$GITHUB_OUTPUT\"; else echo \"captured=false\" >> \"$GITHUB_OUTPUT\"; fi\\n\\n - name: Upload recap screenshot artifact\\n if: steps.shot.outputs.captured == \\'true\\'\\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\\n with:\\n name: pr-visual-recap-${{ github.event.pull_request.number }}\\n path: |\\n recap.png\\n recap-dark.png\\n if-no-files-found: ignore\\n retention-days: 14\\n\\n - name: Upload recap source artifact\\n if: always() && !cancelled()\\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\\n with:\\n # recap-source.json + the agent transcript (claude-result.json /\\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\\n # did when a publish fails (no plan URL) — INCLUDING the case where it\\n # finished without writing recap-source.json at all. The sticky comment\\n # only shows the screenshot, so without these a failed recap is\\n # undebuggable. Uploaded on success + failure; tolerant when absent.\\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\\n path: |\\n recap-source.json\\n claude-result.json\\n claude-stderr.log\\n codex-events.jsonl\\n codex-stderr.log\\n openai-compatible-result.txt\\n openai-compatible-usage.json\\n openai-compatible-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 RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\\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';\n"]}
@@ -1,2 +1,2 @@
1
- export declare const VISUAL_RECAP_SKILL_MD = "---\nname: visual-recap\ndescription: >-\n Turn a PR, branch, commit, or git diff into an interactive visual recap with\n diagrams, file maps, API/schema summaries, annotated diffs, and focused review\n notes.\nmetadata:\n visibility: exported\n---\n\n# Visual Recap\n\n`/visual-recap` creates a visual plan built **from** a diff, not toward one. It\nis the reverse of forward planning: instead of describing the change you are\nabout to make, you describe the change that was just made, at a higher altitude\nthan line-by-line review. The same plan data model serves both directions \u2014\nschema, API, file, and architecture changes become the same `data-model`,\n`api-endpoint`, `file-tree`, and `diagram` blocks a forward plan would use, only\nnow they summarize work that exists. A reviewer scans the shape of the change\nbefore spending attention on the literal lines.\n\n## Publish As An Agent-Native Plan \u2014 Never Inline\n\nThe deliverable is ALWAYS a published Agent-Native Plan, created with\n`create-visual-recap` on the Plan MCP connector \u2014 NEVER inline chat content (not\nMarkdown prose, an ASCII sketch, a table, a fenced \"wireframe\", or a \"here's the\nrecap\" summary). A recap's entire value is the hosted, interactive, annotatable\nplan; an inline summary is not a degraded recap, it is the thing a recap\nreplaces. If the `plan` (or legacy `agent-native-plans`) tools are not visible,\ndiscover them through the host's `tool_search` first; if they are still missing,\nSTOP and give the user the client-specific reconnect step rather than improvising\nan inline recap. Before publishing, or whenever a connector or auth error\nappears, READ `references/connection.md` in this skill directory \u2014 it is the\nsingle source of truth for the never-inline rule, connector discovery, and the\nper-client reconnect steps. Local-files privacy mode (below) is the one\nexception.\n\n## Local-Files Privacy Mode \u2014 read `references/local-files.md`\n\nWhen the user wants no hosted Plan database writes \u2014 no DB writes, no Plan MCP\npublish, fully local/offline/private recaps, or `AGENT_NATIVE_PLANS_MODE=local-files`\n\u2014 do not call any hosted Plan tool except the schema-only `get-plan-blocks`\ncatalog lookup. Read the diff with the local `recap collect-diff` / `scan` /\n`build-prompt --local-files` helpers, author a local MDX folder (set\n`kind: \"recap\"` and `localOnly: true`), and preview it with `plan local check`,\n`plan local serve --kind recap`, and `plan local verify --kind recap`. Before\nusing local-files mode, READ `references/local-files.md` in this skill directory\n\u2014 it is the single source of truth for the full contract.\n\n## When To Use\n\nBuild a recap when a PR or commit is large, multi-file, or touches schema, API\ncontracts, or architecture, and a reviewer would benefit from seeing the change\nmapped to structured blocks before reading the raw diff. A GitHub Action can\ngenerate one automatically from a PR diff; an agent can generate one on request\n(\"recap this PR\", \"show me what this branch changed\"). Skip it for small,\nsingle-file, or obvious diffs \u2014 a recap is review overhead, and a tiny change\nreviews faster as plain diff.\n\n## Recap The Whole Work Unit\n\nWhen `/visual-recap` is invoked in a chat thread after work has already happened,\nthe default scope is the whole current work unit/thread, not only the most recent\nuser message, tool action, or follow-up fix. Gather the thread-owned changes\nacross the conversation: original implementation work, later bug fixes, UI\nfollow-ups, tests, changesets, skill/instruction updates, generated plan/source\nartifacts, and any local import/linking fixes needed to make the recap open.\n\nUse the current diff plus conversation context to separate thread-owned changes\nfrom unrelated dirty work that existed before the thread. Exclude unrelated\npre-existing edits. If the scope is genuinely ambiguous and cannot be inferred,\nstate the assumption or ask a concise question before publishing.\n\nWhen updating an existing recap after feedback, revise the recap so it still\ncovers the whole thread/work unit plus the new correction. Do not replace a broad\nrecap with a narrow recap of only the latest feedback unless the user explicitly\nasks for that narrower scope.\n\n## Keep The Recap Body Lean\n\nDo not add boilerplate intro, disclaimer, provenance, or summary prose blocks to\nthe generated plan body. In particular, do not create a `rich-text` block just to\nsay the recap is an aid, that the reviewer should still review the diff, how many\nfiles changed, or which ref/working tree generated the recap. The plan title,\nbrief, and `file-tree` (which carries the per-file change stats) already carry\nthat context.\n\nOnly add prose blocks when they tell the reviewer something specific about the\nchange that the structured blocks do not: the objective, a real compatibility\nrisk, an important decision visible in the diff, or a grounded review note.\n\n## Recaps Must Be Substantial\n\nLean is not the same as thin. A recap is not a single wireframe plus one\nsentence \u2014 that under-serves the reviewer as much as boilerplate prose over-serves\nthem. Alongside the visual/structural headline (wireframes, `data-model`,\n`api-endpoint`, `diagram`), a substantial recap also carries the implementation\nevidence:\n\n- A short surface/state inventory before authoring: list the changed routes,\n components, popovers/dialogs, role/access states, empty/error states, and\n shared abstractions visible in the diff. The final recap must either represent\n each meaningful item with a block or intentionally omit it because it is tiny,\n redundant, or not user-visible.\n- A `file-tree` of the changed files with each entry's `change` flag, so the\n reviewer sees the footprint of the work at a glance.\n- The split `diff` of the KEY changed files, grouped under a `## Key changes`\n `rich-text` heading in a single horizontal `tabs` block (the default\n orientation, one file per tab), with a one-line `summary` and a few\n `annotations` on each \u2014 so the reviewer can drop from the high-altitude shape\n straight into the load-bearing code. Use horizontal file tabs, not a vertical\n side rail, so the selected file has enough width for the side-by-side diff.\n\nSkip the diff appendix only for a genuinely tiny change that reviews faster as\nplain diff (see \"When To Use\"); for any change worth recapping, the file-tree and\nkey-change diffs belong in the plan.\n\n## Canonical Shape And Budgets\n\nA strong recap follows one skeleton, top to bottom:\n\n1. UI-impact headline \u2014 wireframes first, when the diff changed rendered UI.\n2. Short outcome narrative (`rich-text`): what changed and why, 1-3 paragraphs.\n3. `data-model` / `api-endpoint` blocks for schema and contract changes.\n4. `file-tree` of the changed files with `change` flags.\n5. `## Key changes` \u2014 one horizontal `tabs` block of `diff` / `annotated-code`.\n\nBudgets that keep the recap reviewable:\n\n- 3-8 key-change tabs. Fewer than 3 on a large change under-serves the\n reviewer; more than 8 stops being a summary.\n- Keep each diff/annotated-code excerpt focused \u2014 prefer under ~150 lines per\n tab; summarize or link the rest of a long file instead of dumping it.\n- Title at most ~70 characters; brief 1-3 sentences.\n\n**GOOD.** A 25-file auth change: Before/After wireframes of the login surface,\na two-paragraph narrative, a diff-aware `data-model` of the sessions table, an\n`api-endpoint` for the new refresh route, a `file-tree` with change flags, and\n`## Key changes` with five focused tabs, each with a one-line `summary` and a\nfew annotations on the load-bearing hunks.\n\n**BAD.** One giant unsegmented diff dump with no summaries or annotations; or a\nsparse three-block recap of a 40-file change (one wireframe, one sentence, one\nfile list) that forces the reviewer back into the raw diff anyway.\n\n## UI Impact Needs Wireframes\n\nWhen the diff changes rendered UI, layout, density, visual state, interaction\naffordances, navigation, controls, menus, dialogs, or design tokens, the recap\nMUST include one or more wireframes. Prose and file diffs are not a substitute\nfor showing what changed visually.\n\nBefore choosing wireframes, make a UI coverage pass from the diff:\n\n- Identify the entry surface where the change appears, such as a page header,\n list row, toolbar, route shell, or menu trigger.\n- Identify the interaction surface that opens or changes, such as a popover,\n dialog, tab, sheet, dropdown, inline editor, or toast.\n- Identify the resulting destination or persistent state, such as a public page,\n read-only view, empty state, error state, loading state, permission-denied\n state, or saved/shared state.\n- Identify access or role variants when permissions change. Owner/admin/editor\n versus viewer/non-manager differences are visual behavior and need a compact\n matrix, paired wireframes, or clearly labeled state sequence.\n\nFor UI-heavy PRs, a single before/after of the entry surface is not enough.\nShow the changed entry point, the main changed interaction surface, and the\nresulting/destination state. Add more states when the diff adds tabs, role-based\ncontrols, public/private visibility, invite/manage flows, destructive controls,\nor empty/error branches.\n\nChoose the smallest visual surface that makes the review clear:\n\n- Use a `Before` / `After` wireframe pair when the reviewer benefits from direct\n comparison, such as a removed or added control, a changed state, layout\n density, ordering, navigation, or a visible component replacement.\n `references/wireframe.md` owns how to lay that pair out (columns vs.\n vertical stack by geometry).\n- Use an after-only wireframe when the change is purely additive or the \"before\"\n state would only show absence without adding review value.\n- Use more than two wireframes when the UI change is flow-dependent, responsive,\n or stateful; show the meaningful states in order instead of forcing a single\n before/after pair.\n- For tiny surfaces like menus, popovers, dialogs, toasts, or panels, use the\n matching `surface` (`popover`, `panel`, etc.) and show the focused sub-surface.\n Do not redraw a full page unless placement in the page is itself part of the\n change.\n\nGround each wireframe in the changed UI behavior, component names, file paths,\nand diff-visible labels/states. If exact pixels are inferred rather than\ncaptured, say so in the wireframe caption or a concise annotation. For\nlocal/manual recaps, import or update the plan source that holds the wireframes\nso the rendered recap opens with the UI visual available.\n\n## Wireframe Quality \u2014 read `references/wireframe.md`\n\nUI recap/plan wireframes must meet a strict quality bar \u2014 full-width chrome,\npinned bottom bars, real product content, before/after comparability, the right\n`surface` preset, `--wf-*` tokens instead of hex, and no `<html>`/`<style>`/font\ntags. Before authoring ANY wireframe / `<Screen>` / `WireframeBlock`, READ\n`references/wireframe.md` in this skill directory \u2014 it is the single source of\ntruth for HTML wireframe quality, shared word for word with `/visual-plan`\nand `/visual-recap`. Do not author wireframes from memory.\n\nUse the standard `WireframeBlock` / `<Screen>` format so the Plan viewer owns the\nsurface frame, theme, and sketchy/clean toggle. HTML wireframes are appropriate\nwhen placement precision matters, especially popovers, menus, dialogs, and dense\nforms. For HTML\nwireframes, keep `renderMode` unset or `wireframe` unless a design-only editable\nmockup is explicitly required, because `renderMode=\"design\"` disables the\nsketchy rough overlay.\n\nWhen a browser tool is available, render a UI-impact recap in the Plan viewer\nand visually inspect it at the current theme before sharing. If any label,\nannotation, toolbar, or wireframe content overlaps another element, fix the MDX\nand re-import before reporting the link. A text-match screenshot is not enough;\nvisually inspect the captured image. When no browser is available (for example\na headless CI agent), state that in the recap handoff instead.\n\n## Top Canvas Recaps \u2014 read `../visual-plan/references/canvas.md`\n\nWhen a recap includes a top canvas, storyboard, or flow view, READ\n`../visual-plan/references/canvas.md` before authoring `canvas.mdx`. Recap\ncanvas artboards must use the same HTML wireframe path as good document-body\nwireframes: `<Screen surface=\"...\" html={...} />` with a semantic HTML fragment.\nDo not author fresh kit-tree children such as `<FrameScreen>`, `<Card>`,\n`<Row>`, `<Title>`, or `<Btn>` inside canvas `<Screen>` tags. Those components\nare legacy compatibility markup for old plans; in new canvas storyboards they\ncan produce cramped or overlapping layouts even when the inline body wireframe\nlooks good. If a canvas mockup looks worse than the same screen below the fold,\nassume it used the legacy kit path and replace it with an HTML screen.\n\n## Open And Report The Recap\n\nIn local-files privacy mode, run `plan local check` first, then report the local\nbridge URL from\n`npx @agent-native/core@latest plan local serve --dir <plan-dir> --kind recap --open`\nor from `<plan-dir>/.plan-url`. It opens the hosted Plan UI but reads from the\nlocalhost bridge on this machine, so it is not shareable across machines. If the\nPlan app itself is running locally with the same `PLAN_LOCAL_DIR`, the\n`/local-plans/<slug>` route is also valid. Do not invent a hosted database URL\nand do not publish just to get an absolute Plan link.\n\nAfter creating the recap, link the reviewer to the rendered plan with an\n**absolute URL on the origin whose database actually holds the plan**. That\norigin is the Plan MCP server you just created the recap through \u2014 NOT whatever\ndev server you happen to know is running. The create tool returns the correct\nlink; report THAT. Never make the primary link a local `plan.mdx` file, a local\nmirror folder, or a relative path such as `/plans/<id>`.\n\nWhen the recap is posted to a PR for a private repo, the plan link is not a\npublic URL. Make the PR comment/handoff copy explicit: reviewers may need to\nsign in to Agent-Native Plans with an account that has access to the owning\norganization before the link loads. Use wording like: \"Private repo recap:\nsign in with access to this org if the plan does not open.\" Do not imply the\nlink is broken or public when access is gated by repo/org visibility.\n\nA recap lives only in the database of the MCP that created it. A separately\nrunning local dev server (e.g. `http://localhost:8081`) has its OWN database and\nwill NOT contain a recap created through the hosted MCP, so a hand-built\n`localhost` link returns \"Plan not found\". This is the most common recap\nmistake \u2014 do not guess an origin you have not confirmed shares the MCP's data.\n\nResolve the URL in this order:\n\n1. Use the absolute URL the create tool RETURNS \u2014 `openLink.webUrl`, else the\n `visualUrl` in the returned `plan.mdx` frontmatter, else `url`/`path`\n resolved against the MCP server's own origin (for the hosted MCP that is\n `https://plan.agent-native.com`). This always points at the database that has\n the plan.\n2. Use a `localhost`/dev origin ONLY when the recap was created through a Plan\n MCP bound to that same origin \u2014 i.e. that MCP's url is\n `http://localhost:<port>/mcp`. Creating through the hosted MCP\n and linking to localhost is the exact mismatch that 404s.\n3. If only a plan id is available, build the MCP origin's absolute URL\n (hosted: `https://plan.agent-native.com/plans/<id>`) and say it was inferred.\n\nIf the user wants to review on localhost but the recap was created through the\nhosted MCP, say so plainly: the local dev server cannot see it. To view a recap\non localhost (e.g. to exercise un-deployed local renderer changes), they must\nconnect a LOCAL Plan MCP (`http://localhost:<port>/mcp`) and\nre-create the recap through it so it lands in the local database; offer to do\nthat rather than handing over a localhost URL that will not resolve.\n\nWhen running in Codex and the Browser/in-app side browser tools are available,\nopen the returned absolute recap URL there automatically after creation. Still\ninclude the same absolute URL in the final response. Local mirror files like\n`plans/<slug>/plan.mdx` may be mentioned only as secondary source-control\nartifacts, not as the main way to open the recap.\n\n## Diff \u2192 Block Mapping\n\nMap each kind of change to the block that carries it, derived mechanically from\nthe actual diff. The names below are the CONCEPTUAL block types, not the JSX\ntags \u2014 resolve every conceptual name to its exact tag + prop schema with the\n`get-plan-blocks` tool (see \"Block reference\" below) before authoring.\n\n- **Schema / migration change** \u2192 `data-model` for the resulting entities,\n fields, and relations. Flag what moved per field/entity with\n `change: \"added\" | \"modified\" | \"removed\" | \"renamed\"`, and for a changed type\n set `was` to the prior value (e.g. the old column type) \u2014 grounded in the real\n migration diff. That diff-aware `data-model` is the headline; reach for a split\n `diff` of the literal SQL only when the exact statement still matters, not by\n default.\n- **API / action / route change** \u2192 `api-endpoint` with the method, path,\n params, request, and responses as they are after the change. Flag each changed\n param/response with `change` (and `was` on a param whose type/shape changed),\n and set `change` on the endpoint root for a wholly added or removed route. Mark\n removed endpoints with `deprecated: true` and explain in prose.\n Keep multiple API endpoints in the normal single-column document flow unless\n they are an explicit before/after contract comparison.\n Author each request/response example as a SINGLE valid JSON value \u2014 one\n top-level object or array, parseable on its own \u2014 so it renders in the\n collapsible JSON explorer. Do not put `//` or `/* */` comments, prose,\n trailing commas, or two or more concatenated top-level objects inside one\n example; a non-parseable body falls back to flat text and loses the explorer.\n When an endpoint has several distinct message shapes (for example separate\n websocket frame types, or a success body versus an error body), give each its\n OWN example with its own label rather than cramming them into one body.\n- **Compatibility-sensitive change** \u2192 short `rich-text` notes beside the\n relevant `data-model` / `api-endpoint` block. Name the changed field,\n endpoint, or behavior and mark whether it is breaking, risky, or non-breaking;\n pair that note with a split `diff` for the literal lines.\n- **Any meaningful code hunk** \u2192 `diff` with `mode: \"split\"`, carrying the real\n `before` / `after` text and the `filename` / `language`. Split mode is the\n default for recap code review because before/after legibility is the point;\n use `mode: \"unified\"` only for a genuinely narrow standalone hunk where\n side-by-side would hide the code. Give every `diff` a one-line `summary`\n saying what the hunk changes and why; it renders as a description above the\n code so the reviewer reads intent first. Never leave a diff unlabeled.\n For the KEY changed files, attach `annotations` to the `diff` so the recap\n calls out what each important hunk does \u2014 this is the headline affordance for\n annotating the key files updated. Each annotation anchors to the AFTER-side\n line numbers by default (set `side: \"before\"` to point at removed lines). Keep\n it to a few high-signal notes per file, not one per line.\n When several key files each need a substantial diff, introduce the group with a\n `rich-text` heading block whose markdown is `## Key changes`, then place the\n `diff` blocks under it in a reusable `tabs` block with horizontal orientation\n (the default \u2014 omit `orientation`) so the selected file's split diff gets the\n full document width. Let that heading label the section \u2014 do NOT also set a\n `title` on the `tabs` block. Keep each tab label to the file path or a short\n basename plus directory hint.\n The renderer's wide document layout is intentionally allowlisted: `diff`,\n `annotated-code`, vertical `tabs`, and `tabs` containing diff-like children\n break out wider than prose. Do not put API endpoints, OpenAPI specs, data\n models, JSON explorers, wireframes, question forms, or custom HTML into tabs\n merely to make them wide.\n If the recap ends with more than one supporting diff, that trailing diff\n appendix should be one horizontal `tabs` block under its own `## Key changes`\n heading, not a stack of separate `diff` blocks.\n- **Brand-new file or a substantial added block with no meaningful \"before\"** \u2192\n `annotated-code` rather than a one-sided split `diff`. Carry the real new code\n with its `filename` / `language` and anchor a few high-signal notes to the lines\n that matter so the reviewer reads what the new code does, not code for code's\n sake. Keep split `diff` for true before/after hunks where the removed lines\n still carry meaning, and group several annotated walkthroughs in a horizontal\n `tabs` block the same way diffs are grouped.\n- **Files added / removed / renamed** \u2192 `file-tree` with each entry's `change`\n flag (`added`, `removed`, `modified`, `renamed`) and a short `note`; attach a\n `snippet` only when one tells the reviewer something the path does not.\n- **Rendered UI / interaction change** \u2192 one or more wireframes showing the\n visible UI delta before the reviewer reads code. Use `Before` / `After`\n wireframes when the comparison clarifies the change; otherwise use after-only\n or a short state/flow sequence. Use realistic UI surfaces: for a popover\n change, show a popover with its title row, top-right actions, options/fields,\n tabs, selected/disabled states, people/lists/rows, and any opened prompt/menu\n anchored to the correct trigger. If a route was added, show the route body and\n the unavailable/empty state when the diff implements one. If permissions\n changed, show what managers can do and what viewers/non-managers see instead.\n Keep the body lean: the wireframe carries the UI story, while the file tree\n and `diff` blocks carry implementation evidence.\n- **Architecture or data-flow shift** \u2192 `diagram` with `data.html` / `data.css`\n as a two-panel before/after, layered, or swimlane layout, or `mermaid` for a\n quick graph. Use two-dimensional layouts; do not reduce a structural change to\n a left-to-right chain. Do not use `diagram` as a stand-in for rendered UI\n controls; UI changes need `wireframe` blocks.\n Author diagram HTML/CSS with the renderer-owned `.diagram-*` primitives\n (`.diagram-panel`, `.diagram-node`, `.diagram-pill`, `[data-rough]`, \u2026) and\n the same `--wf-*` theme tokens `references/wireframe.md` defines \u2014 never\n `font-family`, hex, rgb/hsl literals, or one-off dark/light palettes. Choose\n the outer `frame` intentionally: recap diagrams usually benefit from\n `frame: \"show\"` when they stand alone, but use `frame: \"hide\"` when columns,\n tabs, a card, or the diagram's own panels already provide the boundary.\n- **Outcome-first narrative** \u2192 `rich-text` for the \"what changed and why\" prose:\n the objective the diff served, the key decisions visible in it, and the risks a\n reviewer should weigh. This is the only place the model writes freely.\n\n## Block reference \u2014 call `get-plan-blocks`, do not memorize tags\n\nThe conceptual block names above (`api-endpoint`, `data-model`, `json-explorer`,\n`tabs`, \u2026) are NOT the JSX tags you author with, and the exact tags, required\nfields, and prop shapes change as the block library evolves. Do not author from\nmemorized tags \u2014 they drift and silently produce a wrong tag (`ApiEndpoint`\ninstead of `Endpoint`, `JsonExplorer` instead of `Json`, `Tabs` instead of\n`TabsBlock`) that errors on import.\n\n**Before writing any structured plan content, fetch/read the block catalog.** In\nhosted or self-hosted mode, call `get-plan-blocks` on the Plan MCP connector\n(`plan` or legacy `agent-native-plans`). If no Plan tools are visible yet in a\nlazy-loading client, search/load them through the host's tool discovery surface\nfirst (`tool_search` when available). In local-files mode, or when the skill was\ninstalled as plain text and no MCP tools are registered after discovery, run\n`npx @agent-native/core@latest plan blocks --out plan-blocks.md` and read that\nfile first. The CLI command calls the public no-auth `get-plan-blocks` route and\nsends no plan/recap content. If network access is unavailable, use the bundled\nreferences and validate with `plan local check`; run `plan local serve` only\nwhen the hosted Plan UI is reachable or a local Plan app is already running.\n\nThe catalog returns the authoritative, always-current block vocabulary generated\nlive from the app's own block registry \u2014 the same config the renderer and MDX\nround-trip use \u2014 so it can never be stale even if this SKILL.md is an old\ninstalled copy:\n\n- `get-plan-blocks` (default `format: \"reference\"`) \u2192 a compact table of every\n block's runtime `type`, exact MDX `<Tag>`, placement, and key data fields.\n This is your map from each conceptual name above to its real tag and props.\n- `get-plan-blocks` with `format: \"schema\"` \u2192 the full per-block JSON Schema\n plus a worked example for each block, when you need exact field types,\n enums, or nesting (e.g. `Diff.annotations`, `Endpoint.params[].in`,\n `DataModel.entities[].fields[]`).\n\nAuthor the recap source against the tags and schemas that call returns. The\ncomplete set of valid block-level tags is whatever `get-plan-blocks` lists;\nany other capitalized tag at the block level is rejected on import with an\n\"Unknown plan block\" / \"did you mean\" error. Lowercase HTML tags inside\n`rich-text`/markdown prose (`<div>`, `<span>`, `<code>`, `<br>`, \u2026) are always\nfine \u2014 only capitalized component-style block tags are validated.\n\nA few recap-specific authoring rules the registry table cannot encode:\n\n- Every structured block takes a REQUIRED `id` (unique across the whole plan)\n plus the shared optional `summary` / `editable` envelope. Ordinary top-level\n Markdown prose imports as rich-text automatically; use `<RichText id=\"...\">`\n only when prose needs explicit metadata or a preserved referenced block id.\n- Every capitalized block component must be self-closing (`<Diagram ... />`) or\n explicitly closed around children (`<RichText ...>...</RichText>`). Never\n leave a bare opening tag like `<RichText ...>` in a paragraph; MDX treats it\n as unclosed JSX and import fails before the recap can render.\n- Code-bearing blocks (`Code`, `AnnotatedCode`, and `Diff`) are\n whitespace-sensitive. Prefer the exact MDX form from the `get-plan-blocks`\n examples / source exporter, where multiline code is encoded as JSON string\n attributes such as `code={\"const x =\\n y\"}`. Static template literals are\n accepted only when they are static strings with no `${...}` interpolation.\n- `Endpoint`: prose `description` is the MDX **children** (body between the\n tags), not an attribute; for a WebSocket upgrade use `method=\"GET\"`. Each\n request/response `example` is a JSON **string** (the renderer parses it into\n the JSON explorer), so keep it a single parseable JSON value.\n- `TabsBlock`: the whole `tabs` array (including nested child blocks) is ONE\n JSON `tabs={[\u2026]}` prop \u2014 there is NO nested `<Tab>` element.\n- `WireframeBlock`: its body is a single `<Screen surface ... html=\u2026 />` subtree\n (nested MDX, not a flat prop); `html` must be a single-quoted string or static\n template literal, never a dynamic `html={someVar}` expression. See\n `references/wireframe.md` for the HTML rules.\n- `Diagram`: the whole payload is one `data={{ html?, css?, nodes?, edges?, \u2026 }}`\n attribute and requires either `html` or at least one node; `Mermaid` is its\n own separate block (`source` text), not a `Diagram` prop.\n\n## Before / After Is The Headline\n\nThe recap's center of gravity is the before/after comparison. For document-body\ncomparisons there are two primitives, and they cover the whole need together:\n\n- **`columns`** \u2014 the side-by-side container, for **structured** comparisons.\n Use two columns labeled `Before` and `After`, each holding a block (commonly a\n `data-model`, `api-endpoint`, or `rich-text`), so the reviewer reads the old\n shape against the new shape in one glance. This is the right primitive for\n \"the schema went from X to Y\" or \"the endpoint contract changed like this.\"\n Do not use `columns` simply to compact or group a list of API endpoints.\n- **`diff`** \u2014 for **code**. It renders the literal removed and added lines. Use\n it for the actual hunks. Use split mode by default for recap code review;\n reserve `mode: \"unified\"` for genuinely narrow standalone hunks where\n side-by-side would hide the code. Key-file diff groups should use horizontal\n tabs so split diffs get the full document width.\n\nFor UI diffs, wireframes are the visual comparison primitive. Use before/after\nwireframes when the comparison clarifies the change; use after-only or a state\nsequence when that better matches the change. The visual headline must show\nexact placement, realistic chrome, and adequate padding before any abstract\nexplanation. Do not stop at the first visible affordance when the diff adds a\nflow; show the entry point, the opened surface, and the resulting state or page\nso the reviewer can trace the actual user path. `references/wireframe.md` owns\nthe before/after layout choice \u2014\nthe `columns` renderer keeps narrow surfaces side by side and auto-stacks wide\n`desktop`/`browser` frames vertically; never hand-build a side-by-side\nwireframe layout in `custom-html`. For document-body\ncomparisons, there is no other multi-column primitive \u2014 `columns` plus the\n`diff` block are the whole comparison vocabulary. Do not hand-build side-by-side\nlayouts in `custom-html`, and do not stack two `data-model` blocks vertically\nand call it a comparison when `columns` exists to put them side by side.\n\n## Grounding Rule\n\nStructured blocks are **true by construction** only if they are derived from the\nactual changed lines. The `diff`, `data-model`, `api-endpoint`, and `file-tree`\nblocks MUST be built mechanically from the real diff \u2014 real paths, real fields,\nreal method/path, real before/after text \u2014 never inferred, rounded, or invented.\nThe model writes only the prose: the \"why\", the narrative, the risk read. A\nconfidently wrong recap is dangerous in a review context, because a reviewer who\ntrusts the summary may skip the very line the summary got wrong. When the diff\ndoes not contain a fact, leave it out rather than guess; mark anything the model\ninferred (not extracted) as inferred in prose.\n\n## Security\n\n- **Gate visibility.** Recaps of a private repo are org/login-gated \u2014 set the\n plan's visibility to the owning org or login, never auto-public. A recap can\n expose unreleased schema, internal endpoints, and architecture; treat it like\n the source it summarizes. Any PR comment or handoff that links to the recap\n must say that private-repo recaps require signing in with access to the owning\n org if the link does not load.\n- **Never transcribe secrets.** A diff can contain API keys, tokens, webhook\n URLs, signing secrets, `.env` values, or credential-looking literals. Do not\n copy any of these into a `diff`, `file-tree` snippet, `api-endpoint`, or prose\n block \u2014 redact them (`sk-\u2022\u2022\u2022`, `<redacted>`). This mirrors the repo's\n hardcoded-secret rule: obviously fake placeholders only, never the real value,\n in any block, caption, or note.\n\n## Bidirectional Loop\n\nIn hosted mode, because a recap is a real, editable plan, the same review loop\nas forward plans applies: a reviewer can annotate any block, and the coding\nagent reads `get-plan-feedback` to drive fixes back into the code \u2014 annotation \u2192\nagent \u2192 diff, the same close-the-loop flow forward plans use. After a reviewer\nannotates a block, call `get-plan-feedback` to read the structured feedback,\nthen either update the recap with `create-visual-recap` (passing the existing\n`planId` to replace it in place) or apply targeted changes with\n`update-visual-plan`. The loop is live and wired. In local-files privacy mode,\ndo not call those hosted tools; read review notes from chat or local files, edit\n`<plan-dir>/*.mdx` directly, and rerun `plan local check`, `serve`, or `verify`\nfor `<plan-dir>`. The one thing not yet automatic is PR-comment-triggered\nre-runs: the GitHub Action creates an initial recap per PR, but it does not yet\nre-run automatically when new review feedback is posted in GitHub \u2014 that\nauto-re-run is the remaining fast-follow.\n\n## Related Skills\n\n- **visual-plan** \u2014 the canonical command and the source of the shared Wireframe\n & Canvas and Document Quality cores; a recap follows the same block discipline\n in reverse.\n- **comment anchors** \u2014 recap comments use the same anchor rules as forward\n plans; see \"Interpreting comment anchors\" in the visual-plan skill for\n coordinate frames, wireframe node ids, text-quote resolution, detached\n threads, routing via `resolutionTarget`, and two-axis consumed/resolved state.\n- **security** \u2014 data scoping, secret handling, and the hardcoded-secret rule the\n recap's redaction and visibility gating mirror.\n- **sharing** \u2014 org/login-gated visibility for the plan that holds the recap.\n";
1
+ export declare const VISUAL_RECAP_SKILL_MD = "---\nname: visual-recap\ndescription: >-\n Turn a PR, branch, commit, or git diff into an interactive visual recap with\n diagrams, file maps, API/schema summaries, annotated diffs, and focused review\n notes.\nmetadata:\n visibility: exported\n---\n\n# Visual Recap\n\n`/visual-recap` creates a visual plan built **from** a diff, not toward one. It\nis the reverse of forward planning: instead of describing the change you are\nabout to make, you describe the change that was just made, at a higher altitude\nthan line-by-line review. The same plan data model serves both directions \u2014\nschema, API, file, and architecture changes become the same `data-model`,\n`api-endpoint`, `file-tree`, and `diagram` blocks a forward plan would use, only\nnow they summarize work that exists. A reviewer scans the shape of the change\nbefore spending attention on the literal lines.\n\n## Publish As An Agent-Native Plan \u2014 Never Inline\n\nThe deliverable is ALWAYS a published Agent-Native Plan, created with\n`create-visual-recap` on the Plan MCP connector \u2014 NEVER inline chat content (not\nMarkdown prose, an ASCII sketch, a table, a fenced \"wireframe\", or a \"here's the\nrecap\" summary). A recap's entire value is the hosted, interactive, annotatable\nplan; an inline summary is not a degraded recap, it is the thing a recap\nreplaces. If the `plan` (or legacy `agent-native-plans`) tools are not visible,\ndiscover them through the host's `tool_search` first; if they are still missing,\nSTOP and give the user the client-specific reconnect step rather than improvising\nan inline recap. Before publishing, or whenever a connector or auth error\nappears, READ `references/connection.md` in this skill directory \u2014 it is the\nsingle source of truth for the never-inline rule, connector discovery, and the\nper-client reconnect steps. Local-files privacy mode (below) is the one\nexception.\n\n## Local-Files Privacy Mode \u2014 read `references/local-files.md`\n\nWhen the user wants no hosted Plan database writes \u2014 no DB writes, no Plan MCP\npublish, fully local/offline/private recaps, or `AGENT_NATIVE_PLANS_MODE=local-files`\n\u2014 do not call any hosted Plan tool except the schema-only `get-plan-blocks`\ncatalog lookup. Read the diff with the local `recap collect-diff` / `scan` /\n`build-prompt --local-files` helpers, author a local MDX folder (set\n`kind: \"recap\"` and `localOnly: true`), and preview it with `plan local check`,\n`plan local serve --kind recap`, and `plan local verify --kind recap`. Before\nusing local-files mode, READ `references/local-files.md` in this skill directory\n\u2014 it is the single source of truth for the full contract.\n\n## When To Use\n\nBuild a recap when a PR or commit is large, multi-file, or touches schema, API\ncontracts, or architecture, and a reviewer would benefit from seeing the change\nmapped to structured blocks before reading the raw diff. A GitHub Action can\ngenerate one automatically from a PR diff; an agent can generate one on request\n(\"recap this PR\", \"show me what this branch changed\"). Skip it for small,\nsingle-file, or obvious diffs \u2014 a recap is review overhead, and a tiny change\nreviews faster as plain diff.\n\n## Recap The Whole Work Unit\n\nWhen `/visual-recap` is invoked in a chat thread after work has already happened,\nthe default scope is the whole current work unit/thread, not only the most recent\nuser message, tool action, or follow-up fix. Gather the thread-owned changes\nacross the conversation: original implementation work, later bug fixes, UI\nfollow-ups, tests, changesets, skill/instruction updates, generated plan/source\nartifacts, and any local import/linking fixes needed to make the recap open.\n\nUse the current diff plus conversation context to separate thread-owned changes\nfrom unrelated dirty work that existed before the thread. Exclude unrelated\npre-existing edits. If the scope is genuinely ambiguous and cannot be inferred,\nstate the assumption or ask a concise question before publishing.\n\nWhen updating an existing recap after feedback, revise the recap so it still\ncovers the whole thread/work unit plus the new correction. Do not replace a broad\nrecap with a narrow recap of only the latest feedback unless the user explicitly\nasks for that narrower scope.\n\n## Keep The Recap Body Lean\n\nDo not add boilerplate intro, disclaimer, provenance, or summary prose blocks to\nthe generated plan body. In particular, do not create a `rich-text` block just to\nsay the recap is an aid, that the reviewer should still review the diff, how many\nfiles changed, or which ref/working tree generated the recap. The plan title,\nbrief, and `file-tree` (which carries the per-file change stats) already carry\nthat context.\n\nOnly add prose blocks when they tell the reviewer something specific about the\nchange that the structured blocks do not: the objective, a real compatibility\nrisk, an important decision visible in the diff, or a grounded review note.\n\n## Recaps Must Be Substantial\n\nLean is not the same as thin. A recap is not a single wireframe plus one\nsentence \u2014 that under-serves the reviewer as much as boilerplate prose over-serves\nthem. Alongside the visual/structural headline (wireframes, `data-model`,\n`api-endpoint`, `diagram`), a substantial recap also carries the implementation\nevidence:\n\n- A short surface/state inventory before authoring: list the changed routes,\n components, popovers/dialogs, role/access states, empty/error states, and\n shared abstractions visible in the diff. The final recap must either represent\n each meaningful item with a block or intentionally omit it because it is tiny,\n redundant, or not user-visible.\n- A `file-tree` of the changed files with each entry's `change` flag, so the\n reviewer sees the footprint of the work at a glance.\n- The split `diff` of the KEY changed files, grouped under a `## Key changes`\n `rich-text` heading in a single horizontal `tabs` block (the default\n orientation, one file per tab), with a one-line `summary` and a few\n `annotations` on each \u2014 so the reviewer can drop from the high-altitude shape\n straight into the load-bearing code. Use horizontal file tabs, not a vertical\n side rail, so the selected file has enough width for the side-by-side diff.\n\nSkip the diff appendix only for a genuinely tiny change that reviews faster as\nplain diff (see \"When To Use\"); for any change worth recapping, the file-tree and\nkey-change diffs belong in the plan.\n\n## Canonical Shape And Budgets\n\nA strong recap follows one skeleton, top to bottom:\n\n1. UI-impact headline \u2014 wireframes first, when the diff changed rendered UI.\n2. Short outcome narrative (`rich-text`): what changed and why, 1-3 paragraphs.\n3. `data-model` / `api-endpoint` blocks for schema and contract changes.\n4. `file-tree` of the changed files with `change` flags.\n5. `## Key changes` \u2014 one horizontal `tabs` block of `diff` / `annotated-code`.\n\nBudgets that keep the recap reviewable:\n\n- 3-8 key-change tabs. Fewer than 3 on a large change under-serves the\n reviewer; more than 8 stops being a summary.\n- Keep each diff/annotated-code excerpt focused \u2014 prefer under ~150 lines per\n tab; summarize or link the rest of a long file instead of dumping it.\n- Title at most ~70 characters; brief 1-3 sentences.\n\nThese budgets are also the cost ceiling: do not exceed them in the name of\nthoroughness, and do not re-read the full diff after the initial sequential\npass \u2014 work from the notes taken during that pass.\n\n**GOOD.** A 25-file auth change: Before/After wireframes of the login surface,\na two-paragraph narrative, a diff-aware `data-model` of the sessions table, an\n`api-endpoint` for the new refresh route, a `file-tree` with change flags, and\n`## Key changes` with five focused tabs, each with a one-line `summary` and a\nfew annotations on the load-bearing hunks.\n\n**BAD.** One giant unsegmented diff dump with no summaries or annotations; or a\nsparse three-block recap of a 40-file change (one wireframe, one sentence, one\nfile list) that forces the reviewer back into the raw diff anyway.\n\n## UI Impact Needs Wireframes\n\nWhen the diff changes rendered UI, layout, density, visual state, interaction\naffordances, navigation, controls, menus, dialogs, or design tokens, the recap\nMUST include one or more wireframes. Prose and file diffs are not a substitute\nfor showing what changed visually.\n\nBefore choosing wireframes, make a UI coverage pass from the diff:\n\n- Identify the entry surface where the change appears, such as a page header,\n list row, toolbar, route shell, or menu trigger.\n- Identify the interaction surface that opens or changes, such as a popover,\n dialog, tab, sheet, dropdown, inline editor, or toast.\n- Identify the resulting destination or persistent state, such as a public page,\n read-only view, empty state, error state, loading state, permission-denied\n state, or saved/shared state.\n- Identify access or role variants when permissions change. Owner/admin/editor\n versus viewer/non-manager differences are visual behavior and need a compact\n matrix, paired wireframes, or clearly labeled state sequence.\n\nFor UI-heavy PRs, a single before/after of the entry surface is not enough.\nShow the changed entry point, the main changed interaction surface, and the\nresulting/destination state. Add more states when the diff adds tabs, role-based\ncontrols, public/private visibility, invite/manage flows, destructive controls,\nor empty/error branches.\n\nChoose the smallest visual surface that makes the review clear:\n\n- Use a `Before` / `After` wireframe pair when the reviewer benefits from direct\n comparison, such as a removed or added control, a changed state, layout\n density, ordering, navigation, or a visible component replacement.\n `references/wireframe.md` owns how to lay that pair out (columns vs.\n vertical stack by geometry).\n- Use an after-only wireframe when the change is purely additive or the \"before\"\n state would only show absence without adding review value.\n- Use more than two wireframes when the UI change is flow-dependent, responsive,\n or stateful; show the meaningful states in order instead of forcing a single\n before/after pair.\n- For tiny surfaces like menus, popovers, dialogs, toasts, or panels, use the\n matching `surface` (`popover`, `panel`, etc.) and show the focused sub-surface.\n Do not redraw a full page unless placement in the page is itself part of the\n change.\n\nGround each wireframe in the changed UI behavior, component names, file paths,\nand diff-visible labels/states. If exact pixels are inferred rather than\ncaptured, say so in the wireframe caption or a concise annotation. For\nlocal/manual recaps, import or update the plan source that holds the wireframes\nso the rendered recap opens with the UI visual available.\n\n## Wireframe Quality \u2014 read `references/wireframe.md`\n\nUI recap/plan wireframes must meet a strict quality bar \u2014 full-width chrome,\npinned bottom bars, real product content, before/after comparability, the right\n`surface` preset, `--wf-*` tokens instead of hex, and no `<html>`/`<style>`/font\ntags. Before authoring ANY wireframe / `<Screen>` / `WireframeBlock`, READ\n`references/wireframe.md` in this skill directory \u2014 it is the single source of\ntruth for HTML wireframe quality, shared word for word with `/visual-plan`\nand `/visual-recap`. Do not author wireframes from memory.\n\nUse the standard `WireframeBlock` / `<Screen>` format so the Plan viewer owns the\nsurface frame, theme, and sketchy/clean toggle. HTML wireframes are appropriate\nwhen placement precision matters, especially popovers, menus, dialogs, and dense\nforms. For HTML\nwireframes, keep `renderMode` unset or `wireframe` unless a design-only editable\nmockup is explicitly required, because `renderMode=\"design\"` disables the\nsketchy rough overlay.\n\nWhen a browser tool is available, render a UI-impact recap in the Plan viewer\nand visually inspect it at the current theme before sharing. If any label,\nannotation, toolbar, or wireframe content overlaps another element, fix the MDX\nand re-import before reporting the link. Limit this to one render-and-inspect\npass plus at most one fix-and-re-render; do not keep iterating beyond that\nunless the user explicitly asks. A text-match screenshot is not enough;\nvisually inspect the captured image. When no browser is available (for example\na headless CI agent), state that in the recap handoff instead.\n\n## Top Canvas Recaps \u2014 read `../visual-plan/references/canvas.md`\n\nWhen a recap includes a top canvas, storyboard, or flow view, READ\n`../visual-plan/references/canvas.md` before authoring `canvas.mdx`. Recap\ncanvas artboards must use the same HTML wireframe path as good document-body\nwireframes: `<Screen surface=\"...\" html={...} />` with a semantic HTML fragment.\nDo not author fresh kit-tree children such as `<FrameScreen>`, `<Card>`,\n`<Row>`, `<Title>`, or `<Btn>` inside canvas `<Screen>` tags. Those components\nare legacy compatibility markup for old plans; in new canvas storyboards they\ncan produce cramped or overlapping layouts even when the inline body wireframe\nlooks good. If a canvas mockup looks worse than the same screen below the fold,\nassume it used the legacy kit path and replace it with an HTML screen.\n\n## Open And Report The Recap\n\nIn local-files privacy mode, run `plan local check` first, then report the local\nbridge URL from\n`npx @agent-native/core@latest plan local serve --dir <plan-dir> --kind recap --open`\nor from `<plan-dir>/.plan-url`. It opens the hosted Plan UI but reads from the\nlocalhost bridge on this machine, so it is not shareable across machines. If the\nPlan app itself is running locally with the same `PLAN_LOCAL_DIR`, the\n`/local-plans/<slug>` route is also valid. Do not invent a hosted database URL\nand do not publish just to get an absolute Plan link.\n\nAfter creating the recap, link the reviewer to the rendered plan with an\n**absolute URL on the origin whose database actually holds the plan**. That\norigin is the Plan MCP server you just created the recap through \u2014 NOT whatever\ndev server you happen to know is running. The create tool returns the correct\nlink; report THAT. Never make the primary link a local `plan.mdx` file, a local\nmirror folder, or a relative path such as `/plans/<id>`.\n\nWhen the recap is posted to a PR for a private repo, the plan link is not a\npublic URL. Make the PR comment/handoff copy explicit: reviewers may need to\nsign in to Agent-Native Plans with an account that has access to the owning\norganization before the link loads. Use wording like: \"Private repo recap:\nsign in with access to this org if the plan does not open.\" Do not imply the\nlink is broken or public when access is gated by repo/org visibility.\n\nA recap lives only in the database of the MCP that created it. A separately\nrunning local dev server (e.g. `http://localhost:8081`) has its OWN database and\nwill NOT contain a recap created through the hosted MCP, so a hand-built\n`localhost` link returns \"Plan not found\". This is the most common recap\nmistake \u2014 do not guess an origin you have not confirmed shares the MCP's data.\n\nResolve the URL in this order:\n\n1. Use the absolute URL the create tool RETURNS \u2014 `openLink.webUrl`, else the\n `visualUrl` in the returned `plan.mdx` frontmatter, else `url`/`path`\n resolved against the MCP server's own origin (for the hosted MCP that is\n `https://plan.agent-native.com`). This always points at the database that has\n the plan.\n2. Use a `localhost`/dev origin ONLY when the recap was created through a Plan\n MCP bound to that same origin \u2014 i.e. that MCP's url is\n `http://localhost:<port>/mcp`. Creating through the hosted MCP\n and linking to localhost is the exact mismatch that 404s.\n3. If only a plan id is available, build the MCP origin's absolute URL\n (hosted: `https://plan.agent-native.com/plans/<id>`) and say it was inferred.\n\nIf the user wants to review on localhost but the recap was created through the\nhosted MCP, say so plainly: the local dev server cannot see it. To view a recap\non localhost (e.g. to exercise un-deployed local renderer changes), they must\nconnect a LOCAL Plan MCP (`http://localhost:<port>/mcp`) and\nre-create the recap through it so it lands in the local database; offer to do\nthat rather than handing over a localhost URL that will not resolve.\n\nWhen running in Codex and the Browser/in-app side browser tools are available,\nopen the returned absolute recap URL there automatically after creation. Still\ninclude the same absolute URL in the final response. Local mirror files like\n`plans/<slug>/plan.mdx` may be mentioned only as secondary source-control\nartifacts, not as the main way to open the recap.\n\n## Diff \u2192 Block Mapping\n\nMap each kind of change to the block that carries it, derived mechanically from\nthe actual diff. The names below are the CONCEPTUAL block types, not the JSX\ntags \u2014 resolve every conceptual name to its exact tag + prop schema with the\n`get-plan-blocks` tool (see \"Block reference\" below) before authoring.\n\n- **Schema / migration change** \u2192 `data-model` for the resulting entities,\n fields, and relations. Flag what moved per field/entity with\n `change: \"added\" | \"modified\" | \"removed\" | \"renamed\"`, and for a changed type\n set `was` to the prior value (e.g. the old column type) \u2014 grounded in the real\n migration diff. That diff-aware `data-model` is the headline; reach for a split\n `diff` of the literal SQL only when the exact statement still matters, not by\n default.\n- **API / action / route change** \u2192 `api-endpoint` with the method, path,\n params, request, and responses as they are after the change. Flag each changed\n param/response with `change` (and `was` on a param whose type/shape changed),\n and set `change` on the endpoint root for a wholly added or removed route. Mark\n removed endpoints with `deprecated: true` and explain in prose.\n Keep multiple API endpoints in the normal single-column document flow unless\n they are an explicit before/after contract comparison.\n Author each request/response example as a SINGLE valid JSON value \u2014 one\n top-level object or array, parseable on its own \u2014 so it renders in the\n collapsible JSON explorer. Do not put `//` or `/* */` comments, prose,\n trailing commas, or two or more concatenated top-level objects inside one\n example; a non-parseable body falls back to flat text and loses the explorer.\n When an endpoint has several distinct message shapes (for example separate\n websocket frame types, or a success body versus an error body), give each its\n OWN example with its own label rather than cramming them into one body.\n- **Compatibility-sensitive change** \u2192 short `rich-text` notes beside the\n relevant `data-model` / `api-endpoint` block. Name the changed field,\n endpoint, or behavior and mark whether it is breaking, risky, or non-breaking;\n pair that note with a split `diff` for the literal lines.\n- **Any meaningful code hunk** \u2192 `diff` with `mode: \"split\"`, carrying the real\n `before` / `after` text and the `filename` / `language`. Split mode is the\n default for recap code review because before/after legibility is the point;\n use `mode: \"unified\"` only for a genuinely narrow standalone hunk where\n side-by-side would hide the code. Give every `diff` a one-line `summary`\n saying what the hunk changes and why; it renders as a description above the\n code so the reviewer reads intent first. Never leave a diff unlabeled.\n For the KEY changed files, attach `annotations` to the `diff` so the recap\n calls out what each important hunk does \u2014 this is the headline affordance for\n annotating the key files updated. Each annotation anchors to the AFTER-side\n line numbers by default (set `side: \"before\"` to point at removed lines). Keep\n it to a few high-signal notes per file, not one per line.\n When several key files each need a substantial diff, introduce the group with a\n `rich-text` heading block whose markdown is `## Key changes`, then place the\n `diff` blocks under it in a reusable `tabs` block with horizontal orientation\n (the default \u2014 omit `orientation`) so the selected file's split diff gets the\n full document width. Let that heading label the section \u2014 do NOT also set a\n `title` on the `tabs` block. Keep each tab label to the file path or a short\n basename plus directory hint.\n The renderer's wide document layout is intentionally allowlisted: `diff`,\n `annotated-code`, vertical `tabs`, and `tabs` containing diff-like children\n break out wider than prose. Do not put API endpoints, OpenAPI specs, data\n models, JSON explorers, wireframes, question forms, or custom HTML into tabs\n merely to make them wide.\n If the recap ends with more than one supporting diff, that trailing diff\n appendix should be one horizontal `tabs` block under its own `## Key changes`\n heading, not a stack of separate `diff` blocks.\n- **Brand-new file or a substantial added block with no meaningful \"before\"** \u2192\n `annotated-code` rather than a one-sided split `diff`. Carry the real new code\n with its `filename` / `language` and anchor a few high-signal notes to the lines\n that matter so the reviewer reads what the new code does, not code for code's\n sake. Keep split `diff` for true before/after hunks where the removed lines\n still carry meaning, and group several annotated walkthroughs in a horizontal\n `tabs` block the same way diffs are grouped.\n- **Files added / removed / renamed** \u2192 `file-tree` with each entry's `change`\n flag (`added`, `removed`, `modified`, `renamed`) and a short `note`; attach a\n `snippet` only when one tells the reviewer something the path does not.\n- **Rendered UI / interaction change** \u2192 one or more wireframes showing the\n visible UI delta before the reviewer reads code. Use `Before` / `After`\n wireframes when the comparison clarifies the change; otherwise use after-only\n or a short state/flow sequence. Use realistic UI surfaces: for a popover\n change, show a popover with its title row, top-right actions, options/fields,\n tabs, selected/disabled states, people/lists/rows, and any opened prompt/menu\n anchored to the correct trigger. If a route was added, show the route body and\n the unavailable/empty state when the diff implements one. If permissions\n changed, show what managers can do and what viewers/non-managers see instead.\n Keep the body lean: the wireframe carries the UI story, while the file tree\n and `diff` blocks carry implementation evidence.\n- **Architecture or data-flow shift** \u2192 `diagram` with `data.html` / `data.css`\n as a two-panel before/after, layered, or swimlane layout, or `mermaid` for a\n quick graph. Use two-dimensional layouts; do not reduce a structural change to\n a left-to-right chain. Do not use `diagram` as a stand-in for rendered UI\n controls; UI changes need `wireframe` blocks.\n Author diagram HTML/CSS with the renderer-owned `.diagram-*` primitives\n (`.diagram-panel`, `.diagram-node`, `.diagram-pill`, `[data-rough]`, \u2026) and\n the same `--wf-*` theme tokens `references/wireframe.md` defines \u2014 never\n `font-family`, hex, rgb/hsl literals, or one-off dark/light palettes. Choose\n the outer `frame` intentionally: recap diagrams usually benefit from\n `frame: \"show\"` when they stand alone, but use `frame: \"hide\"` when columns,\n tabs, a card, or the diagram's own panels already provide the boundary.\n- **Outcome-first narrative** \u2192 `rich-text` for the \"what changed and why\" prose:\n the objective the diff served, the key decisions visible in it, and the risks a\n reviewer should weigh. This is the only place the model writes freely.\n\n## Block reference \u2014 call `get-plan-blocks`, do not memorize tags\n\nThe conceptual block names above (`api-endpoint`, `data-model`, `json-explorer`,\n`tabs`, \u2026) are NOT the JSX tags you author with, and the exact tags, required\nfields, and prop shapes change as the block library evolves. Do not author from\nmemorized tags \u2014 they drift and silently produce a wrong tag (`ApiEndpoint`\ninstead of `Endpoint`, `JsonExplorer` instead of `Json`, `Tabs` instead of\n`TabsBlock`) that errors on import.\n\n**Before writing any structured plan content, fetch/read the block catalog.** In\nhosted or self-hosted mode, call `get-plan-blocks` on the Plan MCP connector\n(`plan` or legacy `agent-native-plans`). If no Plan tools are visible yet in a\nlazy-loading client, search/load them through the host's tool discovery surface\nfirst (`tool_search` when available). In local-files mode, or when the skill was\ninstalled as plain text and no MCP tools are registered after discovery, run\n`npx @agent-native/core@latest plan blocks --out plan-blocks.md` and read that\nfile first. The CLI command calls the public no-auth `get-plan-blocks` route and\nsends no plan/recap content. If network access is unavailable, use the bundled\nreferences and validate with `plan local check`; run `plan local serve` only\nwhen the hosted Plan UI is reachable or a local Plan app is already running.\n\nThe catalog returns the authoritative, always-current block vocabulary generated\nlive from the app's own block registry \u2014 the same config the renderer and MDX\nround-trip use \u2014 so it can never be stale even if this SKILL.md is an old\ninstalled copy:\n\n- `get-plan-blocks` (default `format: \"reference\"`) \u2192 a compact table of every\n block's runtime `type`, exact MDX `<Tag>`, placement, and key data fields.\n This is your map from each conceptual name above to its real tag and props.\n- `get-plan-blocks` with `format: \"schema\"` \u2192 the full per-block JSON Schema\n plus a worked example for each block, when you need exact field types,\n enums, or nesting (e.g. `Diff.annotations`, `Endpoint.params[].in`,\n `DataModel.entities[].fields[]`).\n\nAuthor the recap source against the tags and schemas that call returns. The\ncomplete set of valid block-level tags is whatever `get-plan-blocks` lists;\nany other capitalized tag at the block level is rejected on import with an\n\"Unknown plan block\" / \"did you mean\" error. Lowercase HTML tags inside\n`rich-text`/markdown prose (`<div>`, `<span>`, `<code>`, `<br>`, \u2026) are always\nfine \u2014 only capitalized component-style block tags are validated.\n\nA few recap-specific authoring rules the registry table cannot encode:\n\n- Every structured block takes a REQUIRED `id` (unique across the whole plan)\n plus the shared optional `summary` / `editable` envelope. Ordinary top-level\n Markdown prose imports as rich-text automatically; use `<RichText id=\"...\">`\n only when prose needs explicit metadata or a preserved referenced block id.\n- Every capitalized block component must be self-closing (`<Diagram ... />`) or\n explicitly closed around children (`<RichText ...>...</RichText>`). Never\n leave a bare opening tag like `<RichText ...>` in a paragraph; MDX treats it\n as unclosed JSX and import fails before the recap can render.\n- Code-bearing blocks (`Code`, `AnnotatedCode`, and `Diff`) are\n whitespace-sensitive. Prefer the exact MDX form from the `get-plan-blocks`\n examples / source exporter, where multiline code is encoded as JSON string\n attributes such as `code={\"const x =\\n y\"}`. Static template literals are\n accepted only when they are static strings with no `${...}` interpolation.\n- `Endpoint`: prose `description` is the MDX **children** (body between the\n tags), not an attribute; for a WebSocket upgrade use `method=\"GET\"`. Each\n request/response `example` is a JSON **string** (the renderer parses it into\n the JSON explorer), so keep it a single parseable JSON value.\n- `TabsBlock`: the whole `tabs` array (including nested child blocks) is ONE\n JSON `tabs={[\u2026]}` prop \u2014 there is NO nested `<Tab>` element.\n- `WireframeBlock`: its body is a single `<Screen surface ... html=\u2026 />` subtree\n (nested MDX, not a flat prop); `html` must be a single-quoted string or static\n template literal, never a dynamic `html={someVar}` expression. See\n `references/wireframe.md` for the HTML rules.\n- `Diagram`: the whole payload is one `data={{ html?, css?, nodes?, edges?, \u2026 }}`\n attribute and requires either `html` or at least one node; `Mermaid` is its\n own separate block (`source` text), not a `Diagram` prop.\n\n## Before / After Is The Headline\n\nThe recap's center of gravity is the before/after comparison. For document-body\ncomparisons there are two primitives, and they cover the whole need together:\n\n- **`columns`** \u2014 the side-by-side container, for **structured** comparisons.\n Use two columns labeled `Before` and `After`, each holding a block (commonly a\n `data-model`, `api-endpoint`, or `rich-text`), so the reviewer reads the old\n shape against the new shape in one glance. This is the right primitive for\n \"the schema went from X to Y\" or \"the endpoint contract changed like this.\"\n Do not use `columns` simply to compact or group a list of API endpoints.\n- **`diff`** \u2014 for **code**. It renders the literal removed and added lines. Use\n it for the actual hunks. Use split mode by default for recap code review;\n reserve `mode: \"unified\"` for genuinely narrow standalone hunks where\n side-by-side would hide the code. Key-file diff groups should use horizontal\n tabs so split diffs get the full document width.\n\nFor UI diffs, wireframes are the visual comparison primitive. Use before/after\nwireframes when the comparison clarifies the change; use after-only or a state\nsequence when that better matches the change. The visual headline must show\nexact placement, realistic chrome, and adequate padding before any abstract\nexplanation. Do not stop at the first visible affordance when the diff adds a\nflow; show the entry point, the opened surface, and the resulting state or page\nso the reviewer can trace the actual user path. `references/wireframe.md` owns\nthe before/after layout choice \u2014\nthe `columns` renderer keeps narrow surfaces side by side and auto-stacks wide\n`desktop`/`browser` frames vertically; never hand-build a side-by-side\nwireframe layout in `custom-html`. For document-body\ncomparisons, there is no other multi-column primitive \u2014 `columns` plus the\n`diff` block are the whole comparison vocabulary. Do not hand-build side-by-side\nlayouts in `custom-html`, and do not stack two `data-model` blocks vertically\nand call it a comparison when `columns` exists to put them side by side.\n\n## Grounding Rule\n\nStructured blocks are **true by construction** only if they are derived from the\nactual changed lines. The `diff`, `data-model`, `api-endpoint`, and `file-tree`\nblocks MUST be built mechanically from the real diff \u2014 real paths, real fields,\nreal method/path, real before/after text \u2014 never inferred, rounded, or invented.\nThe model writes only the prose: the \"why\", the narrative, the risk read. A\nconfidently wrong recap is dangerous in a review context, because a reviewer who\ntrusts the summary may skip the very line the summary got wrong. When the diff\ndoes not contain a fact, leave it out rather than guess; mark anything the model\ninferred (not extracted) as inferred in prose.\n\n## Security\n\n- **Gate visibility.** Recaps of a private repo are org/login-gated \u2014 set the\n plan's visibility to the owning org or login, never auto-public. A recap can\n expose unreleased schema, internal endpoints, and architecture; treat it like\n the source it summarizes. Any PR comment or handoff that links to the recap\n must say that private-repo recaps require signing in with access to the owning\n org if the link does not load.\n- **Never transcribe secrets.** A diff can contain API keys, tokens, webhook\n URLs, signing secrets, `.env` values, or credential-looking literals. Do not\n copy any of these into a `diff`, `file-tree` snippet, `api-endpoint`, or prose\n block \u2014 redact them (`sk-\u2022\u2022\u2022`, `<redacted>`). This mirrors the repo's\n hardcoded-secret rule: obviously fake placeholders only, never the real value,\n in any block, caption, or note.\n\n## Bidirectional Loop\n\nIn hosted mode, because a recap is a real, editable plan, the same review loop\nas forward plans applies: a reviewer can annotate any block, and the coding\nagent reads `get-plan-feedback` to drive fixes back into the code \u2014 annotation \u2192\nagent \u2192 diff, the same close-the-loop flow forward plans use. After a reviewer\nannotates a block, call `get-plan-feedback` to read the structured feedback,\nthen either update the recap with `create-visual-recap` (passing the existing\n`planId` to replace it in place) or apply targeted changes with\n`update-visual-plan`. The loop is live and wired. In local-files privacy mode,\ndo not call those hosted tools; read review notes from chat or local files, edit\n`<plan-dir>/*.mdx` directly, and rerun `plan local check`, `serve`, or `verify`\nfor `<plan-dir>`. The one thing not yet automatic is PR-comment-triggered\nre-runs: the GitHub Action creates an initial recap per PR, but it does not yet\nre-run automatically when new review feedback is posted in GitHub \u2014 that\nauto-re-run is the remaining fast-follow.\n\n## Related Skills\n\n- **visual-plan** \u2014 the canonical command and the source of the shared Wireframe\n & Canvas and Document Quality cores; a recap follows the same block discipline\n in reverse.\n- **comment anchors** \u2014 recap comments use the same anchor rules as forward\n plans; see \"Interpreting comment anchors\" in the visual-plan skill for\n coordinate frames, wireframe node ids, text-quote resolution, detached\n threads, routing via `resolutionTarget`, and two-axis consumed/resolved state.\n- **security** \u2014 data scoping, secret handling, and the hardcoded-secret rule the\n recap's redaction and visibility gating mirror.\n- **sharing** \u2014 org/login-gated visibility for the plan that holds the recap.\n";
2
2
  //# sourceMappingURL=visual-recap-skill.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"visual-recap-skill.d.ts","sourceRoot":"","sources":["../../src/skill-content/visual-recap-skill.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,qBAAqB,2ziCAkiBjC,CAAC"}
1
+ {"version":3,"file":"visual-recap-skill.d.ts","sourceRoot":"","sources":["../../src/skill-content/visual-recap-skill.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,qBAAqB,kqjCAwiBjC,CAAC"}
@@ -133,6 +133,10 @@ Budgets that keep the recap reviewable:
133
133
  tab; summarize or link the rest of a long file instead of dumping it.
134
134
  - Title at most ~70 characters; brief 1-3 sentences.
135
135
 
136
+ These budgets are also the cost ceiling: do not exceed them in the name of
137
+ thoroughness, and do not re-read the full diff after the initial sequential
138
+ pass — work from the notes taken during that pass.
139
+
136
140
  **GOOD.** A 25-file auth change: Before/After wireframes of the login surface,
137
141
  a two-paragraph narrative, a diff-aware \`data-model\` of the sessions table, an
138
142
  \`api-endpoint\` for the new refresh route, a \`file-tree\` with change flags, and
@@ -213,7 +217,9 @@ sketchy rough overlay.
213
217
  When a browser tool is available, render a UI-impact recap in the Plan viewer
214
218
  and visually inspect it at the current theme before sharing. If any label,
215
219
  annotation, toolbar, or wireframe content overlaps another element, fix the MDX
216
- and re-import before reporting the link. A text-match screenshot is not enough;
220
+ and re-import before reporting the link. Limit this to one render-and-inspect
221
+ pass plus at most one fix-and-re-render; do not keep iterating beyond that
222
+ unless the user explicitly asks. A text-match screenshot is not enough;
217
223
  visually inspect the captured image. When no browser is available (for example
218
224
  a headless CI agent), state that in the recap handoff instead.
219
225