@agent-native/core 0.44.1 → 0.44.4
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.
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +28 -13
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +1 -1
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/cli/skills.d.ts +5 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +651 -21
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/AnnotatedCodeBlock.js +14 -3
- package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
- package/dist/client/blocks/library/ApiEndpointBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/ApiEndpointBlock.js +11 -7
- package/dist/client/blocks/library/ApiEndpointBlock.js.map +1 -1
- package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/DiffBlock.js +89 -5
- package/dist/client/blocks/library/DiffBlock.js.map +1 -1
- package/dist/client/blocks/library/HighlightedCode.d.ts +2 -1
- package/dist/client/blocks/library/HighlightedCode.d.ts.map +1 -1
- package/dist/client/blocks/library/HighlightedCode.js +2 -2
- package/dist/client/blocks/library/HighlightedCode.js.map +1 -1
- package/dist/client/blocks/library/JsonExplorerBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/JsonExplorerBlock.js +57 -13
- package/dist/client/blocks/library/JsonExplorerBlock.js.map +1 -1
- package/dist/client/blocks/library/code.d.ts.map +1 -1
- package/dist/client/blocks/library/code.js +27 -6
- package/dist/client/blocks/library/code.js.map +1 -1
- package/dist/client/blocks/library/columns.d.ts.map +1 -1
- package/dist/client/blocks/library/columns.js +11 -1
- package/dist/client/blocks/library/columns.js.map +1 -1
- package/dist/client/blocks/library/narrow-container.d.ts +13 -0
- package/dist/client/blocks/library/narrow-container.d.ts.map +1 -0
- package/dist/client/blocks/library/narrow-container.js +32 -0
- package/dist/client/blocks/library/narrow-container.js.map +1 -0
- package/dist/client/blocks/library/question-form.d.ts.map +1 -1
- package/dist/client/blocks/library/question-form.js +8 -9
- package/dist/client/blocks/library/question-form.js.map +1 -1
- package/dist/client/blocks/library/tabs.d.ts.map +1 -1
- package/dist/client/blocks/library/tabs.js +6 -5
- package/dist/client/blocks/library/tabs.js.map +1 -1
- package/dist/client/blocks/types.d.ts +2 -0
- package/dist/client/blocks/types.d.ts.map +1 -1
- package/dist/client/blocks/types.js.map +1 -1
- package/dist/client/context-xray/ContextMeter.d.ts.map +1 -1
- package/dist/client/context-xray/ContextMeter.js +5 -3
- package/dist/client/context-xray/ContextMeter.js.map +1 -1
- package/dist/db/client.d.ts +11 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +20 -0
- package/dist/db/client.js.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +9 -2
- package/dist/db/migrations.js.map +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +23 -8
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/styles/blocks.css +36 -10
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +26 -3
- package/dist/vite/client.js.map +1 -1
- package/package.json +1 -1
|
@@ -7,5 +7,5 @@
|
|
|
7
7
|
* recap.spec.ts fails if these drift. Regenerate from the YAML with the snippet
|
|
8
8
|
* in recap.spec.ts.
|
|
9
9
|
*/
|
|
10
|
-
export declare const PR_VISUAL_RECAP_WORKFLOW_YML = "name: PR Visual Recap\n\n# Turns every PR into a \"visual code review\" \u2014 a reverse plan \u2014 by letting a real\n# coding agent RUN THE REPO'S visual-recap SKILL against the diff. The agent\n# (Claude Code by default, or Codex) reads the skill, reasons over the change,\n# publishes an Agent-Native Plan via the plan MCP tools, and writes the plan URL\n# to recap-url.txt. The workflow then screenshots that plan in headless Chrome,\n# uploads the PNG to the plan app's signed public image route, and upserts ONE\n# sticky PR comment with the inline screenshot + the interactive link.\n#\n# Design notes:\n# - Plain `pull_request` (NOT `pull_request_target`) so fork code can never see\n# the publish/agent secrets. Fork PRs are a silent no-op.\n# - The `gate` job is a cheap switch: drafts, forks, bot authors, and the\n# missing-secret case short-circuit with NO comment and NO compute. Merging\n# this workflow before the secrets exist is a safe no-op.\n# - The recap is INFORMATIONAL ONLY. It is not a required check and failures\n# surface as an explanatory sticky comment, never a red X on unrelated code.\n# - Backend is selectable with the `VISUAL_RECAP_AGENT` repo variable\n# (claude | codex; default claude). Model and reasoning depth are tunable with\n# `VISUAL_RECAP_MODEL` (e.g. gpt-5.5) and `VISUAL_RECAP_REASONING`\n# (none|minimal|low|medium|high|xhigh; Codex only). The CLI invocation is\n# auto-detected: local source inside this monorepo, the published\n# @agent-native/core elsewhere \u2014 no repo variable needed.\n# - Only two secrets are required: PLAN_RECAP_TOKEN (publish) and the chosen\n# backend's API key. PLAN_RECAP_APP_URL defaults to the hosted plan app.\n# - Nothing here is deterministic: the skill's instructions drive the recap.\n\non:\n # Run on PRs into any base branch \u2014 the generated workflow ships to repos whose\n # default branch may not be `main`. The gate job below still no-ops drafts,\n # forks, bots, and the missing-secret case, so this stays cheap.\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n\npermissions:\n contents: read\n issues: write\n pull-requests: write\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\njobs:\n # --------------------------------------------------------------------------\n # Cheap gate: decide whether to do any work at all. Sets run=false (silent\n # no-op) for drafts, forks, bot authors, or when the publish secret / the\n # chosen backend's API key is absent.\n # --------------------------------------------------------------------------\n gate:\n name: Gate\n runs-on: ubuntu-latest\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n steps:\n - id: decide\n uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7\n env:\n # Presence-only signals \u2014 we never expose the secret VALUES to the gate.\n # PLAN_RECAP_APP_URL defaults to the hosted app, so only the token is required.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != '' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != '' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != '' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n 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\n // Fork PRs: head repo differs from this repo. Plain pull_request runs\n // fork code with NO secrets, so publishing would fail anyway \u2014 skip.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n if (pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY) {\n reasons.push(`fork PR (${headRepo})`);\n }\n\n // Skip noisy automated authors.\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 // Publish secret must be configured \u2014 otherwise this is a no-op so the\n // workflow can be merged before secrets exist.\n if (process.env.HAS_PLAN !== 'true') reasons.push('PLAN_RECAP_TOKEN not configured');\n\n // The chosen backend's API key must be present. Normalize the agent\n // value once here and validate it: an unknown or mis-cased value\n // (e.g. \"Claude\", \"gpt\") must NOT silently pass the gate and then\n // match neither agent step below.\n const agent = (process.env.AGENT || 'claude').toLowerCase();\n if (agent !== 'claude' && agent !== 'codex') {\n reasons.push(`unsupported VISUAL_RECAP_AGENT \"${process.env.AGENT}\" (expected \"claude\" or \"codex\")`);\n } else if (agent === 'codex') {\n if (process.env.HAS_OPENAI !== 'true') reasons.push('OPENAI_API_KEY not configured (codex backend)');\n } else {\n if (process.env.HAS_ANTHROPIC !== 'true') reasons.push('ANTHROPIC_API_KEY not configured (claude backend)');\n }\n\n // Self-modifying guard, evaluated in the GATE (trusted github-script\n // that runs NO PR-checked-out code): if this PR changes the workflow,\n // the visual-recap/visual-plan skill, the local CLI (packages/core),\n // or any agent config the runner would load (.claude/**, CLAUDE.md,\n // .mcp.json), skip the ENTIRE job \u2014 not just the agent \u2014 so a PR can\n // never rewrite what runs (skill, hooks, settings, CLI) and exfiltrate\n // the publish/API secrets.\n if (pr) {\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 p === '.github/workflows/pr-visual-recap.yml' ||\n /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p) ||\n /(^|\\/)\\.claude\\//.test(p) ||\n /(^|\\/)CLAUDE\\.md$/.test(p) ||\n /(^|\\/)AGENTS\\.md$/.test(p) ||\n /(^|\\/)\\.mcp\\.json$/.test(p) ||\n /(^|\\/)packages\\/core\\//.test(p);\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', \u2026' : ''}) \u2014 skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n 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 // Export the NORMALIZED agent so the recap job's step conditions match\n // case-insensitively via needs.gate.outputs.agent.\n core.setOutput('agent', agent);\n core.info(run ? `Visual recap will run (${agent}).` : `Visual recap skipped: ${reasons.join('; ')}`);\n\n # --------------------------------------------------------------------------\n # Recap: collect the diff, let the agent run the skill + publish, screenshot\n # the result, and upsert the sticky comment.\n # --------------------------------------------------------------------------\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == 'true'\n runs-on: ubuntu-latest\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 HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n steps:\n - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1\n with:\n fetch-depth: 0\n\n # Resolve the CLI invocation once: dogfood local source inside this\n # monorepo, otherwise the published package. No repo variable needed. The\n # pnpm setup/install steps below run ONLY for the local-source path, so the\n # generated workflow works out-of-box in npm/yarn consumer repos (which\n # have no pnpm-lock.yaml) by falling back to `npx @agent-native/core`.\n - name: Resolve recap CLI\n id: cli\n run: |\n if [ -f packages/core/src/cli/index.ts ]; then\n echo \"RECAP_CLI=pnpm exec tsx packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\n echo \"local=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"RECAP_CLI=npx -y @agent-native/core@latest\" >> \"$GITHUB_ENV\"\n echo \"local=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0\n if: steps.cli.outputs.local == 'true'\n\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0\n with:\n node-version: \"22\"\n cache: ${{ steps.cli.outputs.local == 'true' && 'pnpm' || '' }}\n\n - name: Install workspace (local source only)\n if: steps.cli.outputs.local == 'true'\n run: pnpm install --frozen-lockfile --ignore-scripts\n\n # Collect a BOUNDED diff between the PR base and head. We exclude lockfiles,\n # build output, and snapshots (noise), and cap the byte size \u2014 over the cap\n # we set `huge=true` so the agent is told to produce a summarized recap.\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 git diff --no-color \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\n . \\\n ':(exclude)pnpm-lock.yaml' \\\n ':(exclude)**/dist/**' \\\n ':(exclude)**/*.snap' \\\n ':(exclude)**/*.lock' \\\n > recap.diff || true\n git diff --stat --no-color \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\n . \\\n ':(exclude)pnpm-lock.yaml' \\\n ':(exclude)**/dist/**' \\\n ':(exclude)**/*.snap' \\\n ':(exclude)**/*.lock' \\\n > recap.stat || true\n\n BYTES=$(wc -c < recap.diff | tr -d ' ')\n CHANGED=$(git diff --name-only \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\n . \\\n ':(exclude)pnpm-lock.yaml' \\\n ':(exclude)**/dist/**' \\\n ':(exclude)**/*.snap' \\\n ':(exclude)**/*.lock' \\\n | wc -l | tr -d ' ')\n echo \"bytes=$BYTES\" >> \"$GITHUB_OUTPUT\"\n echo \"changed=$CHANGED\" >> \"$GITHUB_OUTPUT\"\n\n # ~600KB cap. Over the cap we both flag `huge` (so the agent is told to\n # summarize) AND physically truncate recap.diff, so an oversized diff\n # cannot overflow the agent's prompt budget when it reads the file.\n # Truncate at a COMPLETE LINE boundary (`sed '$d'` drops the last,\n # possibly-partial, line) so the byte cap can never cut a multi-byte\n # UTF-8 char or a diff line mid-way and corrupt the agent's input.\n if [ \"$BYTES\" -gt 614400 ]; then\n echo \"huge=true\" >> \"$GITHUB_OUTPUT\"\n head -c 614400 recap.diff | sed '$d' > recap.diff.capped && mv recap.diff.capped recap.diff\n printf '\\n\\n[diff truncated at 600KB for the recap agent]\\n' >> recap.diff\n else\n echo \"huge=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n # Tiny diffs (<= 1 changed file AND <= 8 changed lines) aren't worth a\n # recap \u2014 skip generation cleanly.\n LINES=$(grep -cE '^[+-]' recap.diff || true)\n if [ \"$CHANGED\" -le 1 ] && [ \"${LINES:-0}\" -le 8 ]; then\n echo \"tiny=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"tiny=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n # Secret pre-scan: refuse to hand a diff that looks like it leaks\n # credentials to the agent. Prints { suppressed, reason } and always exits 0.\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != 'true'\n run: |\n set -uo pipefail\n # Fail CLOSED: if the scanner errors or emits invalid JSON, treat the\n # diff as suppressed, so a scan failure can never hand a possibly\n # credential-bearing diff to the agent / plan service.\n if ! SCAN_JSON=\"$($RECAP_CLI recap scan --diff recap.diff)\"; then\n SCAN_JSON='{\"suppressed\":true,\"reason\":\"secret scan failed to run; failing closed\"}'\n fi\n # Multi-line-safe write: SCAN_JSON could contain newlines, which would\n # otherwise corrupt $GITHUB_OUTPUT or inject extra keys.\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 # Find the planId from the previous sticky comment so a re-push REPLACES the\n # same hosted plan (synchronize updates in place, no orphaned plans).\n - name: Read previous plan id\n id: prev\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 # Build the agent prompt = the repo's visual-recap SKILL.md + a task wrapper.\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != 'true' && steps.scan.outputs.suppressed != 'true'\n run: |\n set -euo pipefail\n PREV=\"\"\n if [ -n \"${{ steps.prev.outputs.plan_id }}\" ]; then PREV=\"--prev-plan-id ${{ steps.prev.outputs.plan_id }}\"; fi\n HUGE=\"\"\n if [ \"${{ steps.diff.outputs.huge }}\" = \"true\" ]; then HUGE=\"--huge\"; fi\n $RECAP_CLI recap build-prompt \\\n --diff recap.diff --stat recap.stat \\\n --pr \"$PR_NUMBER\" --head \"$HEAD_SHA\" \\\n --app-url \"$PLAN_RECAP_APP_URL\" \\\n --out recap-prompt.md \\\n $HUGE $PREV\n\n # Wire the plan MCP server for the chosen backend, then run the agent. The\n # agent follows the skill, calls create-visual-recap + set-resource-visibility,\n # and writes the published plan URL to recap-url.txt. continue-on-error so a\n # failed agent run becomes an explanatory comment, not a red X.\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == 'claude' && steps.diff.outputs.tiny != '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 MCP_CONFIG=\"$RUNNER_TEMP/plan-mcp.json\"\n node -e 'const fs=require(\"fs\");fs.writeFileSync(process.argv[1],JSON.stringify({mcpServers:{plan:{type:\"http\",url:process.env.PLAN_RECAP_APP_URL.replace(/\\/$/,\"\")+\"/_agent-native/mcp\",headers:{Authorization:\"Bearer \"+process.env.PLAN_RECAP_TOKEN}}}}))' \"$MCP_CONFIG\"\n # VISUAL_RECAP_MODEL picks the Claude model; reasoning depth is model-driven\n # for Claude Code, so VISUAL_RECAP_REASONING only applies to the Codex backend.\n CLAUDE_ARGS=(-p \"$(cat recap-prompt.md)\" --mcp-config \"$MCP_CONFIG\" --allowedTools \"Read,Write,Bash(git diff:*),mcp__plan__create-visual-recap,mcp__plan__set-resource-visibility\" --permission-mode dontAsk --output-format json)\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CLAUDE_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\n # Capture the final JSON result (usage + total_cost_usd) for the usage step.\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-result.json || true\n rm -f \"$MCP_CONFIG\" || true\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == 'codex' && steps.diff.outputs.tiny != '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 mkdir -p \"$HOME/.codex\"\n # JSON.stringify the URL into the TOML value so a stray quote/newline\n # in PLAN_RECAP_APP_URL can't break out of the string (TOML basic\n # strings share JSON's escaping); the key/env name stay literal.\n node -e 'const fs=require(\"fs\");const url=process.env.PLAN_RECAP_APP_URL.replace(/\\/$/,\"\")+\"/_agent-native/mcp\";fs.writeFileSync(process.env.HOME+\"/.codex/config.toml\",\"[mcp_servers.plan]\\nurl = \"+JSON.stringify(url)+\"\\nbearer_token_env_var = \\\"PLAN_RECAP_TOKEN\\\"\\n\")'\n # Authenticate with the API key explicitly. Relying on the bare\n # OPENAI_API_KEY env var alone is unreliable on the gpt-5.5 WebSocket\n # transport: the Authorization header is dropped on the wss path and\n # its HTTPS fallback, surfacing as `401 Missing bearer or basic\n # authentication in header` (openai/codex#15492). `codex login\n # --with-api-key` reads the key from stdin and writes ~/.codex/auth.json,\n # which the exec path reads reliably; piping via stdin keeps the key out\n # of the process args. Non-fatal so a login hiccup still yields the\n # explanatory recap comment rather than a red X.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # VISUAL_RECAP_MODEL (e.g. gpt-5.5) and VISUAL_RECAP_REASONING\n # (none|minimal|low|medium|high|xhigh) tune the Codex run.\n #\n # The GitHub runner is itself an ephemeral, throwaway sandbox, so run\n # Codex with sandboxing and approvals disabled. Codex's own bubblewrap\n # sandbox cannot initialize on the runner (\"could not find bubblewrap\n # on PATH\"), which makes every shell command fail at startup so the\n # agent cannot even read recap.diff; and under an approval gate the\n # write-side plan MCP call (create-visual-recap) is auto-cancelled\n # (\"user cancelled MCP tool call\"). --dangerously-bypass-approvals-and-sandbox\n # is the documented invocation for externally-sandboxed CI and clears both.\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 known enum before embedding it in the\n # codex `-c` TOML override, so an unexpected value can't alter the config.\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 npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-prompt.md)\" | tee codex-events.jsonl || true\n\n # The agent's only hand-off: recap-url.txt with the published plan URL.\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != 'true' && steps.scan.outputs.suppressed != 'true'\n run: |\n set -uo pipefail\n PLAN_URL=\"\"\n if [ -f recap-url.txt ]; then PLAN_URL=\"$(tr -d '\\r\\n' < recap-url.txt | tr -d ' ')\"; fi\n echo \"plan_url=$PLAN_URL\" >> \"$GITHUB_OUTPUT\"\n if [ -n \"$PLAN_URL\" ]; then echo \"ok=true\" >> \"$GITHUB_OUTPUT\"; else echo \"ok=false\" >> \"$GITHUB_OUTPUT\"; fi\n\n # Capture the agent run token usage and attach it to the published recap\n # so the recap row carries input/output/cached tokens, the model, and a\n # cost estimate. Informational and best-effort: never fails the job.\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 run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ \"$VISUAL_RECAP_AGENT\" = \"codex\" ]; then RESULT=codex-events.jsonl; fi\n if [ -f \"$RESULT\" ]; then $RECAP_CLI recap usage --plan-url \"$PLAN_URL\" --agent \"$VISUAL_RECAP_AGENT\" --result-file \"$RESULT\" --model \"${VISUAL_RECAP_MODEL:-}\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\" || true; fi\n\n # Screenshot the published plan in headless Chrome and upload the PNG to the\n # plan app's signed public image route. Best-effort: never fails the job.\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == 'true'\n continue-on-error: true\n env:\n # Pass the agent-produced plan URL through the environment, never via\n # ${{ }} interpolation into the run script: recap-url.txt is untrusted\n # agent output, so inlining it would be a shell-injection vector.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n SHOT_JSON=\"$($RECAP_CLI recap shot --url \"$PLAN_URL\" --token \"$PLAN_RECAP_TOKEN\" --app-url \"$PLAN_RECAP_APP_URL\" --out recap.png || echo '{}')\"\n IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||\"\")}catch{process.stdout.write(\"\")}' \"$SHOT_JSON\")\n echo \"image_url=$IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\n if [ -f recap.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: recap.png\n if-no-files-found: ignore\n retention-days: 14\n\n # Upsert the single sticky comment: inline screenshot + link on success,\n # suppressed / failed / tiny variants otherwise. Runs even on a tiny diff\n # so a prior recap comment is refreshed (not left pointing at a stale SHA).\n - name: Upsert sticky comment\n if: always()\n # The recap is informational/non-blocking: a failed comment upsert must\n # not turn the whole job red (the agent + screenshot steps are already\n # continue-on-error).\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 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 run: |\n set -euo pipefail\n ARGS=(recap comment upsert --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\")\n # On a tiny diff, only REFRESH an existing recap comment \u2014 never create\n # a new one \u2014 so we add no noise but also can't leave a stale prior\n # recap behind.\n if [ \"${DIFF_TINY:-}\" = \"true\" ]; then ARGS+=(--update-only); fi\n $RECAP_CLI \"${ARGS[@]}\"\n";
|
|
10
|
+
export declare const PR_VISUAL_RECAP_WORKFLOW_YML = "name: PR Visual Recap\n\n# Turns every PR into a \"visual code review\" \u2014 a reverse plan \u2014 by letting a real\n# coding agent RUN THE REPO'S visual-recap SKILL against the diff. The agent\n# (Claude Code by default, or Codex) reads the skill, reasons over the change,\n# publishes an Agent-Native Plan via the plan MCP tools, and writes the plan URL\n# to recap-url.txt. The workflow then screenshots that plan in headless Chrome,\n# uploads the PNG to the plan app's signed public image route, and upserts ONE\n# sticky PR comment with the inline screenshot + the interactive link.\n#\n# Design notes:\n# - Plain `pull_request` (NOT `pull_request_target`) so fork code can never see\n# the publish/agent secrets. Fork PRs are a silent no-op.\n# - The `gate` job is a cheap switch: drafts, forks, bot authors, and the\n# missing-secret case short-circuit with NO comment and NO compute. Merging\n# this workflow before the secrets exist is a safe no-op.\n# - The recap is INFORMATIONAL ONLY. It is not a required check and failures\n# surface as an explanatory sticky comment, never a red X on unrelated code.\n# - Backend is selectable with the `VISUAL_RECAP_AGENT` repo variable\n# (claude | codex; default claude). Model and reasoning depth are tunable with\n# `VISUAL_RECAP_MODEL` (e.g. gpt-5.5) and `VISUAL_RECAP_REASONING`\n# (none|minimal|low|medium|high|xhigh; Codex only). The CLI invocation is\n# auto-detected: local source inside this monorepo, the published\n# @agent-native/core elsewhere \u2014 no repo variable needed.\n# - Only two secrets are required: PLAN_RECAP_TOKEN (publish) and the chosen\n# backend's API key. PLAN_RECAP_APP_URL defaults to the hosted plan app.\n# - Nothing here is deterministic: the skill's instructions drive the recap.\n\non:\n # Run on PRs into any base branch \u2014 the generated workflow ships to repos whose\n # default branch may not be `main`. The gate job below still no-ops drafts,\n # forks, bots, and the missing-secret case, so this stays cheap.\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n\npermissions:\n contents: read\n issues: write\n pull-requests: write\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\njobs:\n # --------------------------------------------------------------------------\n # Cheap gate: decide whether to do any work at all. Sets run=false (silent\n # no-op) for drafts, forks, bot authors, or when the publish secret / the\n # chosen backend's API key is absent.\n # --------------------------------------------------------------------------\n gate:\n name: Gate\n runs-on: ubuntu-latest\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n steps:\n - id: decide\n uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7\n env:\n # Presence-only signals \u2014 we never expose the secret VALUES to the gate.\n # PLAN_RECAP_APP_URL defaults to the hosted app, so only the token is required.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != '' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != '' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != '' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n 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\n // Fork PRs: head repo differs from this repo. Plain pull_request runs\n // fork code with NO secrets, so publishing would fail anyway \u2014 skip.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n if (pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY) {\n reasons.push(`fork PR (${headRepo})`);\n }\n\n // Skip noisy automated authors.\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 // Publish secret must be configured \u2014 otherwise this is a no-op so the\n // workflow can be merged before secrets exist.\n if (process.env.HAS_PLAN !== 'true') reasons.push('PLAN_RECAP_TOKEN not configured');\n\n // The chosen backend's API key must be present. Normalize the agent\n // value once here and validate it: an unknown or mis-cased value\n // (e.g. \"Claude\", \"gpt\") must NOT silently pass the gate and then\n // match neither agent step below.\n const agent = (process.env.AGENT || 'claude').toLowerCase();\n if (agent !== 'claude' && agent !== 'codex') {\n reasons.push(`unsupported VISUAL_RECAP_AGENT \"${process.env.AGENT}\" (expected \"claude\" or \"codex\")`);\n } else if (agent === 'codex') {\n if (process.env.HAS_OPENAI !== 'true') reasons.push('OPENAI_API_KEY not configured (codex backend)');\n } else {\n if (process.env.HAS_ANTHROPIC !== 'true') reasons.push('ANTHROPIC_API_KEY not configured (claude backend)');\n }\n\n // Validate VISUAL_RECAP_MODEL if set \u2014 an unchecked value could be injected\n // by a repo settings writer and passed straight to 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 // Self-modifying guard, evaluated in the GATE (trusted github-script\n // that runs NO PR-checked-out code): if this PR changes the workflow,\n // the visual-recap/visual-plan skill, the local CLI (packages/core),\n // or any agent config the runner would load (.claude/**, CLAUDE.md,\n // .mcp.json), skip the ENTIRE job \u2014 not just the agent \u2014 so a PR can\n // never rewrite what runs (skill, hooks, settings, CLI) and exfiltrate\n // the publish/API secrets.\n if (pr) {\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 p === '.github/workflows/pr-visual-recap.yml' ||\n /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p) ||\n /(^|\\/)\\.claude\\//.test(p) ||\n /(^|\\/)CLAUDE\\.md$/.test(p) ||\n /(^|\\/)AGENTS\\.md$/.test(p) ||\n /(^|\\/)\\.mcp\\.json$/.test(p) ||\n /(^|\\/)packages\\/core\\//.test(p);\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', \u2026' : ''}) \u2014 skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n 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 // Export the NORMALIZED agent so the recap job's step conditions match\n // case-insensitively via needs.gate.outputs.agent.\n core.setOutput('agent', agent);\n core.info(run ? `Visual recap will run (${agent}).` : `Visual recap skipped: ${reasons.join('; ')}`);\n\n # --------------------------------------------------------------------------\n # Recap: collect the diff, let the agent run the skill + publish, screenshot\n # the result, and upsert the sticky comment.\n # --------------------------------------------------------------------------\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == 'true'\n runs-on: ubuntu-latest\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 HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n steps:\n - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1\n with:\n fetch-depth: 0\n\n # Resolve the CLI invocation once: dogfood local source inside this\n # monorepo, otherwise the published package. No repo variable needed. The\n # pnpm setup/install steps below run ONLY for the local-source path, so the\n # generated workflow works out-of-box in npm/yarn consumer repos (which\n # have no pnpm-lock.yaml) by falling back to `npx @agent-native/core`.\n - name: Resolve recap CLI\n id: cli\n run: |\n if [ -f packages/core/src/cli/index.ts ]; then\n echo \"RECAP_CLI=pnpm exec tsx packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\n echo \"local=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"RECAP_CLI=npx -y @agent-native/core@latest\" >> \"$GITHUB_ENV\"\n echo \"local=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0\n if: steps.cli.outputs.local == 'true'\n\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0\n with:\n node-version: \"22\"\n cache: ${{ steps.cli.outputs.local == 'true' && 'pnpm' || '' }}\n\n - name: Install workspace (local source only)\n if: steps.cli.outputs.local == 'true'\n run: pnpm install --frozen-lockfile --ignore-scripts\n\n # Collect a BOUNDED diff between the PR base and head. We exclude lockfiles,\n # build output, and snapshots (noise), and cap the byte size \u2014 over the cap\n # we set `huge=true` so the agent is told to produce a summarized recap.\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 git diff --no-color \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\n . \\\n ':(exclude)pnpm-lock.yaml' \\\n ':(exclude)**/dist/**' \\\n ':(exclude)**/*.snap' \\\n ':(exclude)**/*.lock' \\\n > recap.diff || true\n git diff --stat --no-color \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\n . \\\n ':(exclude)pnpm-lock.yaml' \\\n ':(exclude)**/dist/**' \\\n ':(exclude)**/*.snap' \\\n ':(exclude)**/*.lock' \\\n > recap.stat || true\n\n BYTES=$(wc -c < recap.diff | tr -d ' ')\n CHANGED=$(git diff --name-only \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\n . \\\n ':(exclude)pnpm-lock.yaml' \\\n ':(exclude)**/dist/**' \\\n ':(exclude)**/*.snap' \\\n ':(exclude)**/*.lock' \\\n | wc -l | tr -d ' ')\n echo \"bytes=$BYTES\" >> \"$GITHUB_OUTPUT\"\n echo \"changed=$CHANGED\" >> \"$GITHUB_OUTPUT\"\n\n # Count changed lines on the ORIGINAL diff (before any byte-cap truncation),\n # so a large diff is never misclassified as tiny after truncation.\n ORIGINAL_LINES=$(grep -cE '^[+-]' recap.diff || true)\n\n # ~600KB cap. Over the cap we both flag `huge` (so the agent is told to\n # summarize) AND physically truncate recap.diff, so an oversized diff\n # cannot overflow the agent's prompt budget when it reads the file.\n # Truncate at a COMPLETE LINE boundary (`sed '$d'` drops the last,\n # possibly-partial, line) so the byte cap can never cut a multi-byte\n # UTF-8 char or a diff line mid-way and corrupt the agent's input.\n if [ \"$BYTES\" -gt 614400 ]; then\n echo \"huge=true\" >> \"$GITHUB_OUTPUT\"\n head -c 614400 recap.diff | sed '$d' > recap.diff.capped && mv recap.diff.capped recap.diff\n printf '\\n\\n[diff truncated at 600KB for the recap agent]\\n' >> recap.diff\n else\n echo \"huge=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n # Tiny diffs (<= 1 changed file AND <= 8 changed lines) aren't worth a\n # recap \u2014 skip generation cleanly. Use ORIGINAL_LINES (captured before\n # any truncation) so a large diff is never misclassified as tiny.\n if [ \"$CHANGED\" -le 1 ] && [ \"${ORIGINAL_LINES:-0}\" -le 8 ]; then\n echo \"tiny=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"tiny=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n # Secret pre-scan: refuse to hand a diff that looks like it leaks\n # credentials to the agent. Prints { suppressed, reason } and always exits 0.\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != 'true'\n run: |\n set -uo pipefail\n # Fail CLOSED: if the scanner errors or emits invalid JSON, treat the\n # diff as suppressed, so a scan failure can never hand a possibly\n # credential-bearing diff to the agent / plan service.\n if ! SCAN_JSON=\"$($RECAP_CLI recap scan --diff recap.diff)\"; then\n SCAN_JSON='{\"suppressed\":true,\"reason\":\"secret scan failed to run; failing closed\"}'\n fi\n # Multi-line-safe write: SCAN_JSON could contain newlines, which would\n # otherwise corrupt $GITHUB_OUTPUT or inject extra keys.\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 # Find the planId from the previous sticky comment so a re-push REPLACES the\n # same hosted plan (synchronize updates in place, no orphaned plans).\n - name: Read previous plan id\n id: prev\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 # Build the agent prompt = the repo's visual-recap SKILL.md + a task wrapper.\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != 'true' && steps.scan.outputs.suppressed != 'true'\n run: |\n set -euo pipefail\n PREV=\"\"\n if [ -n \"${{ steps.prev.outputs.plan_id }}\" ]; then PREV=\"--prev-plan-id ${{ steps.prev.outputs.plan_id }}\"; fi\n HUGE=\"\"\n if [ \"${{ steps.diff.outputs.huge }}\" = \"true\" ]; then HUGE=\"--huge\"; fi\n $RECAP_CLI recap build-prompt \\\n --diff recap.diff --stat recap.stat \\\n --pr \"$PR_NUMBER\" --head \"$HEAD_SHA\" \\\n --app-url \"$PLAN_RECAP_APP_URL\" \\\n --out recap-prompt.md \\\n $HUGE $PREV\n\n # Wire the plan MCP server for the chosen backend, then run the agent. The\n # agent follows the skill, calls create-visual-recap + set-resource-visibility,\n # and writes the published plan URL to recap-url.txt. continue-on-error so a\n # failed agent run becomes an explanatory comment, not a red X.\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == 'claude' && steps.diff.outputs.tiny != '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 MCP_CONFIG=\"$RUNNER_TEMP/plan-mcp.json\"\n node -e 'const fs=require(\"fs\");fs.writeFileSync(process.argv[1],JSON.stringify({mcpServers:{plan:{type:\"http\",url:process.env.PLAN_RECAP_APP_URL.replace(/\\/$/,\"\")+\"/_agent-native/mcp\",headers:{Authorization:\"Bearer \"+process.env.PLAN_RECAP_TOKEN}}}}))' \"$MCP_CONFIG\"\n # VISUAL_RECAP_MODEL picks the Claude model; reasoning depth is model-driven\n # for Claude Code, so VISUAL_RECAP_REASONING only applies to the Codex backend.\n CLAUDE_ARGS=(-p \"$(cat recap-prompt.md)\" --mcp-config \"$MCP_CONFIG\" --allowedTools \"Read,Write,Bash(git diff:*),mcp__plan__create-visual-recap,mcp__plan__set-resource-visibility\" --permission-mode dontAsk --output-format json)\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CLAUDE_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\n # Capture the final JSON result (usage + total_cost_usd) for the usage step.\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-result.json || true\n rm -f \"$MCP_CONFIG\" || true\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == 'codex' && steps.diff.outputs.tiny != '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 mkdir -p \"$HOME/.codex\"\n # JSON.stringify the URL into the TOML value so a stray quote/newline\n # in PLAN_RECAP_APP_URL can't break out of the string (TOML basic\n # strings share JSON's escaping); the key/env name stay literal.\n node -e 'const fs=require(\"fs\");const url=process.env.PLAN_RECAP_APP_URL.replace(/\\/$/,\"\")+\"/_agent-native/mcp\";fs.writeFileSync(process.env.HOME+\"/.codex/config.toml\",\"[mcp_servers.plan]\\nurl = \"+JSON.stringify(url)+\"\\nbearer_token_env_var = \\\"PLAN_RECAP_TOKEN\\\"\\n\")'\n # Authenticate with the API key explicitly. Relying on the bare\n # OPENAI_API_KEY env var alone is unreliable on the gpt-5.5 WebSocket\n # transport: the Authorization header is dropped on the wss path and\n # its HTTPS fallback, surfacing as `401 Missing bearer or basic\n # authentication in header` (openai/codex#15492). `codex login\n # --with-api-key` reads the key from stdin and writes ~/.codex/auth.json,\n # which the exec path reads reliably; piping via stdin keeps the key out\n # of the process args. Non-fatal so a login hiccup still yields the\n # explanatory recap comment rather than a red X.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # VISUAL_RECAP_MODEL (e.g. gpt-5.5) and VISUAL_RECAP_REASONING\n # (none|minimal|low|medium|high|xhigh) tune the Codex run.\n #\n # The GitHub runner is itself an ephemeral, throwaway sandbox, so run\n # Codex with sandboxing and approvals disabled. Codex's own bubblewrap\n # sandbox cannot initialize on the runner (\"could not find bubblewrap\n # on PATH\"), which makes every shell command fail at startup so the\n # agent cannot even read recap.diff; and under an approval gate the\n # write-side plan MCP call (create-visual-recap) is auto-cancelled\n # (\"user cancelled MCP tool call\"). --dangerously-bypass-approvals-and-sandbox\n # is the documented invocation for externally-sandboxed CI and clears both.\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 known enum before embedding it in the\n # codex `-c` TOML override, so an unexpected value can't alter the config.\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 npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-prompt.md)\" | tee codex-events.jsonl || true\n\n # The agent's only hand-off: recap-url.txt with the published plan URL.\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != 'true' && steps.scan.outputs.suppressed != 'true'\n run: |\n set -uo pipefail\n PLAN_URL=\"\"\n if [ -f recap-url.txt ]; then PLAN_URL=\"$(tr -d '\\r\\n' < recap-url.txt | tr -d ' ')\"; fi\n echo \"plan_url=$PLAN_URL\" >> \"$GITHUB_OUTPUT\"\n if [ -n \"$PLAN_URL\" ]; then echo \"ok=true\" >> \"$GITHUB_OUTPUT\"; else echo \"ok=false\" >> \"$GITHUB_OUTPUT\"; fi\n\n # Capture the agent run token usage and attach it to the published recap\n # so the recap row carries input/output/cached tokens, the model, and a\n # cost estimate. Informational and best-effort: never fails the job.\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 run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ \"$VISUAL_RECAP_AGENT\" = \"codex\" ]; then RESULT=codex-events.jsonl; fi\n if [ -f \"$RESULT\" ]; then $RECAP_CLI recap usage --plan-url \"$PLAN_URL\" --agent \"$VISUAL_RECAP_AGENT\" --result-file \"$RESULT\" --model \"${VISUAL_RECAP_MODEL:-}\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\" || true; fi\n\n # Screenshot the published plan in headless Chrome and upload the PNG to the\n # plan app's signed public image route. Best-effort: never fails the job.\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == 'true'\n continue-on-error: true\n env:\n # Pass the agent-produced plan URL through the environment, never via\n # ${{ }} interpolation into the run script: recap-url.txt is untrusted\n # agent output, so inlining it would be a shell-injection vector.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n SHOT_JSON=\"$($RECAP_CLI recap shot --url \"$PLAN_URL\" --token \"$PLAN_RECAP_TOKEN\" --app-url \"$PLAN_RECAP_APP_URL\" --out recap.png || echo '{}')\"\n IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||\"\")}catch{process.stdout.write(\"\")}' \"$SHOT_JSON\")\n echo \"image_url=$IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\n if [ -f recap.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: recap.png\n if-no-files-found: ignore\n retention-days: 14\n\n # Upsert the single sticky comment: inline screenshot + link on success,\n # suppressed / failed / tiny variants otherwise. Runs even on a tiny diff\n # so a prior recap comment is refreshed (not left pointing at a stale SHA).\n - name: Upsert sticky comment\n if: always()\n # The recap is informational/non-blocking: a failed comment upsert must\n # not turn the whole job red (the agent + screenshot steps are already\n # continue-on-error).\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 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 run: |\n set -euo pipefail\n ARGS=(recap comment upsert --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\")\n # On a tiny diff, only REFRESH an existing recap comment \u2014 never create\n # a new one \u2014 so we add no noise but also can't leave a stale prior\n # recap behind.\n if [ \"${DIFF_TINY:-}\" = \"true\" ]; then ARGS+=(--update-only); fi\n $RECAP_CLI \"${ARGS[@]}\"\n";
|
|
11
11
|
//# sourceMappingURL=pr-visual-recap-workflow.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pr-visual-recap-workflow.d.ts","sourceRoot":"","sources":["../../src/cli/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,4BAA4B,
|
|
1
|
+
{"version":3,"file":"pr-visual-recap-workflow.d.ts","sourceRoot":"","sources":["../../src/cli/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,4BAA4B,2vyBAC2iyB,CAAC"}
|
|
@@ -7,5 +7,5 @@
|
|
|
7
7
|
* recap.spec.ts fails if these drift. Regenerate from the YAML with the snippet
|
|
8
8
|
* in recap.spec.ts.
|
|
9
9
|
*/
|
|
10
|
-
export const PR_VISUAL_RECAP_WORKFLOW_YML = 'name: PR Visual Recap\n\n# Turns every PR into a "visual code review" — a reverse plan — by letting a real\n# coding agent RUN THE REPO\'S visual-recap SKILL against the diff. The agent\n# (Claude Code by default, or Codex) reads the skill, reasons over the change,\n# publishes an Agent-Native Plan via the plan MCP tools, and writes the plan URL\n# to recap-url.txt. The workflow then screenshots that plan in headless Chrome,\n# uploads the PNG to the plan app\'s signed public image route, and upserts ONE\n# sticky PR comment with the inline screenshot + the interactive link.\n#\n# Design notes:\n# - Plain `pull_request` (NOT `pull_request_target`) so fork code can never see\n# the publish/agent secrets. Fork PRs are a silent no-op.\n# - The `gate` job is a cheap switch: drafts, forks, bot authors, and the\n# missing-secret case short-circuit with NO comment and NO compute. Merging\n# this workflow before the secrets exist is a safe no-op.\n# - The recap is INFORMATIONAL ONLY. It is not a required check and failures\n# surface as an explanatory sticky comment, never a red X on unrelated code.\n# - Backend is selectable with the `VISUAL_RECAP_AGENT` repo variable\n# (claude | codex; default claude). Model and reasoning depth are tunable with\n# `VISUAL_RECAP_MODEL` (e.g. gpt-5.5) and `VISUAL_RECAP_REASONING`\n# (none|minimal|low|medium|high|xhigh; Codex only). The CLI invocation is\n# auto-detected: local source inside this monorepo, the published\n# @agent-native/core elsewhere — no repo variable needed.\n# - Only two secrets are required: PLAN_RECAP_TOKEN (publish) and the chosen\n# backend\'s API key. PLAN_RECAP_APP_URL defaults to the hosted plan app.\n# - Nothing here is deterministic: the skill\'s instructions drive the recap.\n\non:\n # Run on PRs into any base branch — the generated workflow ships to repos whose\n # default branch may not be `main`. The gate job below still no-ops drafts,\n # forks, bots, and the missing-secret case, so this stays cheap.\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n\npermissions:\n contents: read\n issues: write\n pull-requests: write\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\njobs:\n # --------------------------------------------------------------------------\n # Cheap gate: decide whether to do any work at all. Sets run=false (silent\n # no-op) for drafts, forks, bot authors, or when the publish secret / the\n # chosen backend\'s API key is absent.\n # --------------------------------------------------------------------------\n gate:\n name: Gate\n runs-on: ubuntu-latest\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n steps:\n - id: decide\n uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7\n env:\n # Presence-only signals — we never expose the secret VALUES to the gate.\n # PLAN_RECAP_APP_URL defaults to the hosted app, so only the token is required.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n 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\n // Fork PRs: head repo differs from this repo. Plain pull_request runs\n // fork code with NO secrets, so publishing would fail anyway — skip.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n if (pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY) {\n reasons.push(`fork PR (${headRepo})`);\n }\n\n // Skip noisy automated authors.\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 // Publish secret must be configured — otherwise this is a no-op so the\n // workflow can be merged before secrets exist.\n if (process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // The chosen backend\'s API key must be present. Normalize the agent\n // value once here and validate it: an unknown or mis-cased value\n // (e.g. "Claude", "gpt") must NOT silently pass the gate and then\n // match neither agent step below.\n const agent = (process.env.AGENT || \'claude\').toLowerCase();\n if (agent !== \'claude\' && agent !== \'codex\') {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude" or "codex")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n }\n\n // Self-modifying guard, evaluated in the GATE (trusted github-script\n // that runs NO PR-checked-out code): if this PR changes the workflow,\n // the visual-recap/visual-plan skill, the local CLI (packages/core),\n // or any agent config the runner would load (.claude/**, CLAUDE.md,\n // .mcp.json), skip the ENTIRE job — not just the agent — so a PR can\n // never rewrite what runs (skill, hooks, settings, CLI) and exfiltrate\n // the publish/API secrets.\n if (pr) {\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 p === \'.github/workflows/pr-visual-recap.yml\' ||\n /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p) ||\n /(^|\\/)\\.claude\\//.test(p) ||\n /(^|\\/)CLAUDE\\.md$/.test(p) ||\n /(^|\\/)AGENTS\\.md$/.test(p) ||\n /(^|\\/)\\.mcp\\.json$/.test(p) ||\n /(^|\\/)packages\\/core\\//.test(p);\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n 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 // Export the NORMALIZED agent so the recap job\'s step conditions match\n // case-insensitively via needs.gate.outputs.agent.\n core.setOutput(\'agent\', agent);\n core.info(run ? `Visual recap will run (${agent}).` : `Visual recap skipped: ${reasons.join(\'; \')}`);\n\n # --------------------------------------------------------------------------\n # Recap: collect the diff, let the agent run the skill + publish, screenshot\n # the result, and upsert the sticky comment.\n # --------------------------------------------------------------------------\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ubuntu-latest\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 HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n steps:\n - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1\n with:\n fetch-depth: 0\n\n # Resolve the CLI invocation once: dogfood local source inside this\n # monorepo, otherwise the published package. No repo variable needed. The\n # pnpm setup/install steps below run ONLY for the local-source path, so the\n # generated workflow works out-of-box in npm/yarn consumer repos (which\n # have no pnpm-lock.yaml) by falling back to `npx @agent-native/core`.\n - name: Resolve recap CLI\n id: cli\n run: |\n if [ -f packages/core/src/cli/index.ts ]; then\n echo "RECAP_CLI=pnpm exec tsx packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "RECAP_CLI=npx -y @agent-native/core@latest" >> "$GITHUB_ENV"\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install workspace (local source only)\n if: steps.cli.outputs.local == \'true\'\n run: pnpm install --frozen-lockfile --ignore-scripts\n\n # Collect a BOUNDED diff between the PR base and head. We exclude lockfiles,\n # build output, and snapshots (noise), and cap the byte size — over the cap\n # we set `huge=true` so the agent is told to produce a summarized recap.\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 git diff --no-color "$BASE_SHA"..."$HEAD_SHA" -- \\\n . \\\n \':(exclude)pnpm-lock.yaml\' \\\n \':(exclude)**/dist/**\' \\\n \':(exclude)**/*.snap\' \\\n \':(exclude)**/*.lock\' \\\n > recap.diff || true\n git diff --stat --no-color "$BASE_SHA"..."$HEAD_SHA" -- \\\n . \\\n \':(exclude)pnpm-lock.yaml\' \\\n \':(exclude)**/dist/**\' \\\n \':(exclude)**/*.snap\' \\\n \':(exclude)**/*.lock\' \\\n > recap.stat || true\n\n BYTES=$(wc -c < recap.diff | tr -d \' \')\n CHANGED=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA" -- \\\n . \\\n \':(exclude)pnpm-lock.yaml\' \\\n \':(exclude)**/dist/**\' \\\n \':(exclude)**/*.snap\' \\\n \':(exclude)**/*.lock\' \\\n | wc -l | tr -d \' \')\n echo "bytes=$BYTES" >> "$GITHUB_OUTPUT"\n echo "changed=$CHANGED" >> "$GITHUB_OUTPUT"\n\n # ~600KB cap. Over the cap we both flag `huge` (so the agent is told to\n # summarize) AND physically truncate recap.diff, so an oversized diff\n # cannot overflow the agent\'s prompt budget when it reads the file.\n # Truncate at a COMPLETE LINE boundary (`sed \'$d\'` drops the last,\n # possibly-partial, line) so the byte cap can never cut a multi-byte\n # UTF-8 char or a diff line mid-way and corrupt the agent\'s input.\n if [ "$BYTES" -gt 614400 ]; then\n echo "huge=true" >> "$GITHUB_OUTPUT"\n head -c 614400 recap.diff | sed \'$d\' > recap.diff.capped && mv recap.diff.capped recap.diff\n printf \'\\n\\n[diff truncated at 600KB for the recap agent]\\n\' >> recap.diff\n else\n echo "huge=false" >> "$GITHUB_OUTPUT"\n fi\n\n # Tiny diffs (<= 1 changed file AND <= 8 changed lines) aren\'t worth a\n # recap — skip generation cleanly.\n LINES=$(grep -cE \'^[+-]\' recap.diff || true)\n if [ "$CHANGED" -le 1 ] && [ "${LINES:-0}" -le 8 ]; then\n echo "tiny=true" >> "$GITHUB_OUTPUT"\n else\n echo "tiny=false" >> "$GITHUB_OUTPUT"\n fi\n\n # Secret pre-scan: refuse to hand a diff that looks like it leaks\n # credentials to the agent. Prints { suppressed, reason } and always exits 0.\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: if the scanner errors or emits invalid JSON, treat the\n # diff as suppressed, so a scan failure can never hand a possibly\n # credential-bearing diff to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff)"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n # Multi-line-safe write: SCAN_JSON could contain newlines, which would\n # otherwise corrupt $GITHUB_OUTPUT or inject extra keys.\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 # Find the planId from the previous sticky comment so a re-push REPLACES the\n # same hosted plan (synchronize updates in place, no orphaned plans).\n - name: Read previous plan id\n id: prev\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 # Build the agent prompt = the repo\'s visual-recap SKILL.md + a task wrapper.\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -euo pipefail\n PREV=""\n if [ -n "${{ steps.prev.outputs.plan_id }}" ]; then PREV="--prev-plan-id ${{ steps.prev.outputs.plan_id }}"; fi\n HUGE=""\n if [ "${{ steps.diff.outputs.huge }}" = "true" ]; then HUGE="--huge"; fi\n $RECAP_CLI recap build-prompt \\\n --diff recap.diff --stat recap.stat \\\n --pr "$PR_NUMBER" --head "$HEAD_SHA" \\\n --app-url "$PLAN_RECAP_APP_URL" \\\n --out recap-prompt.md \\\n $HUGE $PREV\n\n # Wire the plan MCP server for the chosen backend, then run the agent. The\n # agent follows the skill, calls create-visual-recap + set-resource-visibility,\n # and writes the published plan URL to recap-url.txt. continue-on-error so a\n # failed agent run becomes an explanatory comment, not a red X.\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'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 MCP_CONFIG="$RUNNER_TEMP/plan-mcp.json"\n node -e \'const fs=require("fs");fs.writeFileSync(process.argv[1],JSON.stringify({mcpServers:{plan:{type:"http",url:process.env.PLAN_RECAP_APP_URL.replace(/\\/$/,"")+"/_agent-native/mcp",headers:{Authorization:"Bearer "+process.env.PLAN_RECAP_TOKEN}}}}))\' "$MCP_CONFIG"\n # VISUAL_RECAP_MODEL picks the Claude model; reasoning depth is model-driven\n # for Claude Code, so VISUAL_RECAP_REASONING only applies to the Codex backend.\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --mcp-config "$MCP_CONFIG" --allowedTools "Read,Write,Bash(git diff:*),mcp__plan__create-visual-recap,mcp__plan__set-resource-visibility" --permission-mode dontAsk --output-format json)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CLAUDE_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Capture the final JSON result (usage + total_cost_usd) for the usage step.\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json || true\n rm -f "$MCP_CONFIG" || true\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'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 mkdir -p "$HOME/.codex"\n # JSON.stringify the URL into the TOML value so a stray quote/newline\n # in PLAN_RECAP_APP_URL can\'t break out of the string (TOML basic\n # strings share JSON\'s escaping); the key/env name stay literal.\n node -e \'const fs=require("fs");const url=process.env.PLAN_RECAP_APP_URL.replace(/\\/$/,"")+"/_agent-native/mcp";fs.writeFileSync(process.env.HOME+"/.codex/config.toml","[mcp_servers.plan]\\nurl = "+JSON.stringify(url)+"\\nbearer_token_env_var = \\"PLAN_RECAP_TOKEN\\"\\n")\'\n # Authenticate with the API key explicitly. Relying on the bare\n # OPENAI_API_KEY env var alone is unreliable on the gpt-5.5 WebSocket\n # transport: the Authorization header is dropped on the wss path and\n # its HTTPS fallback, surfacing as `401 Missing bearer or basic\n # authentication in header` (openai/codex#15492). `codex login\n # --with-api-key` reads the key from stdin and writes ~/.codex/auth.json,\n # which the exec path reads reliably; piping via stdin keeps the key out\n # of the process args. Non-fatal so a login hiccup still yields the\n # explanatory recap comment rather than a red X.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # VISUAL_RECAP_MODEL (e.g. gpt-5.5) and VISUAL_RECAP_REASONING\n # (none|minimal|low|medium|high|xhigh) tune the Codex run.\n #\n # The GitHub runner is itself an ephemeral, throwaway sandbox, so run\n # Codex with sandboxing and approvals disabled. Codex\'s own bubblewrap\n # sandbox cannot initialize on the runner ("could not find bubblewrap\n # on PATH"), which makes every shell command fail at startup so the\n # agent cannot even read recap.diff; and under an approval gate the\n # write-side plan MCP call (create-visual-recap) is auto-cancelled\n # ("user cancelled MCP tool call"). --dangerously-bypass-approvals-and-sandbox\n # is the documented invocation for externally-sandboxed CI and clears both.\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 known enum before embedding it in the\n # codex `-c` TOML override, so an unexpected value can\'t alter the config.\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 npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" | tee codex-events.jsonl || true\n\n # The agent\'s only hand-off: recap-url.txt with the published plan URL.\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n if [ -f recap-url.txt ]; then PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"; fi\n echo "plan_url=$PLAN_URL" >> "$GITHUB_OUTPUT"\n if [ -n "$PLAN_URL" ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n # Capture the agent run token usage and attach it to the published recap\n # so the recap row carries input/output/cached tokens, the model, and a\n # cost estimate. Informational and best-effort: never fails the job.\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 run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$VISUAL_RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$VISUAL_RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi\n\n # Screenshot the published plan in headless Chrome and upload the PNG to the\n # plan app\'s signed public image route. Best-effort: never fails the job.\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # Pass the agent-produced plan URL through the environment, never via\n # ${{ }} interpolation into the run script: recap-url.txt is untrusted\n # agent output, so inlining it would be a shell-injection vector.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png || echo \'{}\')"\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$SHOT_JSON")\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n if [ -f recap.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: recap.png\n if-no-files-found: ignore\n retention-days: 14\n\n # Upsert the single sticky comment: inline screenshot + link on success,\n # suppressed / failed / tiny variants otherwise. Runs even on a tiny diff\n # so a prior recap comment is refreshed (not left pointing at a stale SHA).\n - name: Upsert sticky comment\n if: always()\n # The recap is informational/non-blocking: a failed comment upsert must\n # not turn the whole job red (the agent + screenshot steps are already\n # continue-on-error).\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 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 run: |\n set -euo pipefail\n ARGS=(recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")\n # On a tiny diff, only REFRESH an existing recap comment — never create\n # a new one — so we add no noise but also can\'t leave a stale prior\n # recap behind.\n if [ "${DIFF_TINY:-}" = "true" ]; then ARGS+=(--update-only); fi\n $RECAP_CLI "${ARGS[@]}"\n';
|
|
10
|
+
export const PR_VISUAL_RECAP_WORKFLOW_YML = 'name: PR Visual Recap\n\n# Turns every PR into a "visual code review" — a reverse plan — by letting a real\n# coding agent RUN THE REPO\'S visual-recap SKILL against the diff. The agent\n# (Claude Code by default, or Codex) reads the skill, reasons over the change,\n# publishes an Agent-Native Plan via the plan MCP tools, and writes the plan URL\n# to recap-url.txt. The workflow then screenshots that plan in headless Chrome,\n# uploads the PNG to the plan app\'s signed public image route, and upserts ONE\n# sticky PR comment with the inline screenshot + the interactive link.\n#\n# Design notes:\n# - Plain `pull_request` (NOT `pull_request_target`) so fork code can never see\n# the publish/agent secrets. Fork PRs are a silent no-op.\n# - The `gate` job is a cheap switch: drafts, forks, bot authors, and the\n# missing-secret case short-circuit with NO comment and NO compute. Merging\n# this workflow before the secrets exist is a safe no-op.\n# - The recap is INFORMATIONAL ONLY. It is not a required check and failures\n# surface as an explanatory sticky comment, never a red X on unrelated code.\n# - Backend is selectable with the `VISUAL_RECAP_AGENT` repo variable\n# (claude | codex; default claude). Model and reasoning depth are tunable with\n# `VISUAL_RECAP_MODEL` (e.g. gpt-5.5) and `VISUAL_RECAP_REASONING`\n# (none|minimal|low|medium|high|xhigh; Codex only). The CLI invocation is\n# auto-detected: local source inside this monorepo, the published\n# @agent-native/core elsewhere — no repo variable needed.\n# - Only two secrets are required: PLAN_RECAP_TOKEN (publish) and the chosen\n# backend\'s API key. PLAN_RECAP_APP_URL defaults to the hosted plan app.\n# - Nothing here is deterministic: the skill\'s instructions drive the recap.\n\non:\n # Run on PRs into any base branch — the generated workflow ships to repos whose\n # default branch may not be `main`. The gate job below still no-ops drafts,\n # forks, bots, and the missing-secret case, so this stays cheap.\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n\npermissions:\n contents: read\n issues: write\n pull-requests: write\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\njobs:\n # --------------------------------------------------------------------------\n # Cheap gate: decide whether to do any work at all. Sets run=false (silent\n # no-op) for drafts, forks, bot authors, or when the publish secret / the\n # chosen backend\'s API key is absent.\n # --------------------------------------------------------------------------\n gate:\n name: Gate\n runs-on: ubuntu-latest\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n steps:\n - id: decide\n uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7\n env:\n # Presence-only signals — we never expose the secret VALUES to the gate.\n # PLAN_RECAP_APP_URL defaults to the hosted app, so only the token is required.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n 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\n // Fork PRs: head repo differs from this repo. Plain pull_request runs\n // fork code with NO secrets, so publishing would fail anyway — skip.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n if (pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY) {\n reasons.push(`fork PR (${headRepo})`);\n }\n\n // Skip noisy automated authors.\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 // Publish secret must be configured — otherwise this is a no-op so the\n // workflow can be merged before secrets exist.\n if (process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // The chosen backend\'s API key must be present. Normalize the agent\n // value once here and validate it: an unknown or mis-cased value\n // (e.g. "Claude", "gpt") must NOT silently pass the gate and then\n // match neither agent step below.\n const agent = (process.env.AGENT || \'claude\').toLowerCase();\n if (agent !== \'claude\' && agent !== \'codex\') {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude" or "codex")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n }\n\n // Validate VISUAL_RECAP_MODEL if set — an unchecked value could be injected\n // by a repo settings writer and passed straight to 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 // Self-modifying guard, evaluated in the GATE (trusted github-script\n // that runs NO PR-checked-out code): if this PR changes the workflow,\n // the visual-recap/visual-plan skill, the local CLI (packages/core),\n // or any agent config the runner would load (.claude/**, CLAUDE.md,\n // .mcp.json), skip the ENTIRE job — not just the agent — so a PR can\n // never rewrite what runs (skill, hooks, settings, CLI) and exfiltrate\n // the publish/API secrets.\n if (pr) {\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 p === \'.github/workflows/pr-visual-recap.yml\' ||\n /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p) ||\n /(^|\\/)\\.claude\\//.test(p) ||\n /(^|\\/)CLAUDE\\.md$/.test(p) ||\n /(^|\\/)AGENTS\\.md$/.test(p) ||\n /(^|\\/)\\.mcp\\.json$/.test(p) ||\n /(^|\\/)packages\\/core\\//.test(p);\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n 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 // Export the NORMALIZED agent so the recap job\'s step conditions match\n // case-insensitively via needs.gate.outputs.agent.\n core.setOutput(\'agent\', agent);\n core.info(run ? `Visual recap will run (${agent}).` : `Visual recap skipped: ${reasons.join(\'; \')}`);\n\n # --------------------------------------------------------------------------\n # Recap: collect the diff, let the agent run the skill + publish, screenshot\n # the result, and upsert the sticky comment.\n # --------------------------------------------------------------------------\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ubuntu-latest\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 HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n steps:\n - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1\n with:\n fetch-depth: 0\n\n # Resolve the CLI invocation once: dogfood local source inside this\n # monorepo, otherwise the published package. No repo variable needed. The\n # pnpm setup/install steps below run ONLY for the local-source path, so the\n # generated workflow works out-of-box in npm/yarn consumer repos (which\n # have no pnpm-lock.yaml) by falling back to `npx @agent-native/core`.\n - name: Resolve recap CLI\n id: cli\n run: |\n if [ -f packages/core/src/cli/index.ts ]; then\n echo "RECAP_CLI=pnpm exec tsx packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "RECAP_CLI=npx -y @agent-native/core@latest" >> "$GITHUB_ENV"\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install workspace (local source only)\n if: steps.cli.outputs.local == \'true\'\n run: pnpm install --frozen-lockfile --ignore-scripts\n\n # Collect a BOUNDED diff between the PR base and head. We exclude lockfiles,\n # build output, and snapshots (noise), and cap the byte size — over the cap\n # we set `huge=true` so the agent is told to produce a summarized recap.\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 git diff --no-color "$BASE_SHA"..."$HEAD_SHA" -- \\\n . \\\n \':(exclude)pnpm-lock.yaml\' \\\n \':(exclude)**/dist/**\' \\\n \':(exclude)**/*.snap\' \\\n \':(exclude)**/*.lock\' \\\n > recap.diff || true\n git diff --stat --no-color "$BASE_SHA"..."$HEAD_SHA" -- \\\n . \\\n \':(exclude)pnpm-lock.yaml\' \\\n \':(exclude)**/dist/**\' \\\n \':(exclude)**/*.snap\' \\\n \':(exclude)**/*.lock\' \\\n > recap.stat || true\n\n BYTES=$(wc -c < recap.diff | tr -d \' \')\n CHANGED=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA" -- \\\n . \\\n \':(exclude)pnpm-lock.yaml\' \\\n \':(exclude)**/dist/**\' \\\n \':(exclude)**/*.snap\' \\\n \':(exclude)**/*.lock\' \\\n | wc -l | tr -d \' \')\n echo "bytes=$BYTES" >> "$GITHUB_OUTPUT"\n echo "changed=$CHANGED" >> "$GITHUB_OUTPUT"\n\n # Count changed lines on the ORIGINAL diff (before any byte-cap truncation),\n # so a large diff is never misclassified as tiny after truncation.\n ORIGINAL_LINES=$(grep -cE \'^[+-]\' recap.diff || true)\n\n # ~600KB cap. Over the cap we both flag `huge` (so the agent is told to\n # summarize) AND physically truncate recap.diff, so an oversized diff\n # cannot overflow the agent\'s prompt budget when it reads the file.\n # Truncate at a COMPLETE LINE boundary (`sed \'$d\'` drops the last,\n # possibly-partial, line) so the byte cap can never cut a multi-byte\n # UTF-8 char or a diff line mid-way and corrupt the agent\'s input.\n if [ "$BYTES" -gt 614400 ]; then\n echo "huge=true" >> "$GITHUB_OUTPUT"\n head -c 614400 recap.diff | sed \'$d\' > recap.diff.capped && mv recap.diff.capped recap.diff\n printf \'\\n\\n[diff truncated at 600KB for the recap agent]\\n\' >> recap.diff\n else\n echo "huge=false" >> "$GITHUB_OUTPUT"\n fi\n\n # Tiny diffs (<= 1 changed file AND <= 8 changed lines) aren\'t worth a\n # recap — skip generation cleanly. Use ORIGINAL_LINES (captured before\n # any truncation) so a large diff is never misclassified as tiny.\n if [ "$CHANGED" -le 1 ] && [ "${ORIGINAL_LINES:-0}" -le 8 ]; then\n echo "tiny=true" >> "$GITHUB_OUTPUT"\n else\n echo "tiny=false" >> "$GITHUB_OUTPUT"\n fi\n\n # Secret pre-scan: refuse to hand a diff that looks like it leaks\n # credentials to the agent. Prints { suppressed, reason } and always exits 0.\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: if the scanner errors or emits invalid JSON, treat the\n # diff as suppressed, so a scan failure can never hand a possibly\n # credential-bearing diff to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff)"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n # Multi-line-safe write: SCAN_JSON could contain newlines, which would\n # otherwise corrupt $GITHUB_OUTPUT or inject extra keys.\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 # Find the planId from the previous sticky comment so a re-push REPLACES the\n # same hosted plan (synchronize updates in place, no orphaned plans).\n - name: Read previous plan id\n id: prev\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 # Build the agent prompt = the repo\'s visual-recap SKILL.md + a task wrapper.\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -euo pipefail\n PREV=""\n if [ -n "${{ steps.prev.outputs.plan_id }}" ]; then PREV="--prev-plan-id ${{ steps.prev.outputs.plan_id }}"; fi\n HUGE=""\n if [ "${{ steps.diff.outputs.huge }}" = "true" ]; then HUGE="--huge"; fi\n $RECAP_CLI recap build-prompt \\\n --diff recap.diff --stat recap.stat \\\n --pr "$PR_NUMBER" --head "$HEAD_SHA" \\\n --app-url "$PLAN_RECAP_APP_URL" \\\n --out recap-prompt.md \\\n $HUGE $PREV\n\n # Wire the plan MCP server for the chosen backend, then run the agent. The\n # agent follows the skill, calls create-visual-recap + set-resource-visibility,\n # and writes the published plan URL to recap-url.txt. continue-on-error so a\n # failed agent run becomes an explanatory comment, not a red X.\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'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 MCP_CONFIG="$RUNNER_TEMP/plan-mcp.json"\n node -e \'const fs=require("fs");fs.writeFileSync(process.argv[1],JSON.stringify({mcpServers:{plan:{type:"http",url:process.env.PLAN_RECAP_APP_URL.replace(/\\/$/,"")+"/_agent-native/mcp",headers:{Authorization:"Bearer "+process.env.PLAN_RECAP_TOKEN}}}}))\' "$MCP_CONFIG"\n # VISUAL_RECAP_MODEL picks the Claude model; reasoning depth is model-driven\n # for Claude Code, so VISUAL_RECAP_REASONING only applies to the Codex backend.\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --mcp-config "$MCP_CONFIG" --allowedTools "Read,Write,Bash(git diff:*),mcp__plan__create-visual-recap,mcp__plan__set-resource-visibility" --permission-mode dontAsk --output-format json)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CLAUDE_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Capture the final JSON result (usage + total_cost_usd) for the usage step.\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json || true\n rm -f "$MCP_CONFIG" || true\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'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 mkdir -p "$HOME/.codex"\n # JSON.stringify the URL into the TOML value so a stray quote/newline\n # in PLAN_RECAP_APP_URL can\'t break out of the string (TOML basic\n # strings share JSON\'s escaping); the key/env name stay literal.\n node -e \'const fs=require("fs");const url=process.env.PLAN_RECAP_APP_URL.replace(/\\/$/,"")+"/_agent-native/mcp";fs.writeFileSync(process.env.HOME+"/.codex/config.toml","[mcp_servers.plan]\\nurl = "+JSON.stringify(url)+"\\nbearer_token_env_var = \\"PLAN_RECAP_TOKEN\\"\\n")\'\n # Authenticate with the API key explicitly. Relying on the bare\n # OPENAI_API_KEY env var alone is unreliable on the gpt-5.5 WebSocket\n # transport: the Authorization header is dropped on the wss path and\n # its HTTPS fallback, surfacing as `401 Missing bearer or basic\n # authentication in header` (openai/codex#15492). `codex login\n # --with-api-key` reads the key from stdin and writes ~/.codex/auth.json,\n # which the exec path reads reliably; piping via stdin keeps the key out\n # of the process args. Non-fatal so a login hiccup still yields the\n # explanatory recap comment rather than a red X.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # VISUAL_RECAP_MODEL (e.g. gpt-5.5) and VISUAL_RECAP_REASONING\n # (none|minimal|low|medium|high|xhigh) tune the Codex run.\n #\n # The GitHub runner is itself an ephemeral, throwaway sandbox, so run\n # Codex with sandboxing and approvals disabled. Codex\'s own bubblewrap\n # sandbox cannot initialize on the runner ("could not find bubblewrap\n # on PATH"), which makes every shell command fail at startup so the\n # agent cannot even read recap.diff; and under an approval gate the\n # write-side plan MCP call (create-visual-recap) is auto-cancelled\n # ("user cancelled MCP tool call"). --dangerously-bypass-approvals-and-sandbox\n # is the documented invocation for externally-sandboxed CI and clears both.\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 known enum before embedding it in the\n # codex `-c` TOML override, so an unexpected value can\'t alter the config.\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 npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" | tee codex-events.jsonl || true\n\n # The agent\'s only hand-off: recap-url.txt with the published plan URL.\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n if [ -f recap-url.txt ]; then PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"; fi\n echo "plan_url=$PLAN_URL" >> "$GITHUB_OUTPUT"\n if [ -n "$PLAN_URL" ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n # Capture the agent run token usage and attach it to the published recap\n # so the recap row carries input/output/cached tokens, the model, and a\n # cost estimate. Informational and best-effort: never fails the job.\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 run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$VISUAL_RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$VISUAL_RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi\n\n # Screenshot the published plan in headless Chrome and upload the PNG to the\n # plan app\'s signed public image route. Best-effort: never fails the job.\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # Pass the agent-produced plan URL through the environment, never via\n # ${{ }} interpolation into the run script: recap-url.txt is untrusted\n # agent output, so inlining it would be a shell-injection vector.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png || echo \'{}\')"\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$SHOT_JSON")\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n if [ -f recap.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: recap.png\n if-no-files-found: ignore\n retention-days: 14\n\n # Upsert the single sticky comment: inline screenshot + link on success,\n # suppressed / failed / tiny variants otherwise. Runs even on a tiny diff\n # so a prior recap comment is refreshed (not left pointing at a stale SHA).\n - name: Upsert sticky comment\n if: always()\n # The recap is informational/non-blocking: a failed comment upsert must\n # not turn the whole job red (the agent + screenshot steps are already\n # continue-on-error).\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 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 run: |\n set -euo pipefail\n ARGS=(recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")\n # On a tiny diff, only REFRESH an existing recap comment — never create\n # a new one — so we add no noise but also can\'t leave a stale prior\n # recap behind.\n if [ "${DIFF_TINY:-}" = "true" ]; then ARGS+=(--update-only); fi\n $RECAP_CLI "${ARGS[@]}"\n';
|
|
11
11
|
//# sourceMappingURL=pr-visual-recap-workflow.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pr-visual-recap-workflow.js","sourceRoot":"","sources":["../../src/cli/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,CAAC,MAAM,4BAA4B,GACvC,q0wBAAq0wB,CAAC","sourcesContent":["/**\n * Bundled copy of .github/workflows/pr-visual-recap.yml so the CLI can write the\n * PR Visual Recap workflow into a user repo via\n * `agent-native skills add visual-plan --with-github-action`.\n *\n * AUTO-GENERATED — keep byte-identical with the source workflow. A sync test in\n * recap.spec.ts fails if these drift. Regenerate from the YAML with the snippet\n * in recap.spec.ts.\n */\n\nexport const PR_VISUAL_RECAP_WORKFLOW_YML =\n 'name: PR Visual Recap\\n\\n# Turns every PR into a \"visual code review\" — a reverse plan — by letting a real\\n# coding agent RUN THE REPO\\'S visual-recap SKILL against the diff. The agent\\n# (Claude Code by default, or Codex) reads the skill, reasons over the change,\\n# publishes an Agent-Native Plan via the plan MCP tools, and writes the plan URL\\n# to recap-url.txt. The workflow then screenshots that plan in headless Chrome,\\n# uploads the PNG to the plan app\\'s signed public image route, and upserts ONE\\n# sticky PR comment with the inline screenshot + the interactive link.\\n#\\n# Design notes:\\n# - Plain `pull_request` (NOT `pull_request_target`) so fork code can never see\\n# the publish/agent secrets. Fork PRs are a silent no-op.\\n# - The `gate` job is a cheap switch: drafts, forks, bot authors, and the\\n# missing-secret case short-circuit with NO comment and NO compute. Merging\\n# this workflow before the secrets exist is a safe no-op.\\n# - The recap is INFORMATIONAL ONLY. It is not a required check and failures\\n# surface as an explanatory sticky comment, never a red X on unrelated code.\\n# - Backend is selectable with the `VISUAL_RECAP_AGENT` repo variable\\n# (claude | codex; default claude). Model and reasoning depth are tunable with\\n# `VISUAL_RECAP_MODEL` (e.g. gpt-5.5) and `VISUAL_RECAP_REASONING`\\n# (none|minimal|low|medium|high|xhigh; Codex only). The CLI invocation is\\n# auto-detected: local source inside this monorepo, the published\\n# @agent-native/core elsewhere — no repo variable needed.\\n# - Only two secrets are required: PLAN_RECAP_TOKEN (publish) and the chosen\\n# backend\\'s API key. PLAN_RECAP_APP_URL defaults to the hosted plan app.\\n# - Nothing here is deterministic: the skill\\'s instructions drive the recap.\\n\\non:\\n # Run on PRs into any base branch — the generated workflow ships to repos whose\\n # default branch may not be `main`. The gate job below still no-ops drafts,\\n # forks, bots, and the missing-secret case, so this stays cheap.\\n pull_request:\\n types: [opened, synchronize, reopened, ready_for_review]\\n\\npermissions:\\n contents: read\\n issues: write\\n pull-requests: write\\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\\njobs:\\n # --------------------------------------------------------------------------\\n # Cheap gate: decide whether to do any work at all. Sets run=false (silent\\n # no-op) for drafts, forks, bot authors, or when the publish secret / the\\n # chosen backend\\'s API key is absent.\\n # --------------------------------------------------------------------------\\n gate:\\n name: Gate\\n runs-on: ubuntu-latest\\n outputs:\\n run: ${{ steps.decide.outputs.run }}\\n agent: ${{ steps.decide.outputs.agent }}\\n steps:\\n - id: decide\\n uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7\\n env:\\n # Presence-only signals — we never expose the secret VALUES to the gate.\\n # PLAN_RECAP_APP_URL defaults to the hosted app, so only the token is required.\\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \\'\\' }}\\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \\'\\' }}\\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \\'\\' }}\\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\\n 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\\n // Fork PRs: head repo differs from this repo. Plain pull_request runs\\n // fork code with NO secrets, so publishing would fail anyway — skip.\\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\\n if (pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY) {\\n reasons.push(`fork PR (${headRepo})`);\\n }\\n\\n // Skip noisy automated authors.\\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 // Publish secret must be configured — otherwise this is a no-op so the\\n // workflow can be merged before secrets exist.\\n if (process.env.HAS_PLAN !== \\'true\\') reasons.push(\\'PLAN_RECAP_TOKEN not configured\\');\\n\\n // The chosen backend\\'s API key must be present. Normalize the agent\\n // value once here and validate it: an unknown or mis-cased value\\n // (e.g. \"Claude\", \"gpt\") must NOT silently pass the gate and then\\n // match neither agent step below.\\n const agent = (process.env.AGENT || \\'claude\\').toLowerCase();\\n if (agent !== \\'claude\\' && agent !== \\'codex\\') {\\n reasons.push(`unsupported VISUAL_RECAP_AGENT \"${process.env.AGENT}\" (expected \"claude\" or \"codex\")`);\\n } else if (agent === \\'codex\\') {\\n if (process.env.HAS_OPENAI !== \\'true\\') reasons.push(\\'OPENAI_API_KEY not configured (codex backend)\\');\\n } else {\\n if (process.env.HAS_ANTHROPIC !== \\'true\\') reasons.push(\\'ANTHROPIC_API_KEY not configured (claude backend)\\');\\n }\\n\\n // Self-modifying guard, evaluated in the GATE (trusted github-script\\n // that runs NO PR-checked-out code): if this PR changes the workflow,\\n // the visual-recap/visual-plan skill, the local CLI (packages/core),\\n // or any agent config the runner would load (.claude/**, CLAUDE.md,\\n // .mcp.json), skip the ENTIRE job — not just the agent — so a PR can\\n // never rewrite what runs (skill, hooks, settings, CLI) and exfiltrate\\n // the publish/API secrets.\\n if (pr) {\\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 p === \\'.github/workflows/pr-visual-recap.yml\\' ||\\n /(^|\\\\/)skills\\\\/visual-(recap|plan|plans)\\\\//.test(p) ||\\n /(^|\\\\/)\\\\.claude\\\\//.test(p) ||\\n /(^|\\\\/)CLAUDE\\\\.md$/.test(p) ||\\n /(^|\\\\/)AGENTS\\\\.md$/.test(p) ||\\n /(^|\\\\/)\\\\.mcp\\\\.json$/.test(p) ||\\n /(^|\\\\/)packages\\\\/core\\\\//.test(p);\\n const hits = files.map((f) => f.filename).filter(isSensitive);\\n if (hits.length) {\\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\\', \\')}${hits.length > 3 ? \\', …\\' : \\'\\'}) — skipping so untrusted PR code never runs with secrets`);\\n }\\n } catch (e) {\\n 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 // Export the NORMALIZED agent so the recap job\\'s step conditions match\\n // case-insensitively via needs.gate.outputs.agent.\\n core.setOutput(\\'agent\\', agent);\\n core.info(run ? `Visual recap will run (${agent}).` : `Visual recap skipped: ${reasons.join(\\'; \\')}`);\\n\\n # --------------------------------------------------------------------------\\n # Recap: collect the diff, let the agent run the skill + publish, screenshot\\n # the result, and upsert the sticky comment.\\n # --------------------------------------------------------------------------\\n recap:\\n name: Generate visual recap\\n needs: gate\\n if: needs.gate.outputs.run == \\'true\\'\\n runs-on: ubuntu-latest\\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 HEAD_SHA: ${{ github.event.pull_request.head.sha }}\\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\\n steps:\\n - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1\\n with:\\n fetch-depth: 0\\n\\n # Resolve the CLI invocation once: dogfood local source inside this\\n # monorepo, otherwise the published package. No repo variable needed. The\\n # pnpm setup/install steps below run ONLY for the local-source path, so the\\n # generated workflow works out-of-box in npm/yarn consumer repos (which\\n # have no pnpm-lock.yaml) by falling back to `npx @agent-native/core`.\\n - name: Resolve recap CLI\\n id: cli\\n run: |\\n if [ -f packages/core/src/cli/index.ts ]; then\\n echo \"RECAP_CLI=pnpm exec tsx packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\\n echo \"local=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"RECAP_CLI=npx -y @agent-native/core@latest\" >> \"$GITHUB_ENV\"\\n echo \"local=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n\\n - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0\\n if: steps.cli.outputs.local == \\'true\\'\\n\\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0\\n with:\\n node-version: \"22\"\\n cache: ${{ steps.cli.outputs.local == \\'true\\' && \\'pnpm\\' || \\'\\' }}\\n\\n - name: Install workspace (local source only)\\n if: steps.cli.outputs.local == \\'true\\'\\n run: pnpm install --frozen-lockfile --ignore-scripts\\n\\n # Collect a BOUNDED diff between the PR base and head. We exclude lockfiles,\\n # build output, and snapshots (noise), and cap the byte size — over the cap\\n # we set `huge=true` so the agent is told to produce a summarized recap.\\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 git diff --no-color \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\\\\n . \\\\\\n \\':(exclude)pnpm-lock.yaml\\' \\\\\\n \\':(exclude)**/dist/**\\' \\\\\\n \\':(exclude)**/*.snap\\' \\\\\\n \\':(exclude)**/*.lock\\' \\\\\\n > recap.diff || true\\n git diff --stat --no-color \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\\\\n . \\\\\\n \\':(exclude)pnpm-lock.yaml\\' \\\\\\n \\':(exclude)**/dist/**\\' \\\\\\n \\':(exclude)**/*.snap\\' \\\\\\n \\':(exclude)**/*.lock\\' \\\\\\n > recap.stat || true\\n\\n BYTES=$(wc -c < recap.diff | tr -d \\' \\')\\n CHANGED=$(git diff --name-only \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\\\\n . \\\\\\n \\':(exclude)pnpm-lock.yaml\\' \\\\\\n \\':(exclude)**/dist/**\\' \\\\\\n \\':(exclude)**/*.snap\\' \\\\\\n \\':(exclude)**/*.lock\\' \\\\\\n | wc -l | tr -d \\' \\')\\n echo \"bytes=$BYTES\" >> \"$GITHUB_OUTPUT\"\\n echo \"changed=$CHANGED\" >> \"$GITHUB_OUTPUT\"\\n\\n # ~600KB cap. Over the cap we both flag `huge` (so the agent is told to\\n # summarize) AND physically truncate recap.diff, so an oversized diff\\n # cannot overflow the agent\\'s prompt budget when it reads the file.\\n # Truncate at a COMPLETE LINE boundary (`sed \\'$d\\'` drops the last,\\n # possibly-partial, line) so the byte cap can never cut a multi-byte\\n # UTF-8 char or a diff line mid-way and corrupt the agent\\'s input.\\n if [ \"$BYTES\" -gt 614400 ]; then\\n echo \"huge=true\" >> \"$GITHUB_OUTPUT\"\\n head -c 614400 recap.diff | sed \\'$d\\' > recap.diff.capped && mv recap.diff.capped recap.diff\\n printf \\'\\\\n\\\\n[diff truncated at 600KB for the recap agent]\\\\n\\' >> recap.diff\\n else\\n echo \"huge=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n\\n # Tiny diffs (<= 1 changed file AND <= 8 changed lines) aren\\'t worth a\\n # recap — skip generation cleanly.\\n LINES=$(grep -cE \\'^[+-]\\' recap.diff || true)\\n if [ \"$CHANGED\" -le 1 ] && [ \"${LINES:-0}\" -le 8 ]; then\\n echo \"tiny=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"tiny=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n\\n # Secret pre-scan: refuse to hand a diff that looks like it leaks\\n # credentials to the agent. Prints { suppressed, reason } and always exits 0.\\n - name: Secret scan\\n id: scan\\n if: steps.diff.outputs.tiny != \\'true\\'\\n run: |\\n set -uo pipefail\\n # Fail CLOSED: if the scanner errors or emits invalid JSON, treat the\\n # diff as suppressed, so a scan failure can never hand a possibly\\n # credential-bearing diff to the agent / plan service.\\n if ! SCAN_JSON=\"$($RECAP_CLI recap scan --diff recap.diff)\"; then\\n SCAN_JSON=\\'{\"suppressed\":true,\"reason\":\"secret scan failed to run; failing closed\"}\\'\\n fi\\n # Multi-line-safe write: SCAN_JSON could contain newlines, which would\\n # otherwise corrupt $GITHUB_OUTPUT or inject extra keys.\\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 # Find the planId from the previous sticky comment so a re-push REPLACES the\\n # same hosted plan (synchronize updates in place, no orphaned plans).\\n - name: Read previous plan id\\n id: prev\\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 # Build the agent prompt = the repo\\'s visual-recap SKILL.md + a task wrapper.\\n - name: Build recap prompt\\n id: prompt\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n run: |\\n set -euo pipefail\\n PREV=\"\"\\n if [ -n \"${{ steps.prev.outputs.plan_id }}\" ]; then PREV=\"--prev-plan-id ${{ steps.prev.outputs.plan_id }}\"; fi\\n HUGE=\"\"\\n if [ \"${{ steps.diff.outputs.huge }}\" = \"true\" ]; then HUGE=\"--huge\"; fi\\n $RECAP_CLI recap build-prompt \\\\\\n --diff recap.diff --stat recap.stat \\\\\\n --pr \"$PR_NUMBER\" --head \"$HEAD_SHA\" \\\\\\n --app-url \"$PLAN_RECAP_APP_URL\" \\\\\\n --out recap-prompt.md \\\\\\n $HUGE $PREV\\n\\n # Wire the plan MCP server for the chosen backend, then run the agent. The\\n # agent follows the skill, calls create-visual-recap + set-resource-visibility,\\n # and writes the published plan URL to recap-url.txt. continue-on-error so a\\n # failed agent run becomes an explanatory comment, not a red X.\\n - name: Run agent (Claude Code)\\n id: claude\\n if: needs.gate.outputs.agent == \\'claude\\' && steps.diff.outputs.tiny != \\'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 MCP_CONFIG=\"$RUNNER_TEMP/plan-mcp.json\"\\n node -e \\'const fs=require(\"fs\");fs.writeFileSync(process.argv[1],JSON.stringify({mcpServers:{plan:{type:\"http\",url:process.env.PLAN_RECAP_APP_URL.replace(/\\\\/$/,\"\")+\"/_agent-native/mcp\",headers:{Authorization:\"Bearer \"+process.env.PLAN_RECAP_TOKEN}}}}))\\' \"$MCP_CONFIG\"\\n # VISUAL_RECAP_MODEL picks the Claude model; reasoning depth is model-driven\\n # for Claude Code, so VISUAL_RECAP_REASONING only applies to the Codex backend.\\n CLAUDE_ARGS=(-p \"$(cat recap-prompt.md)\" --mcp-config \"$MCP_CONFIG\" --allowedTools \"Read,Write,Bash(git diff:*),mcp__plan__create-visual-recap,mcp__plan__set-resource-visibility\" --permission-mode dontAsk --output-format json)\\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CLAUDE_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\\n # Capture the final JSON result (usage + total_cost_usd) for the usage step.\\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-result.json || true\\n rm -f \"$MCP_CONFIG\" || true\\n\\n - name: Run agent (Codex)\\n id: codex\\n if: needs.gate.outputs.agent == \\'codex\\' && steps.diff.outputs.tiny != \\'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 mkdir -p \"$HOME/.codex\"\\n # JSON.stringify the URL into the TOML value so a stray quote/newline\\n # in PLAN_RECAP_APP_URL can\\'t break out of the string (TOML basic\\n # strings share JSON\\'s escaping); the key/env name stay literal.\\n node -e \\'const fs=require(\"fs\");const url=process.env.PLAN_RECAP_APP_URL.replace(/\\\\/$/,\"\")+\"/_agent-native/mcp\";fs.writeFileSync(process.env.HOME+\"/.codex/config.toml\",\"[mcp_servers.plan]\\\\nurl = \"+JSON.stringify(url)+\"\\\\nbearer_token_env_var = \\\\\"PLAN_RECAP_TOKEN\\\\\"\\\\n\")\\'\\n # Authenticate with the API key explicitly. Relying on the bare\\n # OPENAI_API_KEY env var alone is unreliable on the gpt-5.5 WebSocket\\n # transport: the Authorization header is dropped on the wss path and\\n # its HTTPS fallback, surfacing as `401 Missing bearer or basic\\n # authentication in header` (openai/codex#15492). `codex login\\n # --with-api-key` reads the key from stdin and writes ~/.codex/auth.json,\\n # which the exec path reads reliably; piping via stdin keeps the key out\\n # of the process args. Non-fatal so a login hiccup still yields the\\n # explanatory recap comment rather than a red X.\\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\\n # VISUAL_RECAP_MODEL (e.g. gpt-5.5) and VISUAL_RECAP_REASONING\\n # (none|minimal|low|medium|high|xhigh) tune the Codex run.\\n #\\n # The GitHub runner is itself an ephemeral, throwaway sandbox, so run\\n # Codex with sandboxing and approvals disabled. Codex\\'s own bubblewrap\\n # sandbox cannot initialize on the runner (\"could not find bubblewrap\\n # on PATH\"), which makes every shell command fail at startup so the\\n # agent cannot even read recap.diff; and under an approval gate the\\n # write-side plan MCP call (create-visual-recap) is auto-cancelled\\n # (\"user cancelled MCP tool call\"). --dangerously-bypass-approvals-and-sandbox\\n # is the documented invocation for externally-sandboxed CI and clears both.\\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 known enum before embedding it in the\\n # codex `-c` TOML override, so an unexpected value can\\'t alter the config.\\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 npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-prompt.md)\" | tee codex-events.jsonl || true\\n\\n # The agent\\'s only hand-off: recap-url.txt with the published plan URL.\\n - name: Read plan URL\\n id: url\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n run: |\\n set -uo pipefail\\n PLAN_URL=\"\"\\n if [ -f recap-url.txt ]; then PLAN_URL=\"$(tr -d \\'\\\\r\\\\n\\' < recap-url.txt | tr -d \\' \\')\"; fi\\n echo \"plan_url=$PLAN_URL\" >> \"$GITHUB_OUTPUT\"\\n if [ -n \"$PLAN_URL\" ]; then echo \"ok=true\" >> \"$GITHUB_OUTPUT\"; else echo \"ok=false\" >> \"$GITHUB_OUTPUT\"; fi\\n\\n # Capture the agent run token usage and attach it to the published recap\\n # so the recap row carries input/output/cached tokens, the model, and a\\n # cost estimate. Informational and best-effort: never fails the job.\\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 run: |\\n set -uo pipefail\\n RESULT=claude-result.json\\n if [ \"$VISUAL_RECAP_AGENT\" = \"codex\" ]; then RESULT=codex-events.jsonl; fi\\n if [ -f \"$RESULT\" ]; then $RECAP_CLI recap usage --plan-url \"$PLAN_URL\" --agent \"$VISUAL_RECAP_AGENT\" --result-file \"$RESULT\" --model \"${VISUAL_RECAP_MODEL:-}\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\" || true; fi\\n\\n # Screenshot the published plan in headless Chrome and upload the PNG to the\\n # plan app\\'s signed public image route. Best-effort: never fails the job.\\n - name: Screenshot + upload\\n id: shot\\n if: steps.url.outputs.ok == \\'true\\'\\n continue-on-error: true\\n env:\\n # Pass the agent-produced plan URL through the environment, never via\\n # ${{ }} interpolation into the run script: recap-url.txt is untrusted\\n # agent output, so inlining it would be a shell-injection vector.\\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\\n run: |\\n set -uo pipefail\\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\\n SHOT_JSON=\"$($RECAP_CLI recap shot --url \"$PLAN_URL\" --token \"$PLAN_RECAP_TOKEN\" --app-url \"$PLAN_RECAP_APP_URL\" --out recap.png || echo \\'{}\\')\"\\n IMAGE_URL=$(node -e \\'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||\"\")}catch{process.stdout.write(\"\")}\\' \"$SHOT_JSON\")\\n echo \"image_url=$IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\\n if [ -f recap.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\\n with:\\n name: pr-visual-recap-${{ github.event.pull_request.number }}\\n path: recap.png\\n if-no-files-found: ignore\\n retention-days: 14\\n\\n # Upsert the single sticky comment: inline screenshot + link on success,\\n # suppressed / failed / tiny variants otherwise. Runs even on a tiny diff\\n # so a prior recap comment is refreshed (not left pointing at a stale SHA).\\n - name: Upsert sticky comment\\n if: always()\\n # The recap is informational/non-blocking: a failed comment upsert must\\n # not turn the whole job red (the agent + screenshot steps are already\\n # continue-on-error).\\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 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 run: |\\n set -euo pipefail\\n ARGS=(recap comment upsert --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\")\\n # On a tiny diff, only REFRESH an existing recap comment — never create\\n # a new one — so we add no noise but also can\\'t leave a stale prior\\n # recap behind.\\n if [ \"${DIFF_TINY:-}\" = \"true\" ]; then ARGS+=(--update-only); fi\\n $RECAP_CLI \"${ARGS[@]}\"\\n';\n"]}
|
|
1
|
+
{"version":3,"file":"pr-visual-recap-workflow.js","sourceRoot":"","sources":["../../src/cli/pr-visual-recap-workflow.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,CAAC,MAAM,4BAA4B,GACvC,klyBAAklyB,CAAC","sourcesContent":["/**\n * Bundled copy of .github/workflows/pr-visual-recap.yml so the CLI can write the\n * PR Visual Recap workflow into a user repo via\n * `agent-native skills add visual-plan --with-github-action`.\n *\n * AUTO-GENERATED — keep byte-identical with the source workflow. A sync test in\n * recap.spec.ts fails if these drift. Regenerate from the YAML with the snippet\n * in recap.spec.ts.\n */\n\nexport const PR_VISUAL_RECAP_WORKFLOW_YML =\n 'name: PR Visual Recap\\n\\n# Turns every PR into a \"visual code review\" — a reverse plan — by letting a real\\n# coding agent RUN THE REPO\\'S visual-recap SKILL against the diff. The agent\\n# (Claude Code by default, or Codex) reads the skill, reasons over the change,\\n# publishes an Agent-Native Plan via the plan MCP tools, and writes the plan URL\\n# to recap-url.txt. The workflow then screenshots that plan in headless Chrome,\\n# uploads the PNG to the plan app\\'s signed public image route, and upserts ONE\\n# sticky PR comment with the inline screenshot + the interactive link.\\n#\\n# Design notes:\\n# - Plain `pull_request` (NOT `pull_request_target`) so fork code can never see\\n# the publish/agent secrets. Fork PRs are a silent no-op.\\n# - The `gate` job is a cheap switch: drafts, forks, bot authors, and the\\n# missing-secret case short-circuit with NO comment and NO compute. Merging\\n# this workflow before the secrets exist is a safe no-op.\\n# - The recap is INFORMATIONAL ONLY. It is not a required check and failures\\n# surface as an explanatory sticky comment, never a red X on unrelated code.\\n# - Backend is selectable with the `VISUAL_RECAP_AGENT` repo variable\\n# (claude | codex; default claude). Model and reasoning depth are tunable with\\n# `VISUAL_RECAP_MODEL` (e.g. gpt-5.5) and `VISUAL_RECAP_REASONING`\\n# (none|minimal|low|medium|high|xhigh; Codex only). The CLI invocation is\\n# auto-detected: local source inside this monorepo, the published\\n# @agent-native/core elsewhere — no repo variable needed.\\n# - Only two secrets are required: PLAN_RECAP_TOKEN (publish) and the chosen\\n# backend\\'s API key. PLAN_RECAP_APP_URL defaults to the hosted plan app.\\n# - Nothing here is deterministic: the skill\\'s instructions drive the recap.\\n\\non:\\n # Run on PRs into any base branch — the generated workflow ships to repos whose\\n # default branch may not be `main`. The gate job below still no-ops drafts,\\n # forks, bots, and the missing-secret case, so this stays cheap.\\n pull_request:\\n types: [opened, synchronize, reopened, ready_for_review]\\n\\npermissions:\\n contents: read\\n issues: write\\n pull-requests: write\\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\\njobs:\\n # --------------------------------------------------------------------------\\n # Cheap gate: decide whether to do any work at all. Sets run=false (silent\\n # no-op) for drafts, forks, bot authors, or when the publish secret / the\\n # chosen backend\\'s API key is absent.\\n # --------------------------------------------------------------------------\\n gate:\\n name: Gate\\n runs-on: ubuntu-latest\\n outputs:\\n run: ${{ steps.decide.outputs.run }}\\n agent: ${{ steps.decide.outputs.agent }}\\n steps:\\n - id: decide\\n uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7\\n env:\\n # Presence-only signals — we never expose the secret VALUES to the gate.\\n # PLAN_RECAP_APP_URL defaults to the hosted app, so only the token is required.\\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \\'\\' }}\\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \\'\\' }}\\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \\'\\' }}\\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\\n 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\\n // Fork PRs: head repo differs from this repo. Plain pull_request runs\\n // fork code with NO secrets, so publishing would fail anyway — skip.\\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\\n if (pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY) {\\n reasons.push(`fork PR (${headRepo})`);\\n }\\n\\n // Skip noisy automated authors.\\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 // Publish secret must be configured — otherwise this is a no-op so the\\n // workflow can be merged before secrets exist.\\n if (process.env.HAS_PLAN !== \\'true\\') reasons.push(\\'PLAN_RECAP_TOKEN not configured\\');\\n\\n // The chosen backend\\'s API key must be present. Normalize the agent\\n // value once here and validate it: an unknown or mis-cased value\\n // (e.g. \"Claude\", \"gpt\") must NOT silently pass the gate and then\\n // match neither agent step below.\\n const agent = (process.env.AGENT || \\'claude\\').toLowerCase();\\n if (agent !== \\'claude\\' && agent !== \\'codex\\') {\\n reasons.push(`unsupported VISUAL_RECAP_AGENT \"${process.env.AGENT}\" (expected \"claude\" or \"codex\")`);\\n } else if (agent === \\'codex\\') {\\n if (process.env.HAS_OPENAI !== \\'true\\') reasons.push(\\'OPENAI_API_KEY not configured (codex backend)\\');\\n } else {\\n if (process.env.HAS_ANTHROPIC !== \\'true\\') reasons.push(\\'ANTHROPIC_API_KEY not configured (claude backend)\\');\\n }\\n\\n // Validate VISUAL_RECAP_MODEL if set — an unchecked value could be injected\\n // by a repo settings writer and passed straight to 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 // Self-modifying guard, evaluated in the GATE (trusted github-script\\n // that runs NO PR-checked-out code): if this PR changes the workflow,\\n // the visual-recap/visual-plan skill, the local CLI (packages/core),\\n // or any agent config the runner would load (.claude/**, CLAUDE.md,\\n // .mcp.json), skip the ENTIRE job — not just the agent — so a PR can\\n // never rewrite what runs (skill, hooks, settings, CLI) and exfiltrate\\n // the publish/API secrets.\\n if (pr) {\\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 p === \\'.github/workflows/pr-visual-recap.yml\\' ||\\n /(^|\\\\/)skills\\\\/visual-(recap|plan|plans)\\\\//.test(p) ||\\n /(^|\\\\/)\\\\.claude\\\\//.test(p) ||\\n /(^|\\\\/)CLAUDE\\\\.md$/.test(p) ||\\n /(^|\\\\/)AGENTS\\\\.md$/.test(p) ||\\n /(^|\\\\/)\\\\.mcp\\\\.json$/.test(p) ||\\n /(^|\\\\/)packages\\\\/core\\\\//.test(p);\\n const hits = files.map((f) => f.filename).filter(isSensitive);\\n if (hits.length) {\\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\\', \\')}${hits.length > 3 ? \\', …\\' : \\'\\'}) — skipping so untrusted PR code never runs with secrets`);\\n }\\n } catch (e) {\\n 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 // Export the NORMALIZED agent so the recap job\\'s step conditions match\\n // case-insensitively via needs.gate.outputs.agent.\\n core.setOutput(\\'agent\\', agent);\\n core.info(run ? `Visual recap will run (${agent}).` : `Visual recap skipped: ${reasons.join(\\'; \\')}`);\\n\\n # --------------------------------------------------------------------------\\n # Recap: collect the diff, let the agent run the skill + publish, screenshot\\n # the result, and upsert the sticky comment.\\n # --------------------------------------------------------------------------\\n recap:\\n name: Generate visual recap\\n needs: gate\\n if: needs.gate.outputs.run == \\'true\\'\\n runs-on: ubuntu-latest\\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 HEAD_SHA: ${{ github.event.pull_request.head.sha }}\\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\\n steps:\\n - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1\\n with:\\n fetch-depth: 0\\n\\n # Resolve the CLI invocation once: dogfood local source inside this\\n # monorepo, otherwise the published package. No repo variable needed. The\\n # pnpm setup/install steps below run ONLY for the local-source path, so the\\n # generated workflow works out-of-box in npm/yarn consumer repos (which\\n # have no pnpm-lock.yaml) by falling back to `npx @agent-native/core`.\\n - name: Resolve recap CLI\\n id: cli\\n run: |\\n if [ -f packages/core/src/cli/index.ts ]; then\\n echo \"RECAP_CLI=pnpm exec tsx packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\\n echo \"local=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"RECAP_CLI=npx -y @agent-native/core@latest\" >> \"$GITHUB_ENV\"\\n echo \"local=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n\\n - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0\\n if: steps.cli.outputs.local == \\'true\\'\\n\\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0\\n with:\\n node-version: \"22\"\\n cache: ${{ steps.cli.outputs.local == \\'true\\' && \\'pnpm\\' || \\'\\' }}\\n\\n - name: Install workspace (local source only)\\n if: steps.cli.outputs.local == \\'true\\'\\n run: pnpm install --frozen-lockfile --ignore-scripts\\n\\n # Collect a BOUNDED diff between the PR base and head. We exclude lockfiles,\\n # build output, and snapshots (noise), and cap the byte size — over the cap\\n # we set `huge=true` so the agent is told to produce a summarized recap.\\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 git diff --no-color \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\\\\n . \\\\\\n \\':(exclude)pnpm-lock.yaml\\' \\\\\\n \\':(exclude)**/dist/**\\' \\\\\\n \\':(exclude)**/*.snap\\' \\\\\\n \\':(exclude)**/*.lock\\' \\\\\\n > recap.diff || true\\n git diff --stat --no-color \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\\\\n . \\\\\\n \\':(exclude)pnpm-lock.yaml\\' \\\\\\n \\':(exclude)**/dist/**\\' \\\\\\n \\':(exclude)**/*.snap\\' \\\\\\n \\':(exclude)**/*.lock\\' \\\\\\n > recap.stat || true\\n\\n BYTES=$(wc -c < recap.diff | tr -d \\' \\')\\n CHANGED=$(git diff --name-only \"$BASE_SHA\"...\"$HEAD_SHA\" -- \\\\\\n . \\\\\\n \\':(exclude)pnpm-lock.yaml\\' \\\\\\n \\':(exclude)**/dist/**\\' \\\\\\n \\':(exclude)**/*.snap\\' \\\\\\n \\':(exclude)**/*.lock\\' \\\\\\n | wc -l | tr -d \\' \\')\\n echo \"bytes=$BYTES\" >> \"$GITHUB_OUTPUT\"\\n echo \"changed=$CHANGED\" >> \"$GITHUB_OUTPUT\"\\n\\n # Count changed lines on the ORIGINAL diff (before any byte-cap truncation),\\n # so a large diff is never misclassified as tiny after truncation.\\n ORIGINAL_LINES=$(grep -cE \\'^[+-]\\' recap.diff || true)\\n\\n # ~600KB cap. Over the cap we both flag `huge` (so the agent is told to\\n # summarize) AND physically truncate recap.diff, so an oversized diff\\n # cannot overflow the agent\\'s prompt budget when it reads the file.\\n # Truncate at a COMPLETE LINE boundary (`sed \\'$d\\'` drops the last,\\n # possibly-partial, line) so the byte cap can never cut a multi-byte\\n # UTF-8 char or a diff line mid-way and corrupt the agent\\'s input.\\n if [ \"$BYTES\" -gt 614400 ]; then\\n echo \"huge=true\" >> \"$GITHUB_OUTPUT\"\\n head -c 614400 recap.diff | sed \\'$d\\' > recap.diff.capped && mv recap.diff.capped recap.diff\\n printf \\'\\\\n\\\\n[diff truncated at 600KB for the recap agent]\\\\n\\' >> recap.diff\\n else\\n echo \"huge=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n\\n # Tiny diffs (<= 1 changed file AND <= 8 changed lines) aren\\'t worth a\\n # recap — skip generation cleanly. Use ORIGINAL_LINES (captured before\\n # any truncation) so a large diff is never misclassified as tiny.\\n if [ \"$CHANGED\" -le 1 ] && [ \"${ORIGINAL_LINES:-0}\" -le 8 ]; then\\n echo \"tiny=true\" >> \"$GITHUB_OUTPUT\"\\n else\\n echo \"tiny=false\" >> \"$GITHUB_OUTPUT\"\\n fi\\n\\n # Secret pre-scan: refuse to hand a diff that looks like it leaks\\n # credentials to the agent. Prints { suppressed, reason } and always exits 0.\\n - name: Secret scan\\n id: scan\\n if: steps.diff.outputs.tiny != \\'true\\'\\n run: |\\n set -uo pipefail\\n # Fail CLOSED: if the scanner errors or emits invalid JSON, treat the\\n # diff as suppressed, so a scan failure can never hand a possibly\\n # credential-bearing diff to the agent / plan service.\\n if ! SCAN_JSON=\"$($RECAP_CLI recap scan --diff recap.diff)\"; then\\n SCAN_JSON=\\'{\"suppressed\":true,\"reason\":\"secret scan failed to run; failing closed\"}\\'\\n fi\\n # Multi-line-safe write: SCAN_JSON could contain newlines, which would\\n # otherwise corrupt $GITHUB_OUTPUT or inject extra keys.\\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 # Find the planId from the previous sticky comment so a re-push REPLACES the\\n # same hosted plan (synchronize updates in place, no orphaned plans).\\n - name: Read previous plan id\\n id: prev\\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 # Build the agent prompt = the repo\\'s visual-recap SKILL.md + a task wrapper.\\n - name: Build recap prompt\\n id: prompt\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n run: |\\n set -euo pipefail\\n PREV=\"\"\\n if [ -n \"${{ steps.prev.outputs.plan_id }}\" ]; then PREV=\"--prev-plan-id ${{ steps.prev.outputs.plan_id }}\"; fi\\n HUGE=\"\"\\n if [ \"${{ steps.diff.outputs.huge }}\" = \"true\" ]; then HUGE=\"--huge\"; fi\\n $RECAP_CLI recap build-prompt \\\\\\n --diff recap.diff --stat recap.stat \\\\\\n --pr \"$PR_NUMBER\" --head \"$HEAD_SHA\" \\\\\\n --app-url \"$PLAN_RECAP_APP_URL\" \\\\\\n --out recap-prompt.md \\\\\\n $HUGE $PREV\\n\\n # Wire the plan MCP server for the chosen backend, then run the agent. The\\n # agent follows the skill, calls create-visual-recap + set-resource-visibility,\\n # and writes the published plan URL to recap-url.txt. continue-on-error so a\\n # failed agent run becomes an explanatory comment, not a red X.\\n - name: Run agent (Claude Code)\\n id: claude\\n if: needs.gate.outputs.agent == \\'claude\\' && steps.diff.outputs.tiny != \\'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 MCP_CONFIG=\"$RUNNER_TEMP/plan-mcp.json\"\\n node -e \\'const fs=require(\"fs\");fs.writeFileSync(process.argv[1],JSON.stringify({mcpServers:{plan:{type:\"http\",url:process.env.PLAN_RECAP_APP_URL.replace(/\\\\/$/,\"\")+\"/_agent-native/mcp\",headers:{Authorization:\"Bearer \"+process.env.PLAN_RECAP_TOKEN}}}}))\\' \"$MCP_CONFIG\"\\n # VISUAL_RECAP_MODEL picks the Claude model; reasoning depth is model-driven\\n # for Claude Code, so VISUAL_RECAP_REASONING only applies to the Codex backend.\\n CLAUDE_ARGS=(-p \"$(cat recap-prompt.md)\" --mcp-config \"$MCP_CONFIG\" --allowedTools \"Read,Write,Bash(git diff:*),mcp__plan__create-visual-recap,mcp__plan__set-resource-visibility\" --permission-mode dontAsk --output-format json)\\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CLAUDE_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\\n # Capture the final JSON result (usage + total_cost_usd) for the usage step.\\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-result.json || true\\n rm -f \"$MCP_CONFIG\" || true\\n\\n - name: Run agent (Codex)\\n id: codex\\n if: needs.gate.outputs.agent == \\'codex\\' && steps.diff.outputs.tiny != \\'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 mkdir -p \"$HOME/.codex\"\\n # JSON.stringify the URL into the TOML value so a stray quote/newline\\n # in PLAN_RECAP_APP_URL can\\'t break out of the string (TOML basic\\n # strings share JSON\\'s escaping); the key/env name stay literal.\\n node -e \\'const fs=require(\"fs\");const url=process.env.PLAN_RECAP_APP_URL.replace(/\\\\/$/,\"\")+\"/_agent-native/mcp\";fs.writeFileSync(process.env.HOME+\"/.codex/config.toml\",\"[mcp_servers.plan]\\\\nurl = \"+JSON.stringify(url)+\"\\\\nbearer_token_env_var = \\\\\"PLAN_RECAP_TOKEN\\\\\"\\\\n\")\\'\\n # Authenticate with the API key explicitly. Relying on the bare\\n # OPENAI_API_KEY env var alone is unreliable on the gpt-5.5 WebSocket\\n # transport: the Authorization header is dropped on the wss path and\\n # its HTTPS fallback, surfacing as `401 Missing bearer or basic\\n # authentication in header` (openai/codex#15492). `codex login\\n # --with-api-key` reads the key from stdin and writes ~/.codex/auth.json,\\n # which the exec path reads reliably; piping via stdin keeps the key out\\n # of the process args. Non-fatal so a login hiccup still yields the\\n # explanatory recap comment rather than a red X.\\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\\n # VISUAL_RECAP_MODEL (e.g. gpt-5.5) and VISUAL_RECAP_REASONING\\n # (none|minimal|low|medium|high|xhigh) tune the Codex run.\\n #\\n # The GitHub runner is itself an ephemeral, throwaway sandbox, so run\\n # Codex with sandboxing and approvals disabled. Codex\\'s own bubblewrap\\n # sandbox cannot initialize on the runner (\"could not find bubblewrap\\n # on PATH\"), which makes every shell command fail at startup so the\\n # agent cannot even read recap.diff; and under an approval gate the\\n # write-side plan MCP call (create-visual-recap) is auto-cancelled\\n # (\"user cancelled MCP tool call\"). --dangerously-bypass-approvals-and-sandbox\\n # is the documented invocation for externally-sandboxed CI and clears both.\\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 known enum before embedding it in the\\n # codex `-c` TOML override, so an unexpected value can\\'t alter the config.\\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 npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-prompt.md)\" | tee codex-events.jsonl || true\\n\\n # The agent\\'s only hand-off: recap-url.txt with the published plan URL.\\n - name: Read plan URL\\n id: url\\n if: steps.diff.outputs.tiny != \\'true\\' && steps.scan.outputs.suppressed != \\'true\\'\\n run: |\\n set -uo pipefail\\n PLAN_URL=\"\"\\n if [ -f recap-url.txt ]; then PLAN_URL=\"$(tr -d \\'\\\\r\\\\n\\' < recap-url.txt | tr -d \\' \\')\"; fi\\n echo \"plan_url=$PLAN_URL\" >> \"$GITHUB_OUTPUT\"\\n if [ -n \"$PLAN_URL\" ]; then echo \"ok=true\" >> \"$GITHUB_OUTPUT\"; else echo \"ok=false\" >> \"$GITHUB_OUTPUT\"; fi\\n\\n # Capture the agent run token usage and attach it to the published recap\\n # so the recap row carries input/output/cached tokens, the model, and a\\n # cost estimate. Informational and best-effort: never fails the job.\\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 run: |\\n set -uo pipefail\\n RESULT=claude-result.json\\n if [ \"$VISUAL_RECAP_AGENT\" = \"codex\" ]; then RESULT=codex-events.jsonl; fi\\n if [ -f \"$RESULT\" ]; then $RECAP_CLI recap usage --plan-url \"$PLAN_URL\" --agent \"$VISUAL_RECAP_AGENT\" --result-file \"$RESULT\" --model \"${VISUAL_RECAP_MODEL:-}\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\" || true; fi\\n\\n # Screenshot the published plan in headless Chrome and upload the PNG to the\\n # plan app\\'s signed public image route. Best-effort: never fails the job.\\n - name: Screenshot + upload\\n id: shot\\n if: steps.url.outputs.ok == \\'true\\'\\n continue-on-error: true\\n env:\\n # Pass the agent-produced plan URL through the environment, never via\\n # ${{ }} interpolation into the run script: recap-url.txt is untrusted\\n # agent output, so inlining it would be a shell-injection vector.\\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\\n run: |\\n set -uo pipefail\\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\\n SHOT_JSON=\"$($RECAP_CLI recap shot --url \"$PLAN_URL\" --token \"$PLAN_RECAP_TOKEN\" --app-url \"$PLAN_RECAP_APP_URL\" --out recap.png || echo \\'{}\\')\"\\n IMAGE_URL=$(node -e \\'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||\"\")}catch{process.stdout.write(\"\")}\\' \"$SHOT_JSON\")\\n echo \"image_url=$IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\\n if [ -f recap.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\\n with:\\n name: pr-visual-recap-${{ github.event.pull_request.number }}\\n path: recap.png\\n if-no-files-found: ignore\\n retention-days: 14\\n\\n # Upsert the single sticky comment: inline screenshot + link on success,\\n # suppressed / failed / tiny variants otherwise. Runs even on a tiny diff\\n # so a prior recap comment is refreshed (not left pointing at a stale SHA).\\n - name: Upsert sticky comment\\n if: always()\\n # The recap is informational/non-blocking: a failed comment upsert must\\n # not turn the whole job red (the agent + screenshot steps are already\\n # continue-on-error).\\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 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 run: |\\n set -euo pipefail\\n ARGS=(recap comment upsert --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\")\\n # On a tiny diff, only REFRESH an existing recap comment — never create\\n # a new one — so we add no noise but also can\\'t leave a stale prior\\n # recap behind.\\n if [ \"${DIFF_TINY:-}\" = \"true\" ]; then ARGS+=(--update-only); fi\\n $RECAP_CLI \"${ARGS[@]}\"\\n';\n"]}
|