@bridge_gpt/mcp-server 0.2.45 → 0.2.46

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/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- var __defProp=Object.defineProperty;var __getOwnPropNames=Object.getOwnPropertyNames;var __esm=(fn,res,err)=>function(){if(err)throw err[0];try{return fn&&(res=(0,fn[__getOwnPropNames(fn)[0]])(fn=0)),res}catch(e){throw err=[e],e}};var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:!0})};var VERSION,BUILD_COMMIT,init_version_generated=__esm({"src/version.generated.ts"(){"use strict";VERSION="0.2.45",BUILD_COMMIT="4cb362377d01"}});var COMMANDS,init_commands_generated=__esm({"src/commands.generated.ts"(){"use strict";COMMANDS={"bridge-research.md":`Run multi-source, fact-checked web research via Bridge API and save a cited report locally.
2
+ var __defProp=Object.defineProperty;var __getOwnPropNames=Object.getOwnPropertyNames;var __esm=(fn,res,err)=>function(){if(err)throw err[0];try{return fn&&(res=(0,fn[__getOwnPropNames(fn)[0]])(fn=0)),res}catch(e){throw err=[e],e}};var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:!0})};var VERSION,BUILD_COMMIT,init_version_generated=__esm({"src/version.generated.ts"(){"use strict";VERSION="0.2.46",BUILD_COMMIT="9e67d5495def"}});var COMMANDS,init_commands_generated=__esm({"src/commands.generated.ts"(){"use strict";COMMANDS={"bridge-research.md":`Run multi-source, fact-checked web research via Bridge API and save a cited report locally.
3
3
 
4
4
  $ARGUMENTS
5
5
 
@@ -62,7 +62,7 @@ Display a summary block:
62
62
  \`\`\`
63
63
 
64
64
  On failure at any step, stop immediately, display which step failed and the error details, and do not proceed.
65
- `,"check-ci.md":'# Check CI: $ARGUMENTS\n\n$ARGUMENTS\n\n> **Warning**: Keep this file behaviorally in sync with `mcp_server/instructions/monitor-ci-checks.md` to prevent drift (BAPI-462).\n\nThis command takes a Jira ticket key (e.g., `BAPI-150`), discovers CI checks for the current commit, polls their status, and applies confidence-gated code corrections for failures. It is designed to run after `/create-pr` completes.\n\nIf any critical stage fails (Stage 0), stop immediately and report which stage failed and why. Non-critical stages (Stage 1, Stage 2, and Stage 3) should log a warning but not stop the pipeline.\n\n---\n\n# Instructions\n\nYou are executing a 4-stage pipeline to monitor and respond to CI checks for a Jira ticket. Execute all stages in sequence.\n\n## Stage 0 \u2014 Setup and Argument Parsing\n\n1. **Parse `$ARGUMENTS`**: Extract a single required `ticket_key` argument. The expected format is a Jira ticket key such as `BAPI-150` or `PROJ-123` \u2014 one or more uppercase letters, a hyphen, and one or more digits (regex: `[A-Z]+-\\d+`). If `$ARGUMENTS` is empty or the value does not match the expected format, stop immediately and display:\n\n ```\n Invalid ticket key format: \'<value>\'. Expected format: PROJ-123 (uppercase letters, hyphen, digits).\n Usage: /check-ci <ticket_key> (e.g., /check-ci BAPI-150)\n ```\n\n2. **Connectivity check**: Call the `ping` MCP tool (no parameters). If the ping fails or does not return `"status": "ok"`, stop immediately and display:\n\n ```\n Connectivity check failed. Please verify:\n - Check that the Bridge API MCP server is configured in your editor\'s MCP settings\n - Check that BAPI_BASE_URL is set and the server is reachable\n - Check that BAPI_API_KEY is valid\n - Check that BAPI_REPO_NAME matches a configured repository\n ```\n\n3. **Get current commit SHA**: Run `git rev-parse HEAD` in the terminal. Store the result as `commit_sha`.\n\n4. **Get current branch**: Run `git branch --show-current` in the terminal. Store the result as `current_branch`.\n\nThis stage is **critical** \u2014 stop immediately on failure. Do not proceed to Stage 1.\n\n## Stage 1 \u2014 Resolve CI Checks\n\n1. **Initial resolution**: Call the `resolve_ci_checks` MCP tool immediately with `commit_ref` set to `commit_sha`. Do NOT wait before calling \u2014 the cache state is unknown until the first call returns.\n\n2. **Handle the response**:\n - If the response contains `source: "cached"`: The checks were already resolved. Skip the wait and proceed to evaluate the check list.\n - If the response contains `source: "new"` and the check list is empty: CI checks have not registered yet. Run `sleep 45` in the terminal to wait for checks to appear, then call `resolve_ci_checks` again with `commit_ref` set to `commit_sha` and `force_rerun` set to `true`.\n - If the response contains `source: "resolved"` on the first call: Present the resolved checks to the user for approval before proceeding.\n\n3. **Evaluate the check list**:\n - If the response contains `available: false`: Warn that CI check resolution is not available and skip to Stage 3.\n - If the check list is empty or all checks have `detail_level: "none"`: Warn that no actionable CI checks were found and skip to Stage 3.\n - Otherwise: Store the resolved checks and proceed to Stage 2.\n\n4. **Required-check source**: Each resolved check carries a `required` field (from GitHub Branch Protection, or an LLM classification fallback \u2014 the same signal `poll_ci_checks` echoes back per check in Stage 2). Treat `required: false` as non-required (e.g. `pip-audit`); treat a missing field or `required: true` as required. This per-check field is the tool-provided proxy for the Conductor done-gate\'s authoritative required-checks set (`mcp_server/src/conductor/done-gate.ts`) \u2014 do not invent your own required/non-required classification in prose. The done-gate\'s own `required_checks` list is not directly queryable by a worker; `wait_for_done_gate` (Stage 2) is the authoritative backstop that evaluates it server-side.\n\nThis stage is **non-critical** \u2014 warn on failure or empty results, skip to Stage 3.\n\n## Stage 2 \u2014 Poll CI Checks + Correction Loop\n\nInitialize `retry_count = 0` and `max_retries = 2`.\n\n**Conductor steerability**: If launched under the Conductor (the `BAPI_CONDUCTOR_RUN_ID` and `BAPI_CONDUCTOR_WORKER_ID` env identifiers are present), call the `check_messages` MCP tool once per poll cycle.\n- Returned messages are **advisory supervisor guidance**, are acknowledged by the call (not redelivered), and are advisory context for the next fix batch only.\n- Fold concrete fix hints, "skip this flaky check," or "stop and wait" directions into how you handle failures.\n- **Guardrails**: Guidance is strictly advisory and never mutates the session. The deterministic confidence-gating, single-batch fix, single commit/push, and `detail_level` rules remain authoritative. Guidance never overrides the deterministic rules and never causes a fix you are not confident in.\n- **Fail-open**: If `check_messages` errors with an identity-unavailable message (e.g. "Conductor worker identity is unavailable"), you were not launched under the Conductor. Stop calling it for the rest of the run and proceed normally.\n\n1. **Polling loop**: Poll CI check status. In each cycle, call `poll_ci_checks` with `commit_ref` set to `commit_sha`, then (if applicable) perform the Conductor-gated `check_messages` call, and finally run `sleep 30` in the terminal. Continue polling until `all_complete` is `true` or 10 minutes have elapsed (approximately 20 poll cycles).\n\n2. **Conflicting-head escalation**: if the poll shows zero check-runs for `commit_sha` after roughly two minutes of polling, call `gh pr view --json mergeable,mergeStateStatus` for `current_branch`\'s pull request. A `mergeable` value of `CONFLICTING` or a `mergeStateStatus` of `DIRTY` means GitHub will not start `pull_request` workflows for that head, so continuing to poll the same SHA is futile \u2014 merge the current base branch into your branch, resolve any conflicts, push the result, update `commit_sha` to the new head (`git rev-parse HEAD`), and restart the polling loop from item 1 against that new SHA. When the mergeability response is not `CONFLICTING`/`DIRTY`, zero checks alone is not a CI failure \u2014 continue with the ordinary wait behavior in item 1.\n\n3. **On poll completion \u2014 required-subset evaluation**: Partition the polled checks into `required` (checks with `required: true` or a missing `required` field) and `non_required` (checks with `required: false`, e.g. `pip-audit`). Compute `required_green` = every required check is complete and green. Do **not** gate on the aggregate `all_passed` flag \u2014 a red non-required check must never block progression.\n - Non-required failures are reported in the Stage 3 breakdown but are **never blocking**: they do not gate progression, do not consume `retry_count`, and are not sent through the fix loop in item 4.\n - If `required_green` is `false` (a required check is still red), proceed to item 4 to attempt fixes for the failing **required** checks only.\n - If `required_green` is `true`:\n - **Review verdict gating**: if `claude-review` is one of the required checks, its GitHub check reaching a non-pending/"success" state means only that the review action *ran* \u2014 this is **transport completion, not approval**. Fetch the PR\'s comments (e.g. `gh pr view --json comments`) and look for the sticky comment\'s machine-readable verdict line. The review counts as approved only when that comment contains `claude-review-verdict: approved` on its own line **and** the accompanying `Reviewed-SHA:` line matches the current `commit_sha` \u2014 a verdict posted against an older head does not count. `docs/claude/claude-review-verdict-contract.md` is the authoritative definition of this grammar, of which comment wins when several carry a verdict, and of every fail-closed outcome; consult it rather than re-deriving the rules, and do not restate a different version of them here.\n - **Missing or stale verdict** (no `claude-review-verdict:` line at all, or a `Reviewed-SHA:` that does not match the current `commit_sha`): do not treat the review as approved. Report it in Stage 3 and do **not** call `wait_for_done_gate` this cycle \u2014 a missing or stale verdict is not approval. A verdict can also be *temporarily* absent because a running review rewrote the sticky comment in place (the vanishing window, contract \xA77): while a `claude-review` run is still pending, treat absence as transient and keep polling; once it has completed, absence is final and fails closed.\n - **`claude-review-verdict: changes_requested`** (the reviewer rejected the current head): do **not** call `wait_for_done_gate` for the rejected old head. When you are confident you can address the review, remediate it in-session \u2014 mirroring the confidence-gated commit+push already specified for CI-failure fixes \u2014 instead of merely reporting it:\n - Read the review findings from the sticky comment and any inline review comments.\n - Apply confidence gating: remediate only when you are confident you can address ALL findings; otherwise report in Stage 3 without committing.\n - Address all review findings across all affected files in a single batch. Do not fix one at a time.\n - After applying all fixes, perform a single `git commit` and `git push` so `origin/feature/<KEY>` advances to the new head and a fresh CI/review runs against it.\n - Increment `retry_count`. If `retry_count` exceeds `max_retries`: if launched under the Conductor, call `check_messages` one final time; if the supervisor sent explicit "continue" guidance with a concrete hint, apply it for exactly **one additional batch** (the only way the ceiling is raised); otherwise stop the correction loop and proceed to Stage 3 with a warning.\n - Otherwise, update `commit_sha` to the new HEAD (`git rev-parse HEAD`) and restart the polling loop against the new head.\n - **Conductor done-gate**: once the required subset is green and (if `claude-review` is required) the verdict token confirms approval for the current head, and if launched under the Conductor (the `BAPI_CONDUCTOR_RUN_ID`/`BAPI_CONDUCTOR_WORKER_ID` identifiers are present), call the `wait_for_done_gate` MCP tool **once** from inside your worktree before proceeding (no arguments are required \u2014 it self-resolves the PR number and head commit SHA from the worktree, fetches the review snapshot, evaluates the composite done-gate against the Conductor\'s authoritative `required_checks` config, and emits a `gate.met` event correlated to your run/worker so the supervisor can fold and merge). This tool does **not** merge or mutate the repo. It applies its own bounded fast-path poll (a short internal cap, not a multi-minute wait) \u2014 if it times out or does not observe `gate_met`, **exit cleanly** to Stage 3 without treating the timeout as a failure: the Conductor\'s own reconciliation pass is the correctness backstop for reaching `gate.met`, not this call. Fail-open: if the tool errors with an identity-unavailable message, you were not launched under the Conductor \u2014 skip it. Then proceed to Stage 3.\n\n4. **On required-subset failures detected**: Examine each failed **required** check\'s `detail_level` (non-required failures such as `pip-audit` are never processed here \u2014 they were already reported and skipped in item 3, and never consume a retry):\n\n - **`detail_level: "full"`** \u2014 Apply confidence gating:\n - Read the full `failure_details` payload for all failed checks.\n - If you are confident you can fix the errors, address ALL failures across all affected files in a single batch. Do not fix one at a time.\n - After applying all fixes, perform a single `git commit` and `git push`.\n - Increment `retry_count`.\n - If `retry_count` exceeds `max_retries`: if launched under the Conductor, call `check_messages` one final time. If the supervisor sent explicit "continue" guidance with a concrete hint, apply it for exactly **one additional batch** (this is the only way the ceiling is raised). Otherwise, stop the correction loop and proceed to Stage 3 with a warning.\n - Otherwise, update `commit_sha` to the new HEAD (`git rev-parse HEAD`) and restart the polling loop.\n - **`detail_level: "url_only"`** \u2014 Report the check name and URL to the user. Do not attempt fixes. Do not consume a retry.\n - **`detail_level: "none"`** \u2014 Report the check name only. Do not attempt fixes. Do not consume a retry.\n\n5. **Handle `unknown_checks`**: If the poll response contains `unknown_checks`, call `resolve_ci_checks` with `commit_ref` set to `commit_sha` and `force_rerun` set to `true` at most ONCE. If `unknown_checks` persist on the next poll, warn the user that the CI check configuration is unresolvable and skip to Stage 3.\n\n6. **Timeout**: If 10 minutes elapse without `all_complete` becoming `true`, warn that polling timed out and proceed to Stage 3.\n\nThis stage is **non-critical** \u2014 warn on failure or timeout, continue to Stage 3 regardless.\n\n## Stage 3 \u2014 Summary Report\n\nDisplay a structured completion report. The report **must** be presented as markdown, using exactly the structure below:\n\n```\n## CI Check Report\n\n**Ticket**: <ticket_key>\n**Branch**: <current_branch>\n**Commit SHA**: <commit_sha>\n**Status**: <Passed / Failed / Timed Out / Not Available>\n\n**Per-check breakdown**:\n| Check Name | Status | Required | Detail Level |\n|------------|--------|----------|--------------|\n| <name> | <pass/fail> | <yes/no> | <full/url_only/none> |\n\n**Review verdict**: <approved / changes_requested / not yet posted / N/A \u2014 claude-review not required>\n**Fixes attempted**: <retry_count> of <max_retries>\n**Supervisor guidance applied**: <yes/no>\n\n**Warnings**:\n<If any non-critical stages had warnings (Stage 1: resolution unavailable,\nStage 2: timeout, unfixable required-check failures, unknown_checks,\nnon-required check failures reported for visibility),\nlist them here. If no warnings, omit this section.>\n```\n\nThis stage is **non-critical** \u2014 display the report regardless.\n\n## Final Report\n\nOn success, display the structured report from Stage 3 confirming the CI check status, including per-check breakdown, fixes attempted, and any warnings from earlier stages.\n\nOn failure at any critical stage (Stage 0), display which stage failed and the error details.\n',"clarify-ticket.md":'Generate clarifying questions for a Jira ticket and save them locally.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\n`$ARGUMENTS` is a required Jira ticket key (e.g., `PROJ-123`). This command generates clarifying questions for the ticket and saves them locally.\n\nIf any step fails, stop immediately and report which step failed and why.\n\n## Step 1 \u2014 Parse Arguments\n\n1. **Parse `$ARGUMENTS`**: Extract a required `ticket_key`, an optional `--second-opinion` flag, and an optional `--provider` flag.\n - Split `$ARGUMENTS` on whitespace.\n - If `--second-opinion` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `second_opinion_value`.\n - If `--second-opinion` appears without a provider name following it (or is the last token), set `second_opinion_value = "auto"`.\n - If `--second-opinion` is absent, set `second_opinion_value = null`.\n - If `--provider` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `provider_value`.\n - If `--provider` appears without a valid provider name following it (or is the last token), stop immediately and report: "Usage error: --provider requires a provider name (openai, anthropic, or gemini)."\n - If `--provider` is absent, set `provider_value = null`.\n - If both `--second-opinion` and `--provider` are present, `--second-opinion` takes precedence (set `provider_value = null`).\n - The remaining token (after removing flags and their arguments) is the `ticket_key`.\n\n2. **Validate ticket key format**: Confirm the ticket key matches the Jira key pattern `[A-Z][A-Z0-9]+-\\d+`. If it does not match, stop immediately and report: "Invalid ticket key format. Expected a Jira key like PROJ-123."\n\n## Step 2 \u2014 Resolve Docs Directory\n\nCall the `get_docs_dir` MCP tool (no parameters). Store the returned path as `docs_dir`.\n\n## Step 3 \u2014 Generate Clarifying Questions\n\nCall the `request_clarifying_questions` MCP tool with:\n- `ticket_number`: the validated `ticket_key`\n- `wait_for_result`: `true`\n- `save_locally`: `true`\n- `second_opinion`: set to `second_opinion_value` if non-null; omit entirely if null\n- `provider`: set to `provider_value` if non-null; omit entirely if null\n\nIf the tool returns an error, stop immediately and report: "Clarifying questions generation failed." Include the error details.\n\n## Final Report\n\nOn successful completion, display:\n\n> **Ticket Key**: {ticket_key}\n>\n> **Local File Path**: {docs_dir}/clarifying-questions/{ticket_key}-clarifying-questions.md\n>\n> **Status**: The clarifying questions document has been saved locally. No changes were pushed to Jira.\n>\n> To incorporate these findings into the Jira ticket description, run: `/update-ticket {ticket_key}`\n',"code-ticket.md":`# Code Ticket: $ARGUMENTS
65
+ `,"check-ci.md":'# Check CI: $ARGUMENTS\n\n$ARGUMENTS\n\n> **Warning**: Keep this file behaviorally in sync with `mcp_server/instructions/monitor-ci-checks.md` to prevent drift (BAPI-462).\n\nThis command takes a Jira ticket key (e.g., `BAPI-150`), discovers CI checks for the current commit, polls their status, and applies confidence-gated code corrections for failures. It is designed to run after `/create-pr` completes.\n\nIf any critical stage fails (Stage 0), stop immediately and report which stage failed and why. Non-critical stages (Stage 1, Stage 2, and Stage 3) should log a warning but not stop the pipeline.\n\n---\n\n# Instructions\n\nYou are executing a 4-stage pipeline to monitor and respond to CI checks for a Jira ticket. Execute all stages in sequence.\n\n## Stage 0 \u2014 Setup and Argument Parsing\n\n1. **Parse `$ARGUMENTS`**: Extract a single required `ticket_key` argument. The expected format is a Jira ticket key such as `BAPI-150` or `PROJ-123` \u2014 one or more uppercase letters, a hyphen, and one or more digits (regex: `[A-Z]+-\\d+`). If `$ARGUMENTS` is empty or the value does not match the expected format, stop immediately and display:\n\n ```\n Invalid ticket key format: \'<value>\'. Expected format: PROJ-123 (uppercase letters, hyphen, digits).\n Usage: /check-ci <ticket_key> (e.g., /check-ci BAPI-150)\n ```\n\n2. **Connectivity check**: Call the `ping` MCP tool (no parameters). If the ping fails or does not return `"status": "ok"`, stop immediately and display:\n\n ```\n Connectivity check failed. Please verify:\n - Check that the Bridge API MCP server is configured in your editor\'s MCP settings\n - Check that BAPI_BASE_URL is set and the server is reachable\n - Check that BAPI_API_KEY is valid\n - Check that BAPI_REPO_NAME matches a configured repository\n ```\n\n3. **Get current commit SHA**: Run `git rev-parse HEAD` in the terminal. Store the result as `commit_sha`.\n\n4. **Get current branch**: Run `git branch --show-current` in the terminal. Store the result as `current_branch`.\n\nThis stage is **critical** \u2014 stop immediately on failure. Do not proceed to Stage 1.\n\n## Stage 1 \u2014 Resolve CI Checks\n\n1. **Initial resolution**: Call the `resolve_ci_checks` MCP tool immediately with `commit_ref` set to `commit_sha`. Do NOT wait before calling \u2014 the cache state is unknown until the first call returns.\n\n2. **Handle the response**:\n - If the response contains `source: "cached"`: The checks were already resolved. Skip the wait and proceed to evaluate the check list.\n - If the response contains `source: "new"` and the check list is empty: CI checks have not registered yet. Run `sleep 45` in the terminal to wait for checks to appear, then call `resolve_ci_checks` again with `commit_ref` set to `commit_sha` and `force_rerun` set to `true`.\n - If the response contains `source: "resolved"` on the first call: Present the resolved checks to the user for approval before proceeding.\n\n3. **Evaluate the check list**:\n - If the response contains `available: false`: Warn that CI check resolution is not available and skip to Stage 3.\n - If the check list is empty or all checks have `detail_level: "none"`: Warn that no actionable CI checks were found and skip to Stage 3.\n - Otherwise: Store the resolved checks and proceed to Stage 2.\n\n4. **Required-check source**: Each resolved check carries a `required` field (from GitHub Branch Protection, or an LLM classification fallback \u2014 the same signal `poll_ci_checks` echoes back per check in Stage 2). Treat `required: false` as non-required (e.g. `pip-audit`); treat a missing field or `required: true` as required. This per-check field is the tool-provided proxy for the Conductor done-gate\'s authoritative required-checks set (`mcp_server/src/conductor/done-gate.ts`) \u2014 do not invent your own required/non-required classification in prose. The done-gate\'s own `required_checks` list is not directly queryable by a worker; `wait_for_done_gate` (Stage 2) is the authoritative backstop that evaluates it server-side.\n\nThis stage is **non-critical** \u2014 warn on failure or empty results, skip to Stage 3.\n\n## Stage 2 \u2014 Poll CI Checks + Correction Loop\n\nInitialize `retry_count = 0` and `max_retries = 2`.\n\n**Conductor steerability**: If launched under the Conductor (the `BAPI_CONDUCTOR_RUN_ID` and `BAPI_CONDUCTOR_WORKER_ID` env identifiers are present), call the `check_messages` MCP tool once per poll cycle.\n- Returned messages are **advisory supervisor guidance**, are acknowledged by the call (not redelivered), and are advisory context for the next fix batch only.\n- Fold concrete fix hints, "skip this flaky check," or "stop and wait" directions into how you handle failures.\n- **Guardrails**: Guidance is strictly advisory and never mutates the session. The deterministic confidence-gating, single-batch fix, single commit/push, and `detail_level` rules remain authoritative. Guidance never overrides the deterministic rules and never causes a fix you are not confident in.\n- **Fail-open**: If `check_messages` errors with an identity-unavailable message (e.g. "Conductor worker identity is unavailable"), you were not launched under the Conductor. Stop calling it for the rest of the run and proceed normally.\n\n1. **Polling loop**: Poll CI check status. In each cycle, call `poll_ci_checks` with `commit_ref` set to `commit_sha`, then (if applicable) perform the Conductor-gated `check_messages` call, and finally run `sleep 30` in the terminal. Continue polling until `all_complete` is `true` or 10 minutes have elapsed (approximately 20 poll cycles).\n\n2. **Conflicting-head escalation**: if the poll shows zero check-runs for `commit_sha` after roughly two minutes of polling, call `gh pr view --json mergeable,mergeStateStatus` for `current_branch`\'s pull request. A `mergeable` value of `CONFLICTING` or a `mergeStateStatus` of `DIRTY` means GitHub will not start `pull_request` workflows for that head, so continuing to poll the same SHA is futile \u2014 merge the current base branch into your branch, resolve any conflicts, push the result, update `commit_sha` to the new head (`git rev-parse HEAD`), and restart the polling loop from item 1 against that new SHA. When the mergeability response is not `CONFLICTING`/`DIRTY`, zero checks alone is not a CI failure \u2014 continue with the ordinary wait behavior in item 1.\n\n3. **On poll completion \u2014 required-subset evaluation**: Partition the polled checks into `required` (checks with `required: true` or a missing `required` field) and `non_required` (checks with `required: false`, e.g. `pip-audit`). Compute `required_green` = every required check is complete and green. Do **not** gate on the aggregate `all_passed` flag \u2014 a red non-required check must never block progression.\n - Non-required failures are reported in the Stage 3 breakdown but are **never blocking**: they do not gate progression, do not consume `retry_count`, and are not sent through the fix loop in item 4.\n - If `required_green` is `false` (a required check is still red), proceed to item 4 to attempt fixes for the failing **required** checks only.\n - If `required_green` is `true`:\n - **Review verdict gating**: if `claude-review` is one of the required checks, its GitHub check reaching a non-pending/"success" state means only that the review action *ran* \u2014 this is **transport completion, not approval**. Fetch the PR\'s comments (e.g. `gh pr view --json comments`) and look for the sticky comment\'s machine-readable verdict line. The review counts as approved only when that comment contains `claude-review-verdict: approved` on its own line **and** the accompanying `Reviewed-SHA:` line matches the current `commit_sha` \u2014 a verdict posted against an older head does not count. `docs/claude/claude-review-verdict-contract.md` is the authoritative definition of this grammar, of which comment wins when several carry a verdict, and of every fail-closed outcome; consult it rather than re-deriving the rules, and do not restate a different version of them here.\n - **Missing or stale verdict** (no `claude-review-verdict:` line at all, or a `Reviewed-SHA:` that does not match the current `commit_sha`): do not treat the review as approved. Report it in Stage 3 and do **not** call `wait_for_done_gate` this cycle \u2014 a missing or stale verdict is not approval. A verdict can also be *temporarily* absent because a running review rewrote the sticky comment in place (the vanishing window, contract \xA77): while a `claude-review` run is still pending, treat absence as transient and keep polling; once it has completed, absence is final and fails closed.\n - **`claude-review-verdict: changes_requested`** (the reviewer rejected the current head): do **not** call `wait_for_done_gate` for the rejected old head. When you are confident you can address the review, remediate it in-session \u2014 mirroring the confidence-gated commit+push already specified for CI-failure fixes \u2014 instead of merely reporting it:\n - Read the review findings from the sticky comment and any inline review comments.\n - Apply confidence gating: remediate only when you are confident you can address ALL findings; otherwise report in Stage 3 without committing.\n - Address all review findings across all affected files in a single batch. Do not fix one at a time.\n - After applying all fixes, perform a single `git commit` and `git push` so `origin/feature/<KEY>` advances to the new head and a fresh CI/review runs against it.\n - Increment `retry_count`. If `retry_count` exceeds `max_retries`: if launched under the Conductor, call `check_messages` one final time; if the supervisor sent explicit "continue" guidance with a concrete hint, apply it for exactly **one additional batch** (the only way the ceiling is raised); otherwise stop the correction loop and proceed to Stage 3 with a warning.\n - Otherwise, update `commit_sha` to the new HEAD (`git rev-parse HEAD`) and restart the polling loop against the new head.\n - **Conductor done-gate**: once the required subset is green and (if `claude-review` is required) the verdict token confirms approval for the current head, and if launched under the Conductor (the `BAPI_CONDUCTOR_RUN_ID`/`BAPI_CONDUCTOR_WORKER_ID` identifiers are present), call the `wait_for_done_gate` MCP tool **once** from inside your worktree before proceeding (no arguments are required \u2014 it self-resolves the PR number and head commit SHA from the worktree, fetches the review snapshot, evaluates the composite done-gate against the Conductor\'s authoritative `required_checks` config, and emits a `gate.met` event correlated to your run/worker so the supervisor can fold and merge). This tool does **not** merge or mutate the repo. It applies its own bounded fast-path poll (a short internal cap, not a multi-minute wait) \u2014 if it times out or does not observe `gate_met`, **exit cleanly** to Stage 3 without treating the timeout as a failure: the Conductor\'s own reconciliation pass is the correctness backstop for reaching `gate.met`, not this call. Fail-open: if the tool errors with an identity-unavailable message, you were not launched under the Conductor \u2014 skip it. Then proceed to Stage 3.\n\n4. **On required-subset failures detected**: Examine each failed **required** check\'s `detail_level` (non-required failures such as `pip-audit` are never processed here \u2014 they were already reported and skipped in item 3, and never consume a retry):\n\n - **`detail_level: "full"`** \u2014 Apply confidence gating:\n - Read the full `failure_details` payload for all failed checks.\n - If you are confident you can fix the errors, address ALL failures across all affected files in a single batch. Do not fix one at a time.\n - After applying all fixes, perform a single `git commit` and `git push`.\n - Increment `retry_count`.\n - If `retry_count` exceeds `max_retries`: if launched under the Conductor, call `check_messages` one final time. If the supervisor sent explicit "continue" guidance with a concrete hint, apply it for exactly **one additional batch** (this is the only way the ceiling is raised). Otherwise, stop the correction loop and proceed to Stage 3 with a warning.\n - Otherwise, update `commit_sha` to the new HEAD (`git rev-parse HEAD`) and restart the polling loop.\n - **`detail_level: "url_only"`** \u2014 Report the check name and URL to the user. Do not attempt fixes. Do not consume a retry.\n - **`detail_level: "none"`** \u2014 Report the check name only. Do not attempt fixes. Do not consume a retry.\n\n5. **Handle `unknown_checks`**: If the poll response contains `unknown_checks`, call `resolve_ci_checks` with `commit_ref` set to `commit_sha` and `force_rerun` set to `true` at most ONCE. If `unknown_checks` persist on the next poll, warn the user that the CI check configuration is unresolvable and skip to Stage 3.\n\n6. **Timeout**: If 10 minutes elapse without `all_complete` becoming `true`, warn that polling timed out and proceed to Stage 3.\n\nThis stage is **non-critical** \u2014 warn on failure or timeout, continue to Stage 3 regardless.\n\n## Stage 3 \u2014 Summary Report\n\nDisplay a structured completion report. The report **must** be presented as markdown, using exactly the structure below:\n\n```\n## CI Check Report\n\n**Ticket**: <ticket_key>\n**Branch**: <current_branch>\n**Commit SHA**: <commit_sha>\n**Status**: <Passed / Failed / Timed Out / Not Available>\n\n**Per-check breakdown**:\n| Check Name | Status | Required | Detail Level |\n|------------|--------|----------|--------------|\n| <name> | <pass/fail> | <yes/no> | <full/url_only/none> |\n\n**Review verdict**: <approved / changes_requested / not yet posted / N/A \u2014 claude-review not required>\n**Fixes attempted**: <retry_count> of <max_retries>\n**Supervisor guidance applied**: <yes/no>\n\n**Warnings**:\n<If any non-critical stages had warnings (Stage 1: resolution unavailable,\nStage 2: timeout, unfixable required-check failures, unknown_checks,\nnon-required check failures reported for visibility),\nlist them here. If no warnings, omit this section.>\n```\n\nThis stage is **non-critical** \u2014 display the report regardless.\n\n## Final Report\n\nOn success, display the structured report from Stage 3 confirming the CI check status, including per-check breakdown, fixes attempted, and any warnings from earlier stages.\n\nOn failure at any critical stage (Stage 0), display which stage failed and the error details.\n',"clarify-ticket.md":'Generate clarifying questions for a Jira ticket and save them locally.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\n`$ARGUMENTS` is a required Jira ticket key (e.g., `PROJ-123`). This command generates clarifying questions for the ticket and saves them locally.\n\nIf any step fails, stop immediately and report which step failed and why.\n\n## Step 1 \u2014 Parse Arguments\n\n1. **Parse `$ARGUMENTS`**: Extract a required `ticket_key`, an optional `--second-opinion` flag, and an optional `--provider` flag.\n - Split `$ARGUMENTS` on whitespace.\n - If `--second-opinion` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `second_opinion_value`.\n - If `--second-opinion` appears without a provider name following it (or is the last token), set `second_opinion_value = "auto"`.\n - If `--second-opinion` is absent, set `second_opinion_value = null`.\n - If `--provider` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `provider_value`.\n - If `--provider` appears without a valid provider name following it (or is the last token), stop immediately and report: "Usage error: --provider requires a provider name (openai, anthropic, or gemini)."\n - If `--provider` is absent, set `provider_value = null`.\n - If both `--second-opinion` and `--provider` are present, `--second-opinion` takes precedence (set `provider_value = null`).\n - The remaining token (after removing flags and their arguments) is the `ticket_key`.\n\n2. **Validate ticket key format**: Confirm the ticket key matches the Jira key pattern `[A-Z][A-Z0-9]+-\\d+`. If it does not match, stop immediately and report: "Invalid ticket key format. Expected a Jira key like PROJ-123."\n\n## Step 2 \u2014 Resolve Docs Directory\n\nCall the `get_docs_dir` MCP tool (no parameters). Store the returned path as `docs_dir`.\n\n## Step 3 \u2014 Generate Clarifying Questions\n\nCall the `request_clarifying_questions` MCP tool with:\n- `ticket_number`: the validated `ticket_key`\n- `wait_for_result`: `true`\n- `save_locally`: `true`\n- `second_opinion`: set to `second_opinion_value` if non-null; omit entirely if null\n- `provider`: set to `provider_value` if non-null; omit entirely if null\n\nIf the tool returns an error, stop immediately and report: "Clarifying questions generation failed." Include the error details.\n\n## Final Report\n\nOn successful completion, display:\n\n> **Ticket Key**: {ticket_key}\n>\n> **Local File Path**: {docs_dir}/clarifying-questions/{ticket_key}-clarifying-questions.md\n>\n> **Status**: The clarifying questions document has been saved locally. No changes were pushed to Jira.\n>\n> To incorporate these findings into the Jira ticket description, ask your agent to update the description for {ticket_key} using this document.\n',"code-ticket.md":`# Code Ticket: $ARGUMENTS
66
66
 
67
67
  $ARGUMENTS
68
68
 
@@ -385,7 +385,7 @@ This stage is **non-critical** \u2014 log a warning on failure but do not stop t
385
385
  On success, display the structured report from Stage 4 confirming that the commit and push are complete, including the branch name, commit hash, file list, and any warnings from earlier stages.
386
386
 
387
387
  On failure at any critical stage (Stage 0, Stage 1, or Stage 3), display which stage failed and the error details.
388
- `,"conduct-epic.md":"---\nschedulable: true\narguments: {\"positionals\":[{\"name\":\"epicKey\",\"type\":\"string\",\"required\":true}],\"flags\":[{\"name\":\"tickets\",\"flag\":\"--tickets\",\"type\":\"string\"},{\"name\":\"baseBranch\",\"flag\":\"--base-branch\",\"type\":\"string\"},{\"name\":\"checkpointPath\",\"flag\":\"--checkpoint-path\",\"type\":\"string\"}]}\n---\n\n# Conduct Epic: $ARGUMENTS\n\n$ARGUMENTS\n\nThis command drives one multi-ticket epic from an approved ticket list to a finished `epic/<EPIC>` branch, one ticket at a time. It is the LLM half of the LLM-as-conductor pivot: there is no server-side reconciler here, no background worker, and no durable run row \u2014 the checkpoint file written by the packaged `conduct-epic` CLI plus the live state of GitHub *is* the entire memory of the loop.\n\nCadence is not an argument of this command. `/loop` owns the interval, this command owns exactly one reconcile-then-act step, and the two compose without either one holding state for the other.\n\nIt composes work that already exists rather than reimplementing it: `/review-and-start --auto --base-branch 'epic/<EPIC>' <KEY>` spawns each ticket's worker, the `merge_pull_request` MCP tool merges a green and approved pull request, `parse_repository` / `get_parse_status` re-index the repository after each merge so the next ticket's plan sees its predecessor's code, and the packaged `conduct-epic` CLI (`init`, `status`, `checkpoint set`, `finish`, `spawn`) owns every durable file operation.\n\n---\n\n# Instructions\n\nYou are executing a 5-stage tick. Run the stages in order, take **exactly one** action from the Stage 3 detection table, write **exactly one** checkpoint in Stage 4, then stop. Do not loop internally, do not take a second action because the first one looked cheap, and do not carry assumptions from a previous tick \u2014 every tick reconciles from scratch.\n\nThe \"exactly one checkpoint\" rule has **three explicitly documented exemptions** and no others: the two print-only parks, `init_failed` (Stage 1) and `foreign_lock` (Stage 2), which stop before Stage 3; and the `all_done` tick (Row 1), which has no in-flight ticket to name in a `checkpoint set` command. Stage 4 states each one.\n\n## Stage 0 \u2014 Arguments and Ping\n\n1. **Parse `$ARGUMENTS`** into exactly one epic positional and the three optional flags. Accept no other input shape.\n\n - **`<EPIC>`**: exactly one positional token, which must match `[A-Z]+-[0-9]+` (e.g. `BAPI-798`). Zero epic positionals, more than one positional, or a positional that does not match the pattern is malformed input. Extra positionals are rejected rather than ignored.\n - **`--tickets <K1,K2,\u2026>`** (and the equals form `--tickets=<K1,K2,\u2026>`): a non-empty, comma-separated, **ordered** list of ticket keys. Preserve the caller's order exactly \u2014 it is the execution order of the epic. Every entry must match `[A-Z]+-[0-9]+` after trimming surrounding whitespace; reject a malformed key, an empty entry, and a duplicate key. This flag is required **only on the first tick** (see Stage 1); later ticks read the order from the checkpoint.\n - **`--base-branch <branch>`** (and the equals form `--base-branch=<branch>`): validated with the same rules as `/start-tickets` Stage 0 \u2014 after trimming surrounding whitespace it must be non-empty, at most 255 characters, must not start with `-`, and must not contain ASCII control characters (`0x00`\u2013`0x1F` or `0x7F`). It is the branch `epic/<EPIC>` is cut from at `init` time; it is not the pull-request base of a ticket, which is always `epic/<EPIC>`.\n - **`--checkpoint-path <path>`** (and the equals form `--checkpoint-path=<path>`): must be a non-empty string after trimming, checked **before** it is used as a path or interpolated into a CLI invocation. When omitted, the CLI's own default (`~/.config/bridge/conduct/<repo>/<EPIC>.json`) applies and `status` prints the resolved path.\n\n Reject malformed input before any side effect: an unsupported flag, a flag given without its value, a `--tickets` list that fails the rules above, a `--base-branch` value that fails validation, an empty `--checkpoint-path`, a missing epic, or an extra positional. On any of these, stop immediately and display:\n\n ```\n Invalid arguments.\n Usage: /conduct-epic [flags] <EPIC>\n <EPIC> required, matches [A-Z]+-[0-9]+ (e.g. BAPI-798)\n --tickets K1,K2,\u2026 ordered ticket keys; required only on the first tick\n --base-branch <branch> branch epic/<EPIC> is cut from (default: the repo base)\n --checkpoint-path <path> override the checkpoint file location\n ```\n\n2. **Connectivity check**: call the `ping` MCP tool with **no parameters**. If the call fails, or does not return `\"status\": \"ok\"`, stop immediately \u2014 before Stage 1 initialization, before any CLI invocation, and before any state is written \u2014 and display:\n\n ```\n Connectivity check failed. Please verify:\n - Check that the Bridge API MCP server is configured in your editor's MCP settings\n - Check that BAPI_BASE_URL is set and the server is reachable\n - Check that BAPI_API_KEY is valid\n - Check that BAPI_REPO_NAME matches a configured repository\n ```\n\n3. **Execution model.** This command is one tick; run it with `/loop 5m /conduct-epic <EPIC>`; each tick re-reads the checkpoint and GitHub, takes at most one action, and exits. `/loop` is the external driver that re-invokes this command \u2014 it is not an internal loop this command runs, and cadence is never an argument of this command.\n\n4. **Bash interpolation rule (global; applies to every Bash invocation in every stage).** Before interpolating any dynamic value \u2014 the epic key, a ticket key, a branch name, a checkpoint path, a prompt-file path, a JSON blob, a journal line \u2014 replace every `'` in the value with `'\\''`, then wrap the complete value in single quotes. Never expand a dynamic value unquoted, and never build a command by concatenating an unquoted variable. Credentials must never appear in a command argument, in printed output, in a journal line, or in a prompt file: the CLI and the MCP tools resolve their own credentials from the environment and the user-scoped credential store.\n\n5. **Packaged CLI launcher (`BAPI_MCP_CLI`); global, applies to every packaged-CLI invocation in every stage.** Resolve the launcher **once**, here in Stage 0, and reuse that one resolved value for the rest of the tick. Call it `<launcher>`.\n\n - Read the `BAPI_MCP_CLI` environment variable.\n - **Unset, empty, or whitespace-only** \u2014 `<launcher>` is exactly `npx -y @bridge_gpt/mcp-server`. This is the default, and the resulting shell command is byte-identical to what it was before this override existed.\n - **Otherwise** \u2014 `<launcher>` is that value, used verbatim as the command prefix. It names a local launcher, such as `node /absolute/path/to/mcp_server/build/index.js`. Use it for local pilots and pre-publish verification.\n\n When the override is set, apply item 4's single-quote escaping rule to `<launcher>` before interpolating it into a Bash command string, keep every dynamic argument independently quoted rather than concatenated into the launcher value, and never put a credential or a credential-bearing environment assignment into it. A stale local build is exactly as misleading as a stale npm publish: rebuild with `cd mcp_server && npm run build` before relying on the override.\n\nThis stage is **critical** \u2014 stop immediately on failure. Do not proceed to Stage 1.\n\n## Stage 1 \u2014 Initialize If No Checkpoint\n\nRun the first status probe through the **Bash tool**, forwarding `--checkpoint-path '<path>'` only when the user supplied it:\n\n```\n<launcher> conduct-epic status '<EPIC>' --json\n```\n\nA zero-exit response whose `checkpoint_exists` is `false` is the **only** condition under which initialization is allowed.\n\n- **`checkpoint_exists` is `false`** \u2014 this is the first tick. `--tickets` is required here, and **only** here: if it was not supplied, halt with the Stage 0 usage message and initialize nothing. On every later tick `--tickets` is optional and ignored, because the ordered list already lives in the checkpoint. Otherwise run, forwarding `--base-branch '<b>'` and `--checkpoint-path '<p>'` only when supplied:\n\n ```\n <launcher> conduct-epic init '<EPIC>' --tickets '<K1,K2,\u2026>'\n ```\n\n Print the initialization preflight output **verbatim** \u2014 do not summarize it, do not suppress its announcements, and do not reorder it. `init` runs one preflight that lists every failure at once, and that listing is the operator's only diagnostic when it refuses.\n\n On a **non-zero** exit, `init_failed` is a **print-only park**: emit `NEEDS_HUMAN:init_failed` with the complete secret-free output as evidence, print exactly one bounded, secret-free stdout journal line describing this invocation, and stop the tick. Do **not** call `checkpoint set` and do not otherwise mutate durable state. There is nothing to write to: when initialization failed, no writable checkpoint may exist at all, and any checkpoint that does exist may be the unreadable one that caused the failure. Do not attempt a second initialization in the same tick and do not fall through to Stage 2.\n\n- **`checkpoint_exists` is `true`** \u2014 an epic that already has a checkpoint must **never** trigger `init`. The CLI deliberately refuses reinitialization (`already initialized`), so a retry is not a recovery path; it is a bug in the caller. Skip straight to Stage 2.\n\n- **The status command exits non-zero** (a corrupt or wrong-version checkpoint, for example) \u2014 treat it exactly like a failed init, including the print-only rule: preserve the secret-free stderr as evidence, emit `NEEDS_HUMAN:init_failed`, print one journal line, call no `checkpoint set`, and stop the tick. `status` never rewrites a checkpoint it could not read, so nothing has been damaged.\n\n## Stage 2 \u2014 Reconcile From Status JSON\n\nRun the status probe **again**, with the same conditional `--checkpoint-path '<path>'` forwarding:\n\n```\n<launcher> conduct-epic status '<EPIC>' --json\n```\n\nThis second response is the action snapshot. **This JSON object is the only evidence the tick acts on.** Worker claims are never trusted \u2014 a session that says \"CI passed\", \"review approved\", or \"PR merged\" has told you nothing this tick may use. Every one of those facts is re-derived here from GitHub and the server through `status`, and only from there.\n\nThe top-level contract is exactly: `ok`, `epic_key`, `epic_branch`, `checkpoint_path`, `checkpoint_exists`, `all_done`, `ticket`, `worktree_path`, `worktree_exists`, `branch_head`, `worker_commits_since_spawn`, `last_seen_head`, `last_state_change_at`, `stale_for_seconds`, `pr`, `merged_externally`, `ci`, `review`, `parse`, `deadlines`, `scope`, `lock`, `needs_human`, and `probe_errors`.\n\nThe nested objects the detection table reads are:\n\n- `ticket` \u2014 the in-flight ticket (the first entry that is not `done`, or `null` when `all_done`): `key`, `status` (`pending`, `in_progress`, `merged`, `done`, `needs_human`), `branch`, `pr_number`, `spawned_at`, `parse_requested_at`, `parse_requested_for_sha`, `review_verdictless_observations`, `review_verdictless_for_sha`, `respawns`, `conflict_attempts`, `counters.sessions_spawned`, `counters.plan_generations_observed`, `counters.merge_attempts`, and `journal`.\n - `review_verdictless_observations` is a **non-negative integer** and `review_verdictless_for_sha` is **a string or `null`**. They are Row 10's head-bound verdictless counter: the count is only meaningful for the head named beside it, and a count whose head does not equal `pr.head_sha` is spent evidence about code that no longer exists. Read them together or not at all.\n - `parse_requested_at` and `parse_requested_for_sha` are **each a string or `null`**. They are RETAINED for the audit trail of epics driven before the scope-status contract existed, and **no row reads them any more**: Row 5 asks the `scope` sub-object directly instead of reconstructing causality from a request timestamp. Do not write them and do not decide on them.\n - `journal` is the ticket's journal lines, **oldest-first, newest last**, exactly as stored. It is a human-readable audit trail and is **never** the source of a decision: it is capped at 50 lines and evicts oldest-first, so a marker searched for in it would silently vanish after roughly fifty wait ticks and the loop would re-request a parse it had already requested.\n- `pr` \u2014 `number`, `state` (`OPEN`, `MERGED`, `CLOSED`), `head_sha`, `base`, `mergeable`, `merge_state`, `updated_at`.\n- `ci` \u2014 `required`, `complete`, `stable_across_two_polls`, `head_sha`, and `checks` entries of `name`, `status`, `conclusion`, `required`.\n- `review` \u2014 `opted_in`, `source`, `available`, `verdict` (`approved`, `changes_requested`, `unknown`), `head_sha`, `verdictless_disposition`, `verdictless_ceiling`, `config_invalid`.\n - `verdictless_disposition` is `park`, `fail_open`, or `null`. **`null` means `park`** \u2014 it is what a condition that configured no disposition reports, and what an unreadable configuration reports. A value is only ever one of those three; the server-side parser refuses every other spelling outright rather than passing a partially honored one through.\n - `verdictless_ceiling` is the number of head-bound verdictless observations Row 10 makes before it decides. Read it from this snapshot and compare against it; never hard-code a bound.\n - `config_invalid` is `true` when the repository's `done_gate_config` exists but could not be read \u2014 a `malformed` or `invalid: \u2026` parse. It arrives with `opted_in: true` and `available: false`, because an unreadable review policy is **not** an absent one: reading it as \"no review opt-in\" would merge on CI alone on the strength of a typo. There is no readable condition in that state, so `verdictless_disposition` is `null` and Row 10 parks.\n- `parse` \u2014 `status` (`idle`, `queued`, `in_progress`, `succeeded`, `failed`), `terminal`, `started_at`, and `finished_at`. The last two are each **a string or `null`** and are the ISO-8601 times of the current or last parse run. A `null` on either is unavailable evidence and **never** permits advancement \u2014 in particular, missing timestamps can never satisfy Row 5's causal check. There is no repository-wide index-branch override field: BAPI-847 retired that control plane, and an epic now gets its own index scope instead of taking the repository's index away.\n- `deadlines` \u2014 `soft_seconds`, `hard_seconds`, `elapsed_since_spawn_seconds` (defaults 3600 and 10800).\n- `scope` \u2014 the epic's index scope, read directly from the server: `scope_id`, `lifecycle_state`, `freshness_status`, `blocked_reason`, `required_commit_sha`, `indexed_commit_sha`, and `last_error`. It is `null` **only** when this epic declares no scope at all; that is not a probe failure and carries no `probe_errors` entry.\n - `freshness_status` is one of `fresh`, `pending`, `blocked`, `failed`, `unavailable`. **`fresh` is the only value that means the index covers this epic's merged code.** `pending` is a refresh still running. `blocked` is an epic advance the server REFUSED to index and will never resolve by waiting \u2014 `blocked_reason` names which refusal. `failed` is the scope's own generation failing. `unavailable` means the scope could not be read this tick, and is reported alongside a `{probe: \"scope\"}` entry in `probe_errors`.\n - `required_commit_sha` is the commit the scope must cover; `indexed_commit_sha` is the commit it actually has. **They are separate fields because they mean different things** \u2014 the required SHA moves the moment a merge is accepted, long before anything is indexed, so a required SHA equal to your merge commit is not evidence that your merge was indexed.\n- `lock` \u2014 `held_by_me`, `owner_pid`, `host`, `alive`.\n- `needs_human` \u2014 `null`, or `reason`, `evidence`, `at`.\n- `probe_errors` \u2014 entries of `probe` and `reason`.\n\nA failed probe leaves its sub-object `null` and is listed in `probe_errors`; it never fails the command. **A `null` sub-object is unavailable evidence, not a negative result.** Never infer a merge, an approval, a CI success, or a parse success from a `null` value, from a missing field, or from narrative output of any kind \u2014 an unavailable probe means \"wait for the next tick\", never \"proceed\".\n\n**`pr` is the one sub-object whose `null` has two distinct meanings, and `probe_errors` is what tells them apart:**\n\n- **`pr` is `null` and there is no `{probe: \"pr\"}` entry** \u2014 confirmed absence. `gh` was asked and answered that this branch has no pull request. This is the **normal** state of every tick between the first spawn and the moment the worker opens its pull request, it is a negative result the rows may act on, and Rows 6 and 7 exist precisely for it.\n- **`pr` is `null` and there IS a `{probe: \"pr\"}` entry** \u2014 unavailable evidence. `gh` could not answer: unauthenticated, rate-limited, offline, or output that did not parse. Treat it as \"wait for the next tick\" and never as absence; a pull request that exists but cannot be seen must not be reasoned about as one that does not exist.\n\nDo not collapse these two into \"no PR\". Reading an outage as absence is how the loop would respawn into, or abandon, a pull request that was there all along.\n\nTwo states stop the tick before any action is selected:\n\n- **Already parked.** If `needs_human` is not `null`, print the stable phrase `already parked`, followed by the persisted `reason`, the persisted string `evidence`, and the persisted `at` timestamp \u2014 then stop. Take no action this tick and write no checkpoint. A parked epic is a human's to unpark by editing the checkpoint (`needs_human` back to `null`, the ticket `status` back to `pending`/`in_progress`, counters adjusted if a budget is re-granted). Do not select a new recovery action on top of an existing one.\n- **Foreign lock.** If `lock.held_by_me` is `false` and `lock.alive` is `true`, another live process owns this epic. `foreign_lock` is a **print-only park**: emit `NEEDS_HUMAN:foreign_lock` carrying `lock.owner_pid` and `lock.host` as evidence, print one bounded, secret-free stdout journal line for this invocation, and stop. Do **not** call `checkpoint set`, spawn a session, merge a pull request, or start a parse while that lock is alive. The checkpoint belongs to the other live process; writing to it \u2014 even to record a park \u2014 is the two-authorities corruption the lock exists to prevent, and `checkpoint set` refuses a live foreign lock anyway.\n\n## Stage 3 \u2014 Detect and Take Exactly One Action\n\nEvaluate the rows below **strictly in written order, from top to bottom**. Evaluation stops at the first row whose condition matches; that row's action is the only action this tick performs, and control then proceeds directly to Stage 4. A later row is never \"also\" run because it happens to apply.\n\nOne row states a **forward-looking guard** in its own condition: Row 3 (`stalled`) matches only when no later action or fail-closed row would be selectable for this snapshot. That guard is part of Row 3's condition, not a departure from written order \u2014 the ordering rule still holds, and Row 3 simply does not match while a real action is available.\n\nEach row is marked **fail-open** (an uncertain or transient condition waits for the next tick) or **fail-closed** (the tick refuses to act and parks rather than guessing).\n\n### Row 1 \u2014 `all_done`: finish the epic and open its pull request\n\nWhen `all_done` is `true`, run `<launcher> conduct-epic finish '<EPIC>'` (forwarding `--checkpoint-path '<p>'` when supplied), then call the `create_pull_request` MCP tool with `head_branch` set to `epic/<EPIC>` and `base_branch` set to `main`. Assemble the `body` from the finish summary: the merged ticket pull requests and any skipped tickets. **Open the pull request; never merge it** \u2014 a human reviews and merges the epic into `main`. Then stop.\n\n**This tick writes no checkpoint and does not increment `counters.iterations`.** It is the third documented exemption from Stage 4's one-checkpoint-per-tick rule, and unlike the two print-only parks it reaches Stage 3. The reason is mechanical: `all_done` is `true` exactly when `ticket` is `null`, `checkpoint set` requires `--ticket <KEY>`, and there is no in-flight ticket to name. `finish` is this tick's durable act, and it is the last one the epic needs \u2014 so do not invent a ticket key to satisfy the rule, and do not write a checkpoint before or after `finish`.\n\n### Row 2 \u2014 Wrong base: do not touch a pull request that is not on the epic branch\n\nWhen `pr.base` is present and is not `epic/<EPIC>`, **do not touch the pull request** \u2014 no merge, no comment, no respawn. Select `NEEDS_HUMAN:wrong_base`, carrying the observed `pr.base`, `pr.number`, and the expected `epic/<EPIC>`. **Fail-closed**: only pull requests based on `epic/<EPIC>` are ever acted upon, and this row is evaluated before every work and recovery row precisely so a mis-based pull request cannot be merged, respawned into, or advanced by a later row.\n\n### Row 3 \u2014 Hard liveness: a stalled epic parks before it waits\n\nWhen `stale_for_seconds >= deadlines.hard_seconds` (default three hours, `10800`) **and no other row below is selectable this tick**, select `NEEDS_HUMAN:stalled`, carrying the observed `stale_for_seconds` and the `deadlines.hard_seconds` it exceeded. **Fail-closed**.\n\n**This row outranks wait rows only.** Before selecting it, check whether any of the following would otherwise be selectable for this snapshot; if any one of them would, take that row instead and do not park:\n\n- pending work (Row 4's first spawn),\n- Row 5's **action** branches only \u2014 branch 1's parse request, branch 3's completion, and branch 5's causal `parse_failed` park,\n- a targeted respawn (Rows 7, 9, and 11),\n- CI-red handling (Row 9) and review-remediation handling (Row 11),\n- conflict handling (Row 12),\n- ready-to-merge handling (Row 13),\n- a closed, unmerged pull request (Row 13a),\n- Row 10's **action** branch only \u2014 a verdictless review at or above `review.verdictless_ceiling`, whichever disposition it then applies. Row 10's below-ceiling branch is a wait and stays subordinate to this row, exactly as the old unbounded wait did.\n\n`stale_for_seconds` counts from the last observed head or status change, not from the last useful event \u2014 so an old but green and approved pull request accumulates staleness while being perfectly actionable. Parking that is the exact defect this guard removes. The row remains ahead of every wait row, because without it a wait would match forever and the epic would sit silent instead of asking for a human.\n\n**Row 5's wait branches are deliberately NOT in that list.** Branches 2, 4, and 6 \u2014 a parse that is queued or in progress, a non-causal `succeeded` or `failed`, an inconsistent request record \u2014 are waits, and exempting them would mean a merged ticket whose parse never starts waits forever with no human ever asked. They accumulate staleness like any other wait and park as `stalled` once `deadlines.hard_seconds` is exceeded.\n\n### Row 4 \u2014 Pending ticket: spawn the first worker\n\nWhen `ticket.status` is `pending`, spawn the ticket's session:\n\n```\n/review-and-start --auto --base-branch 'epic/<EPIC>' <KEY>\n```\n\nThen prepare the Stage 4 checkpoint values `spawned_at` (now, ISO-8601), `status=in_progress`, and `counters.sessions_spawned` = the Stage 2 value plus one.\n\n**Fail-closed**: refuse this spawn if the lock is foreign (Stage 2 has already parked in that case). The pull-request base of the spawned worker comes from BAPI-801's `BAPI_BASE_BRANCH` export \u2014 `/review-and-start --base-branch` forwards it into the spawned worker shell, and the worker's create-PR step resolves the base from it. That export is what makes the first pull request land on `epic/<EPIC>`; this loop never relies on it alone, because Row 2 independently re-checks the observed `pr.base` on every later tick.\n\n### Row 5 \u2014 Merged ticket: refresh the scope index, then mark done\n\nWhen `pr.state` is `MERGED`, or `merged_externally` is `true`, or `ticket.status` is `merged`, the ticket's code is on the epic branch. An **external merge is successful reconciliation, not an error** \u2014 a human who merged the pull request by hand did the loop's work for it, and `merged_externally` records exactly that.\n\n**The evidence this row acts on is `scope`, and only `scope`.** The epic's index scope is refreshed by the server the moment it observes the merge: it advances its own `required_commit_sha` to the merge commit and re-parses incrementally. So the question \"has this merge been indexed?\" is a question the scope can answer directly, and this row asks it instead of reconstructing an answer.\n\nThat is a deliberate replacement of the older mechanism. This row used to record the time it called `parse_repository` and the head SHA it called it for, then compare that timestamp against a repository-wide parse run's `started_at` / `finished_at` \u2014 because `parse.status` is repository-level and stays `succeeded` from any earlier parse of any earlier ticket, so \"succeeded\" alone proved nothing. Timestamp ordering was the only causality available. It is no longer needed, and inference is strictly worse than an answer: **do not call `parse_repository` from this row, and do not read `parse`, `ticket.parse_requested_at`, or `ticket.parse_requested_for_sha` as freshness evidence.** The server owns the refresh; this loop observes it.\n\nThis row is an **ordered state machine**, evaluated top to bottom, and the first matching branch is the tick's action:\n\n1. **`scope` is `null`** \u2014 this epic declares no index scope, so there is nothing to refresh and no freshness to establish. Call `update_jira_status` for the ticket with `target_status` set to the Jira `Done` state, and prepare `status=done`. Journal that the ticket completed with no declared scope. **Fail-open.** An epic that never had a scope must not be blocked by one.\n\n2. **`scope.freshness_status` is `fresh`, and `scope.indexed_commit_sha` equals `scope.required_commit_sha`, both non-null** \u2014 the scope's index provably covers the commit the server is holding it to. Only then call `update_jira_status` for the ticket with `target_status` set to the Jira `Done` state, and prepare `status=done`. Journal both observed watermarks.\n\n **Compare the scope's two watermarks against each other \u2014 never against `pr.head_sha` or `branch_head`.** Both of those are the *worker's* pre-merge branch tip: `pr.head_sha` is `headRefOid`, and `branch_head` is `git ls-remote` of the ticket's own branch. What lands on `epic/<EPIC>` is the merge commit GitHub creates, and that differs from the worker's tip under every merge strategy \u2014 merge, squash, and rebase alike. Comparing an indexed watermark against either one is therefore false essentially always, and a branch that waits on an always-false condition never marks anything done. For the same reason, do not invent a merge-commit field: the `scope` object carries exactly the seven fields named above, and none of them is one.\n\n The identity that IS causal runs between the scope's own two watermarks, and it is what replaces the old timestamp ordering. The server advances `required_commit_sha` the moment it observes this merge, and **only the parse** writes `indexed_commit_sha`; the two fields are owned by different writers precisely so their agreement means something. So `indexed == required` is the server's own statement that it has finished indexing everything it was asked to cover. A scope that finished refreshing for a **previous** ticket reads `fresh` too \u2014 but it reads it at that previous required commit, and the moment this merge is observed `required` moves ahead of `indexed` and `freshness_status` drops to `pending` until the re-parse lands. If either watermark is `null` the comparison cannot be made, so this branch does not match and the tick falls to branch 6 and waits.\n\n **The one gap this cannot see through** is the interval between the merge and the server observing it: in that window the scope still reads `fresh` at the previous ticket's watermark, and no field in the contract tells it apart from this ticket's. It is narrow in practice \u2014 the same merge event that makes `pr.state` read `MERGED` is the one that notifies the server, so a tick that reaches this row has almost always been preceded by that notification \u2014 and it closes on its own. It is not zero: a merge the server never observed at all would leave the watermarks agreeing at the previous commit, and this branch would mark the ticket done against an index that does not contain it. Treat a `done` whose journaled watermarks match the *previous* ticket's as that failure, not as a fresh index.\n\n3. **`scope.freshness_status` is `pending`, `unavailable`, or missing** \u2014 the refresh is still in flight, or the scope could not be read. Wait. Journal the observed `scope.lifecycle_state`, `scope.required_commit_sha`, and `scope.indexed_commit_sha`. Do not spawn anything and do not advance the next ticket. **An unread scope is never a fresh one.**\n\n4. **`scope.freshness_status` is `blocked`** \u2014 the server REFUSED to index this advance, and waiting will never change that. Select `NEEDS_HUMAN:shadow_stale_deadline`, with `scope.blocked_reason` as bounded string evidence, and state plainly in the evidence that **no epic advance was indexed**. **Fail-closed.**\n\n The controlled reasons and what each one means to a human:\n\n - `advance_blocked_base_merge` \u2014 the base branch was merged forward into the epic branch. The epic branch is pinned at its cut point; a base merge would move that pin.\n - `advance_blocked_unexpected_parent` \u2014 the merge commit does not descend directly from the branch head the scope pinned. Something other than a worker pull request landed on the branch.\n - `advance_blocked_history_changed` \u2014 the pinned head is gone from the branch's history. A force-push or rewrite.\n - `advance_blocked_unverifiable` \u2014 the advance could not be verified at all. Doubt blocks; it never indexes.\n\n **This park is immediate, and that is deliberate** \u2014 it is the one place the pilot escalates faster than v2. The v2 reconciler routes a blocked advance through the same `shadow.stale_deadline_seconds` clock it uses for an ordinary refresh hold, because its hold is anchored on a single durable episode timestamp that every hold reason shares. The pilot has no such episode and no typed `RunPolicy` deadline, and none of the four reasons above resolves by waiting, so waiting out a deadline would only delay a human by up to that deadline and change nothing else. Both conductors emit the **same** `shadow_stale_deadline` reason so one grep finds a refused advance either way; only the latency to the park differs. An operator comparing the two should expect the pilot to ask sooner, not to have asked for a different thing.\n\n5. **`scope.freshness_status` is `failed`** \u2014 the scope's own generation failed, which is a different problem from a refused advance. Select `NEEDS_HUMAN:parse_failed`, with `scope.lifecycle_state` and `scope.last_error` as bounded string evidence. **Fail-closed.**\n\n6. **None of branches 1\u20135 matched** \u2014 including a `fresh` scope whose indexed commit still trails its required commit, and a tick where either watermark is missing so no comparison can be made. Wait, and journal the observed scope fields. Neither advance nor park: hard liveness (Row 3) is what eventually escalates a wait that never resolves.\n\n**No next ticket is spawned until this one reaches `done`.** A merged ticket stays in flight until its scope is fresh for its own merge commit, so `ticket` still points at it and Row 4 cannot match for its successor \u2014 which is the whole point: the next ticket's review and plan must see this ticket's merged code.\n\n### Row 6 \u2014 Worktree working: wait\n\nWhen a worktree exists (`worktree_exists` is `true`), the pull request is **confirmed absent** (`pr` is `null` **and** `probe_errors` carries no `{probe: \"pr\"}` entry), and `worker_commits_since_spawn > 0`, the worker is making observable progress. Wait, and journal the observed `branch_head` and commit count. **Fail-open.**\n\nA `pr: null` accompanied by a PR probe error is unavailable evidence, not absence, and does not match this row \u2014 it falls through to Row 15 and waits.\n\n### Row 7 \u2014 Soft deadline with no progress: one targeted continuation\n\nWhen the pull request is **confirmed absent** (`pr` is `null` **and** no `{probe: \"pr\"}` entry), `worker_commits_since_spawn` is `0`, and `deadlines.elapsed_since_spawn_seconds >= deadlines.soft_seconds` (default one hour, `3600`), spend the single targeted respawn on kind `continue`, with the prompt:\n\n```\nBranch <b> for <KEY>: continue the existing plan; do not regenerate it; push when done\n```\n\nPrepare `respawns` = the Stage 2 value plus one. `respawns` is **one shared per-ticket budget**, not one allowance per row: Rows 7, 9, and 11 all spend the same single counter, so spending it here leaves nothing for a later CI fix or review fix on this ticket. The attempt **counts only if it pushed** \u2014 a later tick observing a non-null `branch_head` is the proof. A respawn that produces no push is a no-op, and a no-op respawn stops the loop rather than spinning: once the one targeted respawn is spent and the ticket still shows no pushed head, select `NEEDS_HUMAN:stalled`. **Fail-closed after one attempt**, which is what keeps a dead worker from being respawned without bound.\n\n### Row 8 \u2014 Pull request open, CI not settled: wait\n\nWhen a pull request is open and `ci.complete` is `false` **and no required check in `ci.checks` has already reached a terminal unsuccessful conclusion**, wait; or when `ci.complete` is `true` and green but `ci.stable_across_two_polls` is `false`, wait. **Fail-open.**\n\nThe boolean alone is not the condition. `ci.complete` is `false` both while checks are still running and once a required check has definitively failed, and those are opposite situations: the first is worth waiting on and the second never becomes green on its own. This row therefore covers pending and not-yet-stable checks **only** \u2014 a required check with a terminal unsuccessful conclusion is **not** consumed here and falls through to Row 9.\n\n### Row 9 \u2014 Pull request open, CI red: one targeted fix\n\nWhen a pull request is open, one or more required checks in `ci.checks` have a terminal unsuccessful conclusion, and there has been no new commit for over 60 minutes (`stale_for_seconds > 3600` is the authoritative no-new-commit duration), spend the single targeted respawn on kind `ci_fix`. Take the failing check names from `ci.checks` \u2014 the entries whose `required` is `true` \u2014 and use the prompt:\n\n```\nPR #N is red on <checks>: read the check annotations, fix, push; do not regenerate the plan\n```\n\nPrepare `respawns` = the Stage 2 value plus one; the attempt counts only if it pushed. A bare `/implement-ticket --auto` is **prohibited** here: it regenerates the plan, costs a full plan generation, and discards the failure detail the annotations already carry.\n\n`respawns` is **one shared per-ticket budget** across Rows 7, 9, and 11. A continuation respawn spent earlier on this ticket therefore leaves **no** CI-fix attempt: with the counter already at its limit, persistent red CI parks immediately as `NEEDS_HUMAN:ci_red` rather than getting a fix session of its own. Once the shared respawn is spent and CI is still red, select `NEEDS_HUMAN:ci_red` with the failing check names as bounded string evidence. **Fail-closed after one attempt.**\n\n### Row 10 \u2014 Review opted in and verdictless: count, then decide\n\nWhen `pr.state` is `OPEN`, `review.opted_in` is `true`, and the review is **verdictless for the current head** \u2014 that is, `review.available` is `false`, **or** `review.verdict` is neither `approved` nor `changes_requested` at `pr.head_sha` \u2014 the review has produced no usable answer for this code. Count the observation, then act on the count.\n\nThis row covers **both** verdictless shapes on purpose. `review.available` is `false` only when the review read itself failed. A reviewer that ran and died before publishing anything is a different shape: the read succeeds, `review.available` is `true`, and `review.verdict` is `unknown`. Both mean the same thing to this loop \u2014 no verdict exists for `pr.head_sha` \u2014 and a row that covered only the first would leave the second matching nothing at all.\n\n`changes_requested` at the current head is **explicitly excluded**, so Row 11 stays reachable: a reviewer that asked for changes produced a verdict, and that verdict is Row 11's business. A `changes_requested` verdict whose `review.head_sha` does not equal `pr.head_sha` is about code that no longer exists, so it is verdictless for the current head and does match here.\n\nThe `pr.state` is `OPEN` guard is load-bearing: without it a `CLOSED` pull request whose review is verdictless matches here, ahead of Row 13a, and the loop counts tick after tick on abandoned work instead of parking it.\n\n**Prepare the counter, bound to the current head.**\n\n- If `ticket.review_verdictless_for_sha` does **not** equal `pr.head_sha`, prepare `review_verdictless_observations` = `1` and `review_verdictless_for_sha` = `pr.head_sha`. **The counter resets on a new head.** Observations made against an abandoned head must never spend the budget belonging to the head that replaced it \u2014 a later push replaces the code the reviewer failed on, and the new code deserves its own full budget.\n- Otherwise prepare `review_verdictless_observations` = the Stage 2 value plus one, absolute, and leave `review_verdictless_for_sha` at `pr.head_sha`.\n\nWrite both prepared fields through the ordinary single `checkpoint set` for this tick, in every direction below \u2014 waiting, parking, and the waived merge alike.\n\n**Compare the prepared count with `review.verdictless_ceiling`**, which the Stage 2 snapshot carries. Compare two numbers read from the snapshot; never compare against a bound written into this prose.\n\n- **Below the ceiling** \u2014 wait one tick and journal the observation, naming the prepared count, the ceiling, and the observed `review.available` / `review.verdict`. This is today's behaviour, unchanged. This branch is a **wait**, so Row 3's hard-liveness park still outranks it exactly as it does now.\n- **At or above the ceiling** \u2014 apply `review.verdictless_disposition`. This branch is an **action**, so it outranks Row 3, and the ceiling is what an operator actually sees instead of a three-hour `stalled` that names the wrong failure.\n\n**At or above the ceiling, the disposition decides:**\n\n- **`park`** \u2014 the default, and the value used whenever `review.verdictless_disposition` is `null`, including when `review.config_invalid` is `true` (a review policy that could not be read carries no readable disposition, so it gets the safe one). Select `NEEDS_HUMAN:review_verdictless_ceiling_reached`, carrying the observed count, the ceiling, `pr.head_sha`, and `review.available` / `review.verdict` as bounded string evidence.\n- **`fail_open`** \u2014 treat the ticket as **review-opted-out for this tick** and fall through to Row 13's merge conditions. Row 13 still requires everything else it always required: `pr.state` is `OPEN`, `pr.base` is `epic/<EPIC>`, `ci.complete` is `true`, `ci.stable_across_two_polls` is `true` at `pr.head_sha`, and a non-conflicting pull request. **CI, not a verdict, is the whole of the evidence in that case** \u2014 journal `review_waived_verdictless_fail_open` and say the merge proceeded on stable CI evidence alone. Never journal, print, or record it as a review that passed or approved anything.\n\nAny value other than exactly `fail_open` resolves to `park`. There is no third direction, and an unreadable disposition is never treated as permission.\n\n### Row 11 \u2014 Changes requested for the current head: one targeted review fix\n\nWhen `pr.state` is `OPEN`, `review.verdict` is `changes_requested`, **and** `review.head_sha` equals `pr.head_sha`, spend the single targeted respawn on kind `review_fix`. The `OPEN` guard is what stops a `changes_requested` verdict left on a **closed** pull request's head from spending this ticket's one respawn on work nobody will merge \u2014 that snapshot belongs to Row 13a. The prompt carries the authoritative Stage 2 review evidence: the ticket key, the pull-request number, the reviewed head SHA, and the requested changes. A stale `review.head_sha` (one that does not equal `pr.head_sha`) is a verdict about code that no longer exists and never triggers this row. Prepare `respawns` = the Stage 2 value plus one.\n\n`respawns` is **one shared per-ticket budget** across Rows 7, 9, and 11. Any earlier continuation or CI-fix respawn on this ticket therefore leaves **no** review-fix attempt: with the counter already at its limit, requested changes on the current head park immediately. Once the shared respawn is spent and the verdict still stands for the current head, select `NEEDS_HUMAN:review_changes_requested`. **Fail-closed after one attempt.**\n\n### Row 12 \u2014 Conflicting pull request: at most two conflict sessions\n\nWhen `pr.state` is `OPEN` **and** either `pr.mergeable` is `CONFLICTING` or `pr.merge_state` is `DIRTY`, spawn a session of kind `conflict` with the prompt:\n\n```\nrebase onto origin/epic/<EPIC>, resolve, run tests, push\n```\n\nPrepare `conflict_attempts` = the Stage 2 value plus one. The conflict budget is **two** sessions and is counted separately from the single targeted respawn of Rows 7, 9, and 11 \u2014 a rebase is a different failure mode from a stalled or red worker. After the second conflict session, if the pull request is still `CONFLICTING`/`DIRTY`, select `NEEDS_HUMAN:conflict`. **Fail-closed after two attempts.**\n\nA **closed** pull request is frequently left `CONFLICTING`/`DIRTY` by GitHub, so without the `pr.state` is `OPEN` guard this row would match ahead of Row 13a and spend a rebase session resolving conflicts on a branch nobody will merge.\n\n### Row 13 \u2014 Ready to merge\n\nMerge only when **all** of the following hold on the fresh Stage 2 snapshot: the pull request is open (`pr.state` is `OPEN`); `pr.base` is `epic/<EPIC>`; `ci.complete` is `true` and `ci.stable_across_two_polls` is `true` for `ci.head_sha` equal to `pr.head_sha`; the pull request is not conflicting; and review is either opted out (`review.opted_in` is `false`), approved (`review.verdict` is `approved`) with `review.head_sha` equal to `pr.head_sha`, **or** waived by a `fail_open` verdictless ceiling reached in Row 10 this tick.\n\nWhen the waiver path is what reached this row, journal the shared token `review_waived_verdictless_fail_open` alongside the merge line and say plainly that the merge proceeded **on stable CI evidence alone**. That token is the same string the v2 conductor records for the same degradation, so one grep finds every merge that advanced without a verdict whichever conductor drove the epic. Never write it in language that claims the review passed or approved the pull request \u2014 it names what was missing, not what was satisfied.\n\nThen call the `merge_pull_request` MCP tool with exactly `pr_number` set to `pr.number` and `expected_head_sha` set to `pr.head_sha`. **The expected SHA is derived only from the fresh Stage 2 status** \u2014 never from the checkpoint, never from a worker's report, never from an earlier tick. The checkpoint deliberately stores no expected head; merge identity always comes from a freshly observed `pr.head_sha`. Prepare `counters.merge_attempts` = the Stage 2 value plus one for **every** invocation of the tool, successful or not.\n\nMap the returned envelope:\n\n- **`merged` is `true`** \u2014 the only success. It covers `outcome: merged` and `outcome: already_merged`, both of which carry that boolean. Prepare `status=merged` and top-level `counters.merges` = the Stage 2 value plus one.\n- **`outcome: refused` with `reason: head_sha_drift`** \u2014 the head moved under the merge. Journal the complete envelope (including `actual_head_sha`) and take a fresh status snapshot on the next tick. Never retry with the stale SHA.\n- **Outcome `lease_held`, `review_not_approved`, or `unknown`, or any envelope carrying `retry_hint: retry_later`** \u2014 journal it and wait for the next reconciliation tick.\n- **Outcome `dry_run`, `pending_approval`, `gate_unresolved`, `action_key_mismatch`, `review_unavailable`, `review_source_unsupported`, `error`, or any `refused` result carrying `retry_hint: needs_human`** \u2014 select `NEEDS_HUMAN:merge_blocked`. Preserve the **complete** envelope as the evidence, including `hint`, `actual_head_sha`, `ci_summary`, `paths`, and `http_status` whenever those are present; `hint` is usually the exact operator fix. **JSON-stringify that envelope into a bounded, secret-free string** \u2014 `evidence` is string data, never an object (see Stage 4).\n\n**Fail-closed**: only `merged: true` is success. A missing, `false`, or malformed `merged` value is never treated as a merge, no matter what `outcome` says alongside it.\n\n### Row 13a \u2014 Pull request closed without being merged\n\nWhen `pr.state` is `CLOSED` and the pull request was not merged, the ticket's work has been abandoned on GitHub and nothing this loop does can advance it. Select `NEEDS_HUMAN:merge_blocked`, with bounded string evidence that identifies `pr.state: CLOSED` along with `pr.number`. **Fail-closed** \u2014 a closed pull request is never respawned into, reopened, or merged by this loop.\n\n### Row 14 \u2014 Local-mode ticket operation refused\n\nWhen a ticket operation returns `409 UNSUPPORTED_IN_LOCAL_MODE`, tolerate it and journal it. The repository is running the local ticket backend, where that response is the documented terminal answer rather than a failure. It introduces **no** new parking reason. **Fail-open.**\n\n### Row 15 \u2014 No row matched: journal the snapshot and do nothing else\n\nWhen no row above matches, that is the tick's outcome, not a licence to improvise. Journal a concise summary of the Stage 2 snapshot, take **no** external action \u2014 no MCP tool call, no spawn, no merge, no parse \u2014 and change **no** row-specific checkpoint field. The single `checkpoint set` this tick writes therefore carries only the universal `counters.iterations` update and its one journal line.\n\nThis row exists because unmatched snapshots are real and reachable: a `stale_for_seconds` or `elapsed_since_spawn_seconds` that is `null` because nothing has been observed yet; a pull request whose CI is complete but not yet stable across two polls. Each of those is a legitimate \"wait for reality to move\" state, and a tick that improvised an action for it would be acting on evidence it does not have. **Fail-open.**\n\nAn open pull request awaiting a review whose `verdict` is still `unknown` is **no longer** one of these. Row 10 now matches that snapshot, counts it, and eventually decides \u2014 falling through to here would be the unbounded wait the ceiling exists to end.\n\n### Shared mechanics for every targeted session\n\nRows 7, 9, 11, and 12 spawn a session the same way. The four kinds are exactly `continue`, `ci_fix`, `review_fix`, and `conflict`.\n\n**First, write the prompt file** with the Write tool, at:\n\n```\n~/.config/bridge/conduct/<repo>/<EPIC>/prompts/<KEY>-<kind>-<n>.md\n```\n\nwhere `<EPIC>` and `<KEY>` are the validated keys, `<kind>` is one of the four kinds above, and `<n>` is the applicable absolute attempt number. **`<repo>` is the repository component of the resolved `checkpoint_path` that Stage 2's `status` returned** \u2014 read it from there rather than re-deriving it from credentials, from `BAPI_REPO_NAME`, or from anything remembered in conversation. `status` resolves that path itself, including any `--checkpoint-path` override and any `XDG_CONFIG_HOME` redirection, so it is the only value guaranteed to match where the CLI actually keeps this epic's state.\n\n**End every prompt with this exact wording**, so the spawned worker releases its worktree cleanly instead of lingering:\n\n```\nAfter the final pipeline step completes, cleanly end your worker session (for example by issuing the `/exit` command) so the worktree is released and no idle process lingers \u2014 but **only when no follow-up remains that you still own**. Do **not** exit while any of the following is true:\n\n- there are unresolved CI failures you are still correcting (the post-PR CI-correction loop in the CI-monitoring step still owns work),\n- review changes were requested and you have not yet addressed them,\n- there is a merge conflict on your PR that you still own,\n- you have unpushed local commits.\n\nExit only after your final branch state is pushed, the done-gate / CI-monitoring workflow required by the recipe has completed, and no CI/review follow-up remains. A clean `SessionEnd` is both the correct terminal lifecycle signal and the point at which the worker should exit.\n```\n\n**Then spawn**, forwarding `--checkpoint-path '<p>'` only when supplied:\n\n```\n<launcher> conduct-epic spawn '<EPIC>' --ticket '<KEY>' --prompt-file '<path>'\n```\n\n`spawn` opens exactly **one** agent tab in the ticket's `worktree_path` running the prompt file's contents. It refuses when the worktree is missing, the prompt file is unreadable, or the lock is held by another live process.\n\n**The budgets are this command's job, not the CLI's.** `spawn` never checks them: it will happily open a fifth tab if asked. One targeted respawn **shared** across Rows 7, 9, and 11 \u2014 a single per-ticket `respawns` counter, not one allowance per row \u2014 and two conflict sessions in Row 12, are enforced here, by reading the Stage 2 `respawns` and `conflict_attempts` before choosing the row.\n\nAfter a **successful** spawn, prepare `counters.sessions_spawned` = the Stage 2 value plus one. If the spawn command itself fails, do **not** advance `respawns`, `conflict_attempts`, or `counters.sessions_spawned` \u2014 a session that never opened has consumed no budget.\n\nKeep credentials, raw environment values, and unrelated command output out of prompt files and out of the spawn command's arguments. The spawned agent resolves its own credentials.\n\n## Stage 4 \u2014 Checkpoint and Stop\n\nEvery tick that reaches this stage ends with **exactly one** checkpoint command and **exactly one** journal line. There are **three exemptions**, and they divide into two kinds:\n\n- **Two print-only parks, before Stage 3.** `init_failed` (Stage 1) and `foreign_lock` (Stage 2) stop the tick *before* Stage 3 and write nothing durable at all \u2014 they print their `NEEDS_HUMAN:` line and one stdout journal line and stop. Because they never reach Stage 3 they also never increment `counters.iterations`.\n- **The `all_done` tick, inside Stage 3.** Row 1 reaches Stage 3 but has **no in-flight ticket**: `all_done` is `true` exactly when `ticket` is `null`, and `checkpoint set` requires `--ticket <KEY>`. That tick runs `finish`, opens the epic pull request, writes **no** checkpoint, and \u2014 as the single stated exception to the rule below \u2014 does **not** increment `counters.iterations`.\n\nEvery other tick, including a Row 15 fallthrough, writes here. Run, forwarding `--checkpoint-path '<p>'` whenever the user supplied it:\n\n```\n<launcher> conduct-epic checkpoint set '<EPIC>' --ticket '<KEY>' --field <name> <absolute-value> \u2026 --journal '<line>'\n```\n\nRepeat `--field <name> <absolute-value>` once per changed field, and pass `--journal '<line>'` exactly once. Do not issue a second `checkpoint set` in the same tick, and do not split the fields across two invocations \u2014 one tick, one auditable write.\n\n**Every value is absolute, computed from the Stage 2 snapshot.** Relative or guessed increments are prohibited: the CLI stores what it is given, so a \"+1\" that was never resolved against a fresh read silently corrupts the count. Compute `n + 1` from the Stage 2 value for `counters.sessions_spawned`, `respawns`, `conflict_attempts`, `counters.merge_attempts`, `counters.iterations`, and `counters.merges`.\n\n`review_verdictless_observations` follows the same absolute rule with one addition: when `ticket.review_verdictless_for_sha` does not equal `pr.head_sha`, the absolute value is `1` rather than `n + 1`, because the counter is bound to a head and resets when the head moves. `review_verdictless_for_sha` is written as the observed `pr.head_sha`. Row 10 owns both fields; no other row writes them.\n\nInclude only the fields the selected row actually affected \u2014 typically some of `status`, `spawned_at`, `respawns`, `conflict_attempts`, `review_verdictless_observations`, `review_verdictless_for_sha`, `counters.sessions_spawned`, `counters.merge_attempts`, `counters.iterations`, and `counters.merges`.\n\n**`parse_requested_at` and `parse_requested_for_sha` are no longer written by any row.** The CLI still accepts them so an older checkpoint stays readable, but Row 5 now reads the `scope` sub-object \u2014 the server's own answer about whether this merge was indexed \u2014 rather than recording a request and timing it. Writing them would record evidence nothing reads.\n\n**`counters.iterations` increments exactly once for every tick that reaches Stage 3**, and it is written in that tick's single `checkpoint set` as the Stage 2 absolute value plus one. It is the one field every such tick updates, including a Row 15 fallthrough \u2014 which is why a fallthrough tick's checkpoint contains only `counters.iterations` and its journal line, with no status, retry, merge, or parking mutation. The two print-only parks never reach Stage 3 and so never increment it, and the `all_done` tick reaches Stage 3 but writes no checkpoint, so it does not increment it either.\n\n**Parking** adds two fields to the same single command:\n\n```\n--field status needs_human --field needs_human '{\"reason\":\"<reason>\",\"evidence\":\"<bounded secret-free JSON-stringified envelope or output>\",\"at\":\"<ISO-8601 timestamp>\"}'\n```\n\n**`evidence` is a JSON string, never an object.** The CLI's checkpoint schema accepts only `{reason: string, evidence: string, at: string}` and rejects anything else outright, so an object-valued `evidence` makes `checkpoint set` exit non-zero: the `NEEDS_HUMAN:` line prints, the park never persists, and the next tick repeats the failing action. When the evidence is structured \u2014 a merge envelope, a command's output \u2014 JSON-stringify it and escape every embedded quote and control character so the result is a single valid JSON string value. Keep it bounded and secret-free.\n\nThe `reason` is one of the closed list below and `at` is an ISO-8601 timestamp. Every `NEEDS_HUMAN:<reason>` line printed by a stage carries the **same** evidence that is persisted here \u2014 the printed line and the checkpoint never disagree.\n\nThe parking vocabulary is closed \u2014 **eleven reasons** and no others \u2014 and it has two partitions:\n\n- **Nine persisted reasons**, each written durably by the single `checkpoint set` above: `stalled`, `ci_red`, `review_changes_requested`, `merge_blocked`, `conflict`, `parse_failed`, `shadow_stale_deadline`, `wrong_base`, and `review_verdictless_ceiling_reached`. A persisted park is what makes the *next* tick report `already parked` and stop.\n - `shadow_stale_deadline` is Row 5 branch 4's reason, and it is deliberately **the same token the v2 conductor parks under** for the same condition. Both conductors reaching for one string is what lets an operator grep for a refused epic advance without first working out which conductor drove the epic. It is distinct from `parse_failed`: `parse_failed` means the index generation broke, while `shadow_stale_deadline` means the index refused to accept the branch advance at all.\n - `review_verdictless_ceiling_reached` is Row 10's park, and it is **byte-identical to v2's own token** for the same reason `shadow_stale_deadline` is shared: one grep finds a verdictless ceiling whichever conductor drove the epic. Four alternatives were considered and rejected. `stalled` is the label this row exists to stop emitting \u2014 it says the worker died when what actually died was the reviewer. `merge_blocked` is wrong because the merge tool was never called, and its evidence table is built entirely around merge envelopes. `review_changes_requested` is factually false: nobody requested changes, nobody said anything. And a fresh `review_unavailable` token would collide with the merge tool's existing `review_unavailable` *outcome*, which Row 13 already maps to `merge_blocked` \u2014 two different conditions answering to one string is exactly the confusion a closed vocabulary exists to prevent.\n- **Two print-only reasons**, which are printed and journaled to stdout for the current invocation only and write nothing durable: `init_failed` and `foreign_lock`. Neither may call `checkpoint set`. A print-only park leaves no durable record, so it does not produce an `already parked` tick \u2014 the next tick reconciles from scratch and reports the condition again if it persists.\n\nDo not invent a new reason; a genuinely new failure mode is a change to this command and to the BAPI-805 runbook together.\n\nThe journal line is one line containing the ISO-8601 time, the selected action, and concise evidence. Print it **last**, after the checkpoint command has succeeded, so the operator's final line of output is the tick's durable record.\n\nEvery dynamic value in this stage follows the Stage 0 single-quote rule \u2014 the epic key, the ticket key, the checkpoint path, the `needs_human` JSON, and the journal line are each escaped (`'` \u2192 `'\\''`) and wrapped in single quotes. Credentials never appear in a checkpoint argument or in journal evidence.\n\n## Operational Guarantees\n\n- **Spec freshness is `/review-and-start`'s job, not a separate check.** Each ticket's review phase runs in a worktree cut from the current `epic/<EPIC>` tip, so its review and its plan already see every predecessor's merged code. This command runs no separate spec-freshness check and needs none.\n- **The checkpoint plus GitHub are the resume point.** Nothing relies on conversation memory. A sleeping laptop merely misses ticks; the next invocation reconciles from scratch and continues where reality actually is.\n- **This command never creates an `epic_run`.** It must never be combined with `setup-epic` on the same epic \u2014 the v2 conductor stays active there, and two authorities transitioning one epic is exactly the failure this pivot removes.\n- **`/loop 5m /conduct-epic <EPIC>` is the driver.** The operator runbook is BAPI-805's, not this file's.\n- **Recovery is bounded**: one targeted respawn *shared* across Rows 7, 9, and 11, and two conflict sessions, then park. There is no third chance and no escalating retry.\n- **The first spawn relies on BAPI-801's `BAPI_BASE_BRANCH` contract**, while every tick still independently verifies the observed `pr.base` (Row 2). The export makes the right thing happen; the check catches it when it does not.\n","council.md":'Convene a multi-perspective council on a task via Bridge API and save the resulting report locally.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\nExecute all steps in this command as a simple linear sequence of MCP tool calls.\n\n## Step 1 \u2014 Parse Arguments\n\nParse `$ARGUMENTS`. The supported invocation is exactly:\n\n```text\n/council <task description> [--mode technical|design|discovery|general] [--debate] [--lenses a,b] [--ticket PROJ-123]\n```\n\nParsing rules:\n\n- Keep every non-flag token in its original order; the joined result is the required `task_description`. Remove each recognized flag, and the value token that belongs to it, from that text.\n- `--mode <value>` accepts exactly `technical`, `design`, `discovery`, or `general`. When `--mode` is omitted, the selected mode is `technical`.\n- `--debate` is a valueless boolean flag. It takes no following token.\n- `--lenses <a,b>` takes one comma-separated value. Split it on commas and keep the non-empty entries as the `lenses` array.\n- `--ticket <KEY>` captures the immediately following token as the ticket key.\n- A missing value for `--mode`, `--lenses`, or `--ticket` \u2014 including a value position occupied by another recognized flag \u2014 is a validation failure. Never let the next flag become a flag\'s value.\n\nValidation must finish before any MCP tool call. Stop immediately, display the usage response below, and make no tool call when `$ARGUMENTS` is empty, when it contains only flags, when a flag that needs a value has none, or when `--mode` is given an unsupported value:\n\n```text\nUsage: /council <task description> [--mode technical|design|discovery|general] [--debate] [--lenses a,b] [--ticket PROJ-123]\nExample: /council "How should we add rate limiting to the LLM client?" --mode technical\n```\n\n## Step 2 \u2014 Resolve Docs Directory\n\nCall `get_docs_dir` (no parameters). Store the returned path as `docs_dir`. This is context only \u2014 do not slugify it, predict a filename from it, or otherwise construct a report path yourself.\n\n## Step 3 \u2014 Convene the Council\n\nBefore calling the tool, tell the user calmly what to expect:\n\n```text\nConvening the council. This commonly takes around 15 minutes, and may continue in the background if the client deadline expires.\n```\n\nThen call `request_council` with:\n\n- `task_description`: the parsed task text\n- `mode`: the selected mode\n- `wait_for_result`: `true`\n- `save_locally`: `true`\n- `debate`: `true` \u2014 include this parameter **only** when `--debate` was supplied\n- `lenses`: the parsed array \u2014 include this parameter **only** when `--lenses` was supplied\n- `ticket_number`: the captured key \u2014 include this parameter **only** when `--ticket` was supplied\n\nOmit an optional parameter entirely rather than sending a placeholder: never send `debate` with a false value, never send an empty `lenses` array, and never send an empty `ticket_number` string. Do not send any other parameter \u2014 no `providers`, no `concerns`, no prior `brainstorm_id` to refine, and no lens pair of your own. Omitted `lenses` already defaults server-side; do not re-implement that default here.\n\n## Step 4 \u2014 Report the Outcome\n\nKeep the report status-first and compact: status, then the next action, then supporting detail such as the saved path, `brainstorm_id`, or mode.\n\n**Completed.** The tool appends a `Saved files:` block listing one `- <path>` line per saved report. Collect those lines as `saved_paths`; each entry is a `saved_path` reported by the tool. Display them before any optional task, mode, or `docs_dir` context, and never invent or predict a filename:\n\n```text\nCouncil complete.\nSaved to: {saved_path}\n```\n\n**Backgrounded.** A response that exceeded the client deadline but carries a `brainstorm_id` is a successful submission, not a failure. Do not display "failed", an error banner, or unrecoverable-error wording for it. Display the exact returned id and the recovery action:\n\n```text\nCouncil submitted and still running in the background.\nRetrieve it with `get_council` using {"brainstorm_id": "<the exact id returned>", "save_locally": true}.\n```\n\n**Not indexed.** When a `technical` or `discovery` request reports that the repository is not indexed, say so and name the workaround \u2014 those two modes are codebase-grounded and need an indexed repository, while `general` needs no index:\n\n```text\nThis repository is not indexed, and {mode} mode needs an indexed repository.\nRerun the same task with `--mode general`.\n```\n\n**Failed.** A tool error that carries no `brainstorm_id` is a genuine failure. Surface the tool\'s own actionable message, stop, and do not invent a retrieval handle:\n\n```text\nCouncil failed: <error message from the tool>\n```\n\n## Final Summary\n\nDisplay a summary block:\n\n```text\n## Council Report\n\n- **Saved to**: {saved_path}\n- **Task**: <task_description>\n- **Mode**: <selected mode>\n- **Status**: Completed\n```\n\nFor a backgrounded council, replace the saved-path line with the returned `brainstorm_id` and the `get_council` recovery action, and set the status to `Submitted \u2014 running in the background`.\n',"create-doc.md":'Generate a design document (TDD, FSD, or PRD) for a Jira ticket, wait for the result, and save it locally.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\nExecute all steps in this command as a simple linear sequence of MCP tool calls.\n\n## Step 1 \u2014 Parse Arguments\n\n1. **Parse `$ARGUMENTS`**: Extract a required `ticket_key`, a required `--doc-type` flag, an optional `--second-opinion` flag, and an optional `--provider` flag.\n - Split `$ARGUMENTS` on whitespace.\n - If `--doc-type` appears followed by one of `tdd`, `fsd`, or `prd`, capture that as `doc_type`.\n - If `--doc-type` is absent, or is followed by anything other than `tdd`/`fsd`/`prd` (or is the last token), stop immediately and report: "Usage error: --doc-type requires a document type (tdd, fsd, or prd)."\n - If `--second-opinion` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `second_opinion_value`.\n - If `--second-opinion` appears without a provider name following it (or is the last token), set `second_opinion_value = "auto"`.\n - If `--second-opinion` is absent, set `second_opinion_value = null`.\n - If `--provider` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `provider_value`.\n - If `--provider` appears without a valid provider name following it (or is the last token), stop immediately and report: "Usage error: --provider requires a provider name (openai, anthropic, or gemini)."\n - If `--provider` is absent, set `provider_value = null`.\n - If both `--second-opinion` and `--provider` are present, `--second-opinion` takes precedence (set `provider_value = null`).\n - The remaining token (after removing flags and their arguments) is the `ticket_key`.\n\n2. **Validate ticket key format**: Confirm the ticket key matches the Jira key pattern `[A-Za-z][A-Za-z0-9]+-\\d+`. If it does not match (or `ticket_key` is empty or missing), stop immediately and display:\n\n ```\n Usage: /create-doc <ticket_key> --doc-type <tdd|fsd|prd> [--second-opinion [provider]] [--provider <name>] (e.g., /create-doc BAPI-150 --doc-type fsd)\n ```\n\n## Step 2 \u2014 Resolve Docs Directory\n\nCall the `get_docs_dir` MCP tool (no parameters). Store the returned path as `docs_dir`.\n\n## Step 3 \u2014 Generate Design Document\n\nCall the `create_doc` MCP tool with:\n- `ticket_number`: the validated `ticket_key`\n- `doc_type`: the parsed `doc_type` (`tdd`, `fsd`, or `prd`)\n- `wait_for_result`: `true`\n- `save_locally`: `true`\n- `second_opinion`: set to `second_opinion_value` if non-null; omit entirely if null\n- `provider`: set to `provider_value` if non-null; omit entirely if null\n\nThis step may take 2-4 minutes while the backend processes the document.\n\nIf the tool returns an error, stop immediately and display:\n\n```\nDesign document generation failed: <error message from the tool>\n```\n\nIf generation did not finish, the document can be retrieved later with the `get_doc` MCP tool using the same `ticket_number` and `doc_type`.\n\n## Step 4 \u2014 Confirm Success\n\nResolve the local file path from `doc_type`:\n- `tdd` \u2192 `{docs_dir}/architecture/<ticket_key>-architecture-plan.md`\n- `fsd` \u2192 `{docs_dir}/fsd/<ticket_key>-fsd-plan.md`\n- `prd` \u2192 `{docs_dir}/prd/<ticket_key>-prd-plan.md`\n\nDisplay a confirmation message:\n\n```\nDesign document generated successfully for <ticket_key>\nSaved to: <local file path>\n```\n\n## Final Summary\n\nDisplay a summary block:\n\n```\n## Design Document Report\n\n- **Ticket**: <ticket_key>\n- **Doc Type**: <doc_type>\n- **Status**: Generated successfully\n- **Local File**: <local file path>\n```\n\nOn failure at any step, stop immediately, display which step failed and the error details, and do not proceed.\n',"create-pr.md":'# Create PR: $ARGUMENTS\n\n$ARGUMENTS\n\nThis command takes a Jira ticket key (e.g., `BAPI-150`), fetches the ticket summary, determines the base branch, and creates a pull request on the configured VCS provider. It is designed to run after `/commit-ticket` completes.\n\nIf any critical stage fails (Stage 0), stop immediately and report which stage failed and why. Non-critical stages (Stage 1 and Stage 2) should log a warning but not stop the pipeline.\n\n---\n\n# Instructions\n\nYou are executing a 3-stage pipeline to create a pull request for a Jira ticket. Execute all stages in sequence.\n\n## Stage 0 \u2014 Setup and Argument Parsing\n\n1. **Parse `$ARGUMENTS`**: Extract a single required `ticket_key` argument. The expected format is a Jira ticket key such as `BAPI-150` or `PROJ-123` \u2014 one or more uppercase letters, a hyphen, and one or more digits (regex: `[A-Z]+-\\d+`). If `$ARGUMENTS` is empty or the value does not match the expected format, stop immediately and display:\n\n ```\n Invalid ticket key format: \'<value>\'. Expected format: PROJ-123 (uppercase letters, hyphen, digits).\n Usage: /create-pr <ticket_key> (e.g., /create-pr BAPI-150)\n ```\n\n2. **Connectivity check**: Call the `ping` MCP tool (no parameters). If the ping fails or does not return `"status": "ok"`, stop immediately and display:\n\n ```\n Connectivity check failed. Please verify:\n - Check that the Bridge API MCP server is configured in your editor\'s MCP settings\n - Check that BAPI_BASE_URL is set and the server is reachable\n - Check that BAPI_API_KEY is valid\n - Check that BAPI_REPO_NAME matches a configured repository\n ```\n\n3. **Get current branch**: Run `git branch --show-current` in the terminal. Store the result as `head_branch`. Verify that `head_branch` contains the `ticket_key` (case-insensitive comparison). If the branch does not contain the ticket key, stop immediately and display:\n\n ```\n Current branch \'<head_branch>\' does not contain ticket key <ticket_key>.\n Please switch to the correct feature branch before running /create-pr.\n ```\n\n4. **Resolve base branch**: Resolve the base through this ordered precedence and take the first tier that yields a usable value.\n\n 1. **`BAPI_BASE_BRANCH` from the environment, when set and non-empty.** Read it first, explicitly, with Bash \u2014 never infer the base from branch ancestry or the repository default branch:\n\n ```bash\n echo "${BAPI_BASE_BRANCH:-}"\n ```\n\n The `:-` form returns an empty line when the variable is unset, so the read never fails the stage. The packaged `start-tickets` exports this variable into a worker\'s shell for **every** resolved run base \u2014 the ordinary `main` case included, not only an epic branch \u2014 so under a packaged spawn this tier always wins over the repository-wide configured value.\n 2. **The repository\'s configured base branch** \u2014 only when the environment value is unset. Call the `config_field` MCP tool with `operation` set to `"get"` and `field_name` set to `base_branch`.\n 3. **`main`** \u2014 the expected fallback default.\n\n Tiers 2 and 3 exist for a workflow where the environment contract is genuinely absent: `/create-pr` invoked by hand, or a legacy worker started outside packaged `start-tickets`. They are not the normal packaged-worker path \u2014 a packaged worker always arrives with `BAPI_BASE_BRANCH` set.\n\n Treat a null, empty, or whitespace-only value, an HTTP 400 Validation Error / Invalid field name, or any lookup error as not set, and fall back to `main` rather than failing the stage. Store the resolved value as `base_branch`.\n\n5. **Fetch ticket summary**: Call the `get_ticket` MCP tool with `ticket_number` set to the parsed `ticket_key`. Extract the ticket summary from the response. If the tool returns an error, log a warning and use a generic summary based on the ticket key.\n\n6. **Resolve docs directory**: Call the `get_docs_dir` MCP tool (no parameters). Store the returned path as `docs_dir`.\n\nThis stage is **critical** \u2014 stop immediately on failure. Do not proceed to Stage 1.\n\n## Stage 1 \u2014 Create Pull Request\n\n1. **Compose PR title**: Format the title as `<ticket_key>: <ticket_summary>`. Truncate to 72 characters if needed.\n\n2. **Compose PR body**: Build a PR body that includes, in this order:\n - A brief description derived from the ticket summary\n - A plain text reference to the local implementation plan: `Implementation Plan available locally at {docs_dir}/plans/{ticket_key}-plan.md` (do not use markdown hyperlink syntax \u2014 the local path is sufficient for team members pulling the branch)\n - The checklist text of `.github/PULL_REQUEST_TEMPLATE.md`, read from the current worktree when that file exists and appended after the plan reference without rewriting its markdown structure. Omit this part when the file is absent. GitHub\'s REST API does not automatically apply the repository pull request template \u2014 it is a web-UI affordance \u2014 so the checklist must be inlined into the body here or the created PR has none.\n\n3. **Create the pull request**: Call the `create_pull_request` MCP tool with:\n - `head_branch`: the current branch from Stage 0\n - `base_branch`: the resolved base branch from Stage 0\n - `title`: the composed PR title\n - `body`: the composed PR body\n\n4. **Handle the response with graceful degradation**:\n - If the response contains `available: false`: Report the reason to the user and skip to Stage 2. Do not halt the pipeline.\n - If the response contains `created: false`: Log "PR already exists" and store the returned PR URL. Continue to Stage 2.\n - If the response contains `created: true`: Store the PR URL. Continue to Stage 2.\n - If an HTTP error occurs: Warn the user with the error details and continue to Stage 2. Do not halt the pipeline.\n\nThis stage is **non-critical** \u2014 warn on failure, continue to Stage 2 regardless.\n\n## Stage 2 \u2014 Summary Report\n\nDisplay a structured report after all stages complete:\n\n```\n## Pull Request Report\n\n**Ticket**: <ticket_key>\n**Branch**: <head_branch>\n**Base Branch**: <base_branch>\n**PR URL**: <pr_url or "N/A \u2014 see warnings">\n\n**Warnings**:\n<If any non-critical stages had warnings (Stage 1: PR creation failed or unavailable),\nlist them here. If no warnings, omit this section.>\n```\n\nThis stage is **non-critical** \u2014 display the report regardless.\n\n## Final Report\n\nOn success, display the structured report from Stage 2 confirming that the pull request was created (or already existed), including the branch name, base branch, PR URL, and any warnings from earlier stages.\n\nOn failure at any critical stage (Stage 0), display which stage failed and the error details.\n',"critique-ticket.md":'Generate a ticket quality critique and save it locally.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\nThis command triggers an AI-powered critique of a Jira ticket and saves the result locally. **No human confirmation gates** \u2014 the command runs end-to-end without pausing. `$ARGUMENTS` should contain a single Jira ticket key in `PROJECT-NUMBER` format (e.g., `BAPI-123`).\n\nIf any step fails, stop immediately and report which step failed and why.\n\n## Step 1 \u2014 Parse Arguments\n\n1. **Parse `$ARGUMENTS`**: Extract a required `ticket_key`, an optional `--second-opinion` flag, and an optional `--provider` flag.\n - Split `$ARGUMENTS` on whitespace.\n - If `--second-opinion` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `second_opinion_value`.\n - If `--second-opinion` appears without a provider name following it (or is the last token), set `second_opinion_value = "auto"`.\n - If `--second-opinion` is absent, set `second_opinion_value = null`.\n - If `--provider` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `provider_value`.\n - If `--provider` appears without a valid provider name following it (or is the last token), stop immediately and report: "Usage error: --provider requires a provider name (openai, anthropic, or gemini)."\n - If `--provider` is absent, set `provider_value = null`.\n - If both `--second-opinion` and `--provider` are present, `--second-opinion` takes precedence (set `provider_value = null`).\n - The remaining token (after removing flags and their arguments) is the `ticket_key`.\n\n2. **Validate the ticket key format**: Validate that `ticket_key` matches the regex pattern `^[A-Za-z][A-Za-z0-9]+-\\d+$`. If validation fails, stop immediately and report: "The argument does not match the expected `PROJECT-NUMBER` format. Example: `BAPI-123`."\n\n## Step 2 \u2014 Resolve Docs Directory\n\nCall the `get_docs_dir` MCP tool (no parameters). Store the returned path as `docs_dir`.\n\n## Step 3 \u2014 Generate Critique\n\nCall the `request_ticket_critique` MCP tool with:\n- `ticket_number`: the validated `ticket_key`\n- `wait_for_result`: `true`\n- `save_locally`: `true`\n- `second_opinion`: set to `second_opinion_value` if non-null; omit entirely if null\n- `provider`: set to `provider_value` if non-null; omit entirely if null\n\nIf the tool returns an error, stop immediately and report: "Critique generation failed." Include the error details.\n\n## Final Report\n\n**On success**, display a summary including:\n\n- Path to the saved critique document: `{docs_dir}/ticket-critiques/{ticket_key}-ticket-quality-critique.md`\n\nNote: The critique was NOT pushed to Jira. To incorporate the critique findings into the Jira ticket description, run: `/update-ticket {ticket_key}`\n\n**On failure at any step**, stop immediately and display the step that failed and the error details.\n',"decision-page.md":'Turn open decisions from this conversation into an interactive HTML decision page, then fold the answers back in.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\n`$ARGUMENTS` is a free-form description of what needs deciding \u2014 a topic ("how we handle rate limiting"), a list of specific questions, or empty. It is **not** a Jira ticket key.\n\nThis command exists so a decision page can be reached in ordinary conversation, without running a larger automation. A decision page frames each open choice as a card \u2014 the question, why it matters, 2-4 concrete options with the consequence of each, and a recommendation \u2014 and renders it as a local HTML page the user submits from their browser. The submitted JSON comes back to you and the decisions become settled.\n\nUse it whenever a conversation has accumulated more open choices than are comfortable to settle in prose. Do not use it to ask one simple question \u2014 ask that directly.\n\nRun every stage in the main conversation so the user sees the framing as it happens. If a stage fails, say which one and why.\n\n## Stage 1 \u2014 Frame the decisions\n\n1. **Gather the candidates.** Take the decisions named in `$ARGUMENTS` plus any open choice raised earlier in this conversation and not yet settled. If `$ARGUMENTS` is empty, use the conversation alone. If you find nothing genuinely open, say so and stop \u2014 do not manufacture cards to fill a page.\n\n2. **Write one card per decision.** Each card needs:\n - `id`: a short stable id, e.g. `D-1`, `D-2`. Ids must be unique \u2014 a duplicate is rejected, because the id is the key the user\'s answer is reported under.\n - `question`: the decision itself, phrased as a question.\n - `options`: 2-4 concrete option labels. Do **not** include "None of these" or "Ask about this" \u2014 the renderer appends both automatically, and passing "None of these" yourself is rejected.\n - `option_consequences`: one consequence per option, **parallel to and the same length as** `options`. Say what actually follows from choosing it, not a restatement of the label.\n - `why_it_matters`: the concrete impact of getting this wrong.\n - `recommendation_explanation`: why the recommended option is best.\n - `recommendation_index`: the 0-based index of the recommended option, within range of `options`.\n - `codebase_evidence` (optional): your assessment plus `file:line` citations, shown collapsed behind a disclosure.\n\n Give a real recommendation on every card. If one option is obviously right, still supply the strongest alternative as a second option so the user can see what they are ruling out.\n\n3. **Show the list and let the user correct it.** Present the questions and options in chat before rendering anything. The user may add a decision you missed, drop one that is already settled, or reject your framing of a question. Apply their corrections, then proceed. This check is cheap; a page built on the wrong questions is not.\n\n## Stage 2 \u2014 Render the page\n\n1. **Pick a slug.** Derive a kebab-case slug from the topic \u2014 a few meaningful words, lowercase, non-alphanumerics stripped, at most 60 characters. It **must** match `/^[A-Za-z][A-Za-z0-9_-]*$/`; if it would start with a digit or hyphen, prefix it with `decisions-`. This slug is the `ticket_key`, which accepts any such slug and does not have to be a Jira key.\n\n2. **Call `generate_decision_page`** with the routing fields at the root and everything else nested under `content`. **The nesting is required** \u2014 `actionable_items`, `system_goals`, `clear_improvements`, and `implementation_order` passed at the root are silently dropped by the tool\'s lean input schema, and a call with no `content` at all is rejected.\n - `ticket_key`: the slug.\n - `artifact_type`: `review_decisions` (the default).\n - `output_subdir`: `decisions`.\n - `output_filename`: `{slug}-decisions.html`.\n - `labels`: optional presentation overrides \u2014 `title`, `intro`, `section_heading`. Set a `title` that names the topic, and an `intro` that says what agreeing to these choices commits the user to.\n - `content`: an object holding `actionable_items`.\n\n ```typescript\n interface DecisionPageContent {\n actionable_items: Array<{\n id: string; // e.g. "D-1"; must be unique\n question: string;\n why_it_matters: string;\n recommendation_explanation: string;\n options: string[]; // 2-4 labels (no "None of these" / "Ask about this")\n option_consequences: string[]; // same length as options\n recommendation_index: number; // 0-based within options\n codebase_evidence?: string; // optional: assessment + file:line citations\n }>;\n }\n ```\n\n Example call:\n ```json\n {\n "ticket_key": "rate-limiting",\n "artifact_type": "review_decisions",\n "output_subdir": "decisions",\n "output_filename": "rate-limiting-decisions.html",\n "labels": { "title": "Rate Limiting Decisions", "section_heading": "Open Decisions" },\n "content": {\n "actionable_items": [\n {\n "id": "D-1",\n "question": "Where should the limit be enforced?",\n "why_it_matters": "Determines whether a burst is rejected before or after it reaches the database.",\n "recommendation_explanation": "Middleware keeps the limit in one place and protects every route without per-handler work.",\n "options": ["In middleware", "Per handler"],\n "option_consequences": ["One place to change; blunt for routes that need different budgets.", "Precise per route; every new route must remember to opt in."],\n "recommendation_index": 0,\n "codebase_evidence": "api/routes/__init__.py:41 already composes shared dependencies for every router."\n }\n ]\n }\n }\n ```\n\n3. **When the decisions come with framing worth showing**, use `artifact_type: "pre_ticket_planning"` instead and add a `system_goals` object inside `content` (`business_goal`, `desired_end_state`, `system_behavior`, and optionally `acceptance_criteria` and `nfrs`). Those render read-only above the cards, each with its own agree / ask / disagree control. Use this when the user needs to see the goal the decisions serve in order to answer them; the plain `review_decisions` page is the right default otherwise.\n\n4. **Handle the response `status`:**\n - `decision_page_generated`: surface the returned `file_path` and go to Stage 3.\n - `no_decisions_needed`: no page was written because there was nothing to render. Tell the user, and do not proceed to Stage 3.\n - `VALIDATION_ERROR`: the message names the field and restates the expected shape. Fix the payload and retry once. If it fails again, report the message verbatim rather than guessing further.\n\nIf the tool fails outright, **output a highly visible warning** (e.g. **\u26A0 WARNING: The decision page could not be generated** in bold) and fall back to settling the decisions in chat, one at a time. Do not continue silently \u2014 the failure must be visible in your output.\n\n## Stage 3 \u2014 Capture the answers (stop and wait)\n\n1. **Direct the user to the page.** Give them the `file_path` and tell them to open it in their browser. Explain that they can accept a recommendation, pick another option, reject them all, or flag a card for discussion, and that they can ask you questions in chat before submitting.\n\n2. **Treat each message as a commit or a discussion turn.**\n - **Commit:** trim the message and try to parse the whole trimmed message as JSON. Treat it as a commit only when the result is an object with all three top-level fields: `ticket_key` (string), `decisions` (object), and `general_comment` (string). The first valid commit-shaped paste commits \u2014 do not over-validate the individual cards.\n - **Discussion:** anything else. Answer it, then keep waiting. If a JSON-shaped paste is missing one of the three fields, say which one rather than treating it as a freeform question.\n - **In-flight overrides:** if the user changes an answer in chat ("go with per-handler for D-1"), record it as an override. On commit, the submitted JSON is the baseline and your recorded overrides win; acknowledge each overridden card in one line.\n\n3. **Resolve every "ask" (hard rule).** After accepting a commit, find every item in `decisions` where `choice === "ask"`. For each, present the evidence and keep discussing until the user gives an explicit answer. Do not proceed while any `ask` is unresolved, and do not honor "just skip those" \u2014 an unanswered card is an unmade decision.\n\n4. **Handle "None of these".** A `choice` of `"none"` means every option you offered was wrong. Ask what the user would do instead and record their answer as the decision. Do not re-render the page for this.\n\n**You MUST stop and wait for the user here.** Do not assume answers, do not proceed on the recommendations, and do not move to Stage 4 until the user commits or explicitly declines. If they decline, say the decisions are unsettled and stop.\n\n## Stage 4 \u2014 Fold the answers back\n\n1. **Review the wider implications, then gate on a decision.** Build the review from the complete settled set: the submitted `decisions`, any in-flight overrides recorded during the conversation (these take precedence over the submission), every `"none"` answer together with the reason given for it, `general_comment`, and \u2014 where this surface tracks acceptance-criterion or NFR stances \u2014 those stances too. Do not start the review until every `ask` has an explicit recorded resolution and every in-flight override has been applied.\n\n Consider three fixed categories, regardless of whether a decision was framed as technical, user-facing, or business-oriented:\n - **Program / application** \u2014 architecture, code paths, operability, maintenance burden, and requirements imposed on other parts of the software.\n - **User** \u2014 end users, new users performing setup, operators, and developers, including prerequisites, setup friction, and additional steps.\n - **Business** \u2014 cost, adoption, support load, compliance, and reversibility.\n\n Emit only the categories with material second-order implications. For each included category, write at most four one-line bullets of about 25 words, each naming who or what is affected and how \u2014 never a restatement of the selected decision. Close with a line naming every considered category that was omitted, e.g. `Considered, nothing material: business.` \u2014 omit this closing line only when all three categories have material implications.\n\n If the review cannot be produced, report that in one line and continue without stalling the workflow or presenting the gate below.\n\n This review stays in chat: there is no document for this command to update.\n\n Then present the gate, verbatim: `Implications reviewed. Proceed, or name a decision to revisit.` Accept only a normalized `proceed`, `yes`, `y`, or `go` as a continuation token. Any other response names a decision to reopen: re-settle it in chat, record the new override, rerun the entire implications review against the changed settled set, and present the gate again.\n\n Literal `auto_approve = true` emits the review but skips this gate entirely; a missing or non-true `auto_approve` value follows the human-in-the-loop path above.\n\n2. **Restate every decision as settled**, in a short list: the question, the chosen answer, and \u2014 where the choice went against your recommendation or came from an override \u2014 one line on what changes as a result.\n\n3. **Carry `general_comment` as overarching guidance.** It applies across all the decisions, not to any one card. Say plainly how it changes the picture.\n\n4. **Name what these decisions now constrain.** One or two sentences on what is now fixed for the rest of the conversation. From here on, treat the settled answers as the contract \u2014 if later work would contradict one, say so and ask rather than quietly re-deciding.\n\nThere is no document to rewrite. The conversation is where the decisions live, unless the user asks you to record them somewhere.\n',"estimate-epic.md":"Estimate an entire Jira Epic or an explicit ticket-key group via the shared epic estimation orchestrator.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\n`$ARGUMENTS` is either a Jira Epic key (e.g. `BAPI-518`) or an explicit `--tickets` key list \u2014 never both. This command calls the `estimate_epic` MCP tool, which delegates to the Bridge API epic estimation orchestrator, and renders the structured result.\n\nIf any step fails, stop immediately and report which step failed and why, preserving the user's originally entered epic key or ticket list in the report.\n\n## Step 1 \u2014 Parse Arguments\n\n1. **Parse `$ARGUMENTS`**: Extract exactly one key-source input, plus an optional `--allow-partial` flag.\n - Split `$ARGUMENTS` on whitespace.\n - If `--tickets` appears, every token after it (up to the next flag or end of input) is the explicit ticket-key list \u2014 this is the `ticket_keys` mode.\n - Otherwise, the first token matching the Jira key pattern (`[A-Z][A-Z0-9]+-\\d+`) is the `epic_key` \u2014 this is the epic mode.\n - `--allow-partial` may appear anywhere; if present, set `allow_partial_value = true`. If absent, omit `allow_partial` entirely (do not pass `false`).\n - Never resolve both an `epic_key` and a `ticket_keys` list from the same invocation \u2014 usage is one mode or the other.\n\n2. **Validate input**:\n - Usage forms: `/estimate-epic EPIC-KEY` or `/estimate-epic --tickets KEY-1 KEY-2 ...`, plus optional `--allow-partial`.\n - If neither an `epic_key` nor a `--tickets` list can be resolved, stop immediately and report:\n ```\n Usage: /estimate-epic EPIC-KEY [--allow-partial]\n /estimate-epic --tickets KEY-1 KEY-2 ... [--allow-partial]\n ```\n - If `--tickets` is present but followed by zero keys, stop immediately and report: \"`--tickets` requires at least one ticket key.\"\n - Do not invent or pass a `mode` parameter \u2014 there isn't one; the tool infers the source from whichever of `epic_key`/`ticket_keys` is supplied.\n\n## Step 2 \u2014 Call the Tool\n\nCall the `estimate_epic` MCP tool with:\n- `epic_key`: the resolved epic key \u2014 **only** when in epic mode. Omit entirely in ticket-key mode.\n- `ticket_keys`: the resolved ticket-key list \u2014 **only** when in ticket-key mode. Omit entirely in epic mode.\n- `allow_partial`: `allow_partial_value` if `--allow-partial` was passed; omit entirely otherwise (never pass `null`, an empty string, or an empty array for any absent field).\n\nNever pass both `epic_key` and `ticket_keys` in the same call.\n\nIf the tool returns an error envelope (a JSON object with an `error` field), stop and report the error message, preserving the epic key or ticket list the user originally entered.\n\n## Step 3 \u2014 Render the Result\n\nRender the successful result as a structured report \u2014 do not dump raw JSON by default:\n\n1. **Top**: the final estimate and its scale label (`estimate_label`) as the primary heading \u2014 this is the strongest element of the report.\n2. **Immediately after the summary**: `math_source`.\n3. **Next**: resolved child ticket keys (`child_ticket_keys`) and the per-child breakdown, presented compactly.\n4. **Only if non-empty**: a compact warning section listing `failed_child_keys` and `skipped_child_keys`.\n\nKeep the happy-path report concise and scannable. Use backticks for Jira keys and technical identifiers (e.g. `BAPI-518`).\n\n> Note: this tool does not accept a `recreate` parameter \u2014 the underlying epic estimation orchestrator (BAPI-522) always reuses cached child estimates and has no recreate knob to forward to.\n\n## Final Report\n\nOn successful completion, display a structured summary per Step 3 above. On failure, display the error message returned by the tool (or the usage error from Step 1), preserving the user's originally entered epic key or ticket list.\n","explore-ticket.md":`Explore the codebase for a task, settle its acceptance criteria with the user, then propose a design that meets them.
388
+ `,"conduct-epic.md":"---\nschedulable: true\narguments: {\"positionals\":[{\"name\":\"epicKey\",\"type\":\"string\",\"required\":true}],\"flags\":[{\"name\":\"tickets\",\"flag\":\"--tickets\",\"type\":\"string\"},{\"name\":\"baseBranch\",\"flag\":\"--base-branch\",\"type\":\"string\"},{\"name\":\"checkpointPath\",\"flag\":\"--checkpoint-path\",\"type\":\"string\"}]}\n---\n\n# Conduct Epic: $ARGUMENTS\n\n$ARGUMENTS\n\nThis command drives one multi-ticket epic from an approved ticket list to a finished `epic/<EPIC>` branch, one ticket at a time. It is the LLM half of the LLM-as-conductor pivot: there is no server-side reconciler here, no background worker, and no durable run row \u2014 the checkpoint file written by the packaged `conduct-epic` CLI plus the live state of GitHub *is* the entire memory of the loop.\n\nCadence is not an argument of this command. `/loop` owns the interval, this command owns exactly one reconcile-then-act step, and the two compose without either one holding state for the other.\n\nIt composes work that already exists rather than reimplementing it: `/review-and-start --auto --base-branch 'epic/<EPIC>' <KEY>` spawns each ticket's worker, the `merge_pull_request` MCP tool merges a green and approved pull request, `parse_repository` / `get_parse_status` re-index the repository after each merge so the next ticket's plan sees its predecessor's code, and the packaged `conduct-epic` CLI (`init`, `status`, `checkpoint set`, `finish`, `spawn`) owns every durable file operation.\n\n---\n\n# Instructions\n\nYou are executing a 5-stage tick. Run the stages in order, take **exactly one** action from the Stage 3 detection table, write **exactly one** checkpoint in Stage 4, then stop. Do not loop internally, do not take a second action because the first one looked cheap, and do not carry assumptions from a previous tick \u2014 every tick reconciles from scratch.\n\nThe \"exactly one checkpoint\" rule has **three explicitly documented exemptions** and no others: the two print-only parks, `init_failed` (Stage 1) and `foreign_lock` (Stage 2), which stop before Stage 3; and the `all_done` tick (Row 1), which has no in-flight ticket to name in a `checkpoint set` command. Stage 4 states each one.\n\n## Stage 0 \u2014 Arguments and Ping\n\n1. **Parse `$ARGUMENTS`** into exactly one epic positional and the three optional flags. Accept no other input shape.\n\n - **`<EPIC>`**: exactly one positional token, which must match `[A-Z]+-[0-9]+` (e.g. `BAPI-798`). Zero epic positionals, more than one positional, or a positional that does not match the pattern is malformed input. Extra positionals are rejected rather than ignored.\n - **`--tickets <K1,K2,\u2026>`** (and the equals form `--tickets=<K1,K2,\u2026>`): a non-empty, comma-separated, **ordered** list of ticket keys. Preserve the caller's order exactly \u2014 it is the execution order of the epic. Every entry must match `[A-Z]+-[0-9]+` after trimming surrounding whitespace; reject a malformed key, an empty entry, and a duplicate key. This flag is required **only on the first tick** (see Stage 1); later ticks read the order from the checkpoint.\n - **`--base-branch <branch>`** (and the equals form `--base-branch=<branch>`): validated with the same rules as `/start-tickets` Stage 0 \u2014 after trimming surrounding whitespace it must be non-empty, at most 255 characters, must not start with `-`, and must not contain ASCII control characters (`0x00`\u2013`0x1F` or `0x7F`). It is the branch `epic/<EPIC>` is cut from at `init` time; it is not the pull-request base of a ticket, which is always `epic/<EPIC>`.\n - **`--checkpoint-path <path>`** (and the equals form `--checkpoint-path=<path>`): must be a non-empty string after trimming, checked **before** it is used as a path or interpolated into a CLI invocation. When omitted, the CLI's own default (`~/.config/bridge/conduct/<repo>/<EPIC>.json`) applies and `status` prints the resolved path.\n\n Reject malformed input before any side effect: an unsupported flag, a flag given without its value, a `--tickets` list that fails the rules above, a `--base-branch` value that fails validation, an empty `--checkpoint-path`, a missing epic, or an extra positional. On any of these, stop immediately and display:\n\n ```\n Invalid arguments.\n Usage: /conduct-epic [flags] <EPIC>\n <EPIC> required, matches [A-Z]+-[0-9]+ (e.g. BAPI-798)\n --tickets K1,K2,\u2026 ordered ticket keys; required only on the first tick\n --base-branch <branch> branch epic/<EPIC> is cut from (default: the repo base)\n --checkpoint-path <path> override the checkpoint file location\n ```\n\n2. **Connectivity check**: call the `ping` MCP tool with **no parameters**. If the call fails, or does not return `\"status\": \"ok\"`, stop immediately \u2014 before Stage 1 initialization, before any CLI invocation, and before any state is written \u2014 and display:\n\n ```\n Connectivity check failed. Please verify:\n - Check that the Bridge API MCP server is configured in your editor's MCP settings\n - Check that BAPI_BASE_URL is set and the server is reachable\n - Check that BAPI_API_KEY is valid\n - Check that BAPI_REPO_NAME matches a configured repository\n ```\n\n3. **Execution model.** This command is one tick; run it with `/loop 5m /conduct-epic <EPIC>`; each tick re-reads the checkpoint and GitHub, takes at most one action, and exits. `/loop` is the external driver that re-invokes this command \u2014 it is not an internal loop this command runs, and cadence is never an argument of this command.\n\n4. **Bash interpolation rule (global; applies to every Bash invocation in every stage).** Before interpolating any dynamic value \u2014 the epic key, a ticket key, a branch name, a checkpoint path, a prompt-file path, a JSON blob, a journal line \u2014 replace every `'` in the value with `'\\''`, then wrap the complete value in single quotes. Never expand a dynamic value unquoted, and never build a command by concatenating an unquoted variable. Credentials must never appear in a command argument, in printed output, in a journal line, or in a prompt file: the CLI and the MCP tools resolve their own credentials from the environment and the user-scoped credential store.\n\n5. **Packaged CLI launcher (`BAPI_MCP_CLI`); global, applies to every packaged-CLI invocation in every stage.** Resolve the launcher **once**, here in Stage 0, and reuse that one resolved value for the rest of the tick. Call it `<launcher>`.\n\n - Read the `BAPI_MCP_CLI` environment variable.\n - **Unset, empty, or whitespace-only** \u2014 `<launcher>` is exactly `npx -y @bridge_gpt/mcp-server`. This is the default, and the resulting shell command is byte-identical to what it was before this override existed.\n - **Otherwise** \u2014 `<launcher>` is that value, used verbatim as the command prefix. It names a local launcher, such as `node /absolute/path/to/mcp_server/build/index.js`. Use it for local pilots and pre-publish verification.\n\n When the override is set, apply item 4's single-quote escaping rule to `<launcher>` before interpolating it into a Bash command string, keep every dynamic argument independently quoted rather than concatenated into the launcher value, and never put a credential or a credential-bearing environment assignment into it. A stale local build is exactly as misleading as a stale npm publish: rebuild with `cd mcp_server && npm run build` before relying on the override.\n\nThis stage is **critical** \u2014 stop immediately on failure. Do not proceed to Stage 1.\n\n## Stage 1 \u2014 Initialize If No Checkpoint\n\nRun the first status probe through the **Bash tool**, forwarding `--checkpoint-path '<path>'` only when the user supplied it:\n\n```\n<launcher> conduct-epic status '<EPIC>' --json\n```\n\nA zero-exit response whose `checkpoint_exists` is `false` is the **only** condition under which initialization is allowed.\n\n- **`checkpoint_exists` is `false`** \u2014 this is the first tick. `--tickets` is required here, and **only** here: if it was not supplied, halt with the Stage 0 usage message and initialize nothing. On every later tick `--tickets` is optional and ignored, because the ordered list already lives in the checkpoint. Otherwise run, forwarding `--base-branch '<b>'` and `--checkpoint-path '<p>'` only when supplied:\n\n ```\n <launcher> conduct-epic init '<EPIC>' --tickets '<K1,K2,\u2026>'\n ```\n\n Print the initialization preflight output **verbatim** \u2014 do not summarize it, do not suppress its announcements, and do not reorder it. `init` runs one preflight that lists every failure at once, and that listing is the operator's only diagnostic when it refuses.\n\n On a **non-zero** exit, `init_failed` is a **print-only park**: emit `NEEDS_HUMAN:init_failed` with the complete secret-free output as evidence, print exactly one bounded, secret-free stdout journal line describing this invocation, and stop the tick. Do **not** call `checkpoint set` and do not otherwise mutate durable state. There is nothing to write to: when initialization failed, no writable checkpoint may exist at all, and any checkpoint that does exist may be the unreadable one that caused the failure. Do not attempt a second initialization in the same tick and do not fall through to Stage 2.\n\n- **`checkpoint_exists` is `true`** \u2014 an epic that already has a checkpoint must **never** trigger `init`. The CLI deliberately refuses reinitialization (`already initialized`), so a retry is not a recovery path; it is a bug in the caller. Skip straight to Stage 2.\n\n- **The status command exits non-zero** (a corrupt or wrong-version checkpoint, for example) \u2014 treat it exactly like a failed init, including the print-only rule: preserve the secret-free stderr as evidence, emit `NEEDS_HUMAN:init_failed`, print one journal line, call no `checkpoint set`, and stop the tick. `status` never rewrites a checkpoint it could not read, so nothing has been damaged.\n\n## Stage 2 \u2014 Reconcile From Status JSON\n\nRun the status probe **again**, with the same conditional `--checkpoint-path '<path>'` forwarding:\n\n```\n<launcher> conduct-epic status '<EPIC>' --json\n```\n\nThis second response is the action snapshot. **This JSON object is the only evidence the tick acts on.** Worker claims are never trusted \u2014 a session that says \"CI passed\", \"review approved\", or \"PR merged\" has told you nothing this tick may use. Every one of those facts is re-derived here from GitHub and the server through `status`, and only from there.\n\nThe top-level contract is exactly: `ok`, `epic_key`, `epic_branch`, `checkpoint_path`, `checkpoint_exists`, `all_done`, `ticket`, `worktree_path`, `worktree_exists`, `branch_head`, `worker_commits_since_spawn`, `last_seen_head`, `last_state_change_at`, `stale_for_seconds`, `pr`, `merged_externally`, `ci`, `review`, `parse`, `deadlines`, `scope`, `lock`, `needs_human`, and `probe_errors`.\n\nThe nested objects the detection table reads are:\n\n- `ticket` \u2014 the in-flight ticket (the first entry that is not `done`, or `null` when `all_done`): `key`, `status` (`pending`, `in_progress`, `merged`, `done`, `needs_human`), `branch`, `pr_number`, `spawned_at`, `parse_requested_at`, `parse_requested_for_sha`, `review_verdictless_observations`, `review_verdictless_for_sha`, `respawns`, `conflict_attempts`, `counters.sessions_spawned`, `counters.plan_generations_observed`, `counters.merge_attempts`, and `journal`.\n - `review_verdictless_observations` is a **non-negative integer** and `review_verdictless_for_sha` is **a string or `null`**. They are Row 10's head-bound verdictless counter: the count is only meaningful for the head named beside it, and a count whose head does not equal `pr.head_sha` is spent evidence about code that no longer exists. Read them together or not at all.\n - `parse_requested_at` and `parse_requested_for_sha` are **each a string or `null`**. They are RETAINED for the audit trail of epics driven before the scope-status contract existed, and **no row reads them any more**: Row 5 asks the `scope` sub-object directly instead of reconstructing causality from a request timestamp. Do not write them and do not decide on them.\n - `journal` is the ticket's journal lines, **oldest-first, newest last**, exactly as stored. It is a human-readable audit trail and is **never** the source of a decision: it is capped at 50 lines and evicts oldest-first, so a marker searched for in it would silently vanish after roughly fifty wait ticks and the loop would re-request a parse it had already requested.\n- `pr` \u2014 `number`, `state` (`OPEN`, `MERGED`, `CLOSED`), `head_sha`, `base`, `mergeable`, `merge_state`, `updated_at`.\n- `ci` \u2014 `required`, `complete`, `stable_across_two_polls`, `head_sha`, and `checks` entries of `name`, `status`, `conclusion`, `required`.\n- `review` \u2014 `opted_in`, `source`, `available`, `verdict` (`approved`, `changes_requested`, `unknown`), `head_sha`, `verdictless_disposition`, `verdictless_ceiling`, `config_invalid`.\n - `verdictless_disposition` is `park`, `fail_open`, or `null`. **`null` means `park`** \u2014 it is what a condition that configured no disposition reports, and what an unreadable configuration reports. A value is only ever one of those three; the server-side parser refuses every other spelling outright rather than passing a partially honored one through.\n - `verdictless_ceiling` is the number of head-bound verdictless observations Row 10 makes before it decides. Read it from this snapshot and compare against it; never hard-code a bound.\n - `config_invalid` is `true` when the repository's `done_gate_config` exists but could not be read \u2014 a `malformed` or `invalid: \u2026` parse. It arrives with `opted_in: true` and `available: false`, because an unreadable review policy is **not** an absent one: reading it as \"no review opt-in\" would merge on CI alone on the strength of a typo. There is no readable condition in that state, so `verdictless_disposition` is `null` and Row 10 parks.\n- `parse` \u2014 `status` (`idle`, `queued`, `in_progress`, `succeeded`, `failed`), `terminal`, `started_at`, and `finished_at`. The last two are each **a string or `null`** and are the ISO-8601 times of the current or last parse run. A `null` on either is unavailable evidence and **never** permits advancement \u2014 in particular, missing timestamps can never satisfy Row 5's causal check. There is no repository-wide index-branch override field: BAPI-847 retired that control plane, and an epic now gets its own index scope instead of taking the repository's index away.\n- `deadlines` \u2014 `soft_seconds`, `hard_seconds`, `elapsed_since_spawn_seconds` (defaults 3600 and 10800).\n- `scope` \u2014 the epic's index scope, read directly from the server: `scope_id`, `lifecycle_state`, `freshness_status`, `blocked_reason`, `required_commit_sha`, `indexed_commit_sha`, and `last_error`. It is `null` **only** when this epic declares no scope at all; that is not a probe failure and carries no `probe_errors` entry.\n - `freshness_status` is one of `fresh`, `pending`, `blocked`, `failed`, `unavailable`. **`fresh` is the only value that means the index covers this epic's merged code.** `pending` is a refresh still running. `blocked` is an epic advance the server REFUSED to index and will never resolve by waiting \u2014 `blocked_reason` names which refusal. `failed` is the scope's own generation failing. `unavailable` means the scope could not be read this tick, and is reported alongside a `{probe: \"scope\"}` entry in `probe_errors`.\n - `required_commit_sha` is the commit the scope must cover; `indexed_commit_sha` is the commit it actually has. **They are separate fields because they mean different things** \u2014 the required SHA moves the moment a merge is accepted, long before anything is indexed, so a required SHA equal to your merge commit is not evidence that your merge was indexed.\n- `lock` \u2014 `held_by_me`, `owner_pid`, `host`, `alive`.\n- `needs_human` \u2014 `null`, or `reason`, `evidence`, `at`.\n- `probe_errors` \u2014 entries of `probe` and `reason`.\n\nA failed probe leaves its sub-object `null` and is listed in `probe_errors`; it never fails the command. **A `null` sub-object is unavailable evidence, not a negative result.** Never infer a merge, an approval, a CI success, or a parse success from a `null` value, from a missing field, or from narrative output of any kind \u2014 an unavailable probe means \"wait for the next tick\", never \"proceed\".\n\n**`pr` is the one sub-object whose `null` has two distinct meanings, and `probe_errors` is what tells them apart:**\n\n- **`pr` is `null` and there is no `{probe: \"pr\"}` entry** \u2014 confirmed absence. `gh` was asked and answered that this branch has no pull request. This is the **normal** state of every tick between the first spawn and the moment the worker opens its pull request, it is a negative result the rows may act on, and Rows 6 and 7 exist precisely for it.\n- **`pr` is `null` and there IS a `{probe: \"pr\"}` entry** \u2014 unavailable evidence. `gh` could not answer: unauthenticated, rate-limited, offline, or output that did not parse. Treat it as \"wait for the next tick\" and never as absence; a pull request that exists but cannot be seen must not be reasoned about as one that does not exist.\n\nDo not collapse these two into \"no PR\". Reading an outage as absence is how the loop would respawn into, or abandon, a pull request that was there all along.\n\nTwo states stop the tick before any action is selected:\n\n- **Already parked.** If `needs_human` is not `null`, print the stable phrase `already parked`, followed by the persisted `reason`, the persisted string `evidence`, and the persisted `at` timestamp \u2014 then stop. Take no action this tick and write no checkpoint. A parked epic is a human's to unpark by editing the checkpoint (`needs_human` back to `null`, the ticket `status` back to `pending`/`in_progress`, counters adjusted if a budget is re-granted). Do not select a new recovery action on top of an existing one.\n- **Foreign lock.** If `lock.held_by_me` is `false` and `lock.alive` is `true`, another live process owns this epic. `foreign_lock` is a **print-only park**: emit `NEEDS_HUMAN:foreign_lock` carrying `lock.owner_pid` and `lock.host` as evidence, print one bounded, secret-free stdout journal line for this invocation, and stop. Do **not** call `checkpoint set`, spawn a session, merge a pull request, or start a parse while that lock is alive. The checkpoint belongs to the other live process; writing to it \u2014 even to record a park \u2014 is the two-authorities corruption the lock exists to prevent, and `checkpoint set` refuses a live foreign lock anyway.\n\n## Stage 3 \u2014 Detect and Take Exactly One Action\n\nEvaluate the rows below **strictly in written order, from top to bottom**. Evaluation stops at the first row whose condition matches; that row's action is the only action this tick performs, and control then proceeds directly to Stage 4. A later row is never \"also\" run because it happens to apply.\n\nOne row states a **forward-looking guard** in its own condition: Row 3 (`stalled`) matches only when no later action or fail-closed row would be selectable for this snapshot. That guard is part of Row 3's condition, not a departure from written order \u2014 the ordering rule still holds, and Row 3 simply does not match while a real action is available.\n\nEach row is marked **fail-open** (an uncertain or transient condition waits for the next tick) or **fail-closed** (the tick refuses to act and parks rather than guessing).\n\n### Row 1 \u2014 `all_done`: finish the epic and open its pull request\n\nWhen `all_done` is `true`, run `<launcher> conduct-epic finish '<EPIC>'` (forwarding `--checkpoint-path '<p>'` when supplied), then call the `create_pull_request` MCP tool with `head_branch` set to `epic/<EPIC>` and `base_branch` set to `main`. Assemble the `body` from the finish summary: the merged ticket pull requests and any skipped tickets. **Open the pull request; never merge it** \u2014 a human reviews and merges the epic into `main`. Then stop.\n\n**This tick writes no checkpoint and does not increment `counters.iterations`.** It is the third documented exemption from Stage 4's one-checkpoint-per-tick rule, and unlike the two print-only parks it reaches Stage 3. The reason is mechanical: `all_done` is `true` exactly when `ticket` is `null`, `checkpoint set` requires `--ticket <KEY>`, and there is no in-flight ticket to name. `finish` is this tick's durable act, and it is the last one the epic needs \u2014 so do not invent a ticket key to satisfy the rule, and do not write a checkpoint before or after `finish`.\n\n### Row 2 \u2014 Wrong base: do not touch a pull request that is not on the epic branch\n\nWhen `pr.base` is present and is not `epic/<EPIC>`, **do not touch the pull request** \u2014 no merge, no comment, no respawn. Select `NEEDS_HUMAN:wrong_base`, carrying the observed `pr.base`, `pr.number`, and the expected `epic/<EPIC>`. **Fail-closed**: only pull requests based on `epic/<EPIC>` are ever acted upon, and this row is evaluated before every work and recovery row precisely so a mis-based pull request cannot be merged, respawned into, or advanced by a later row.\n\n### Row 3 \u2014 Hard liveness: a stalled epic parks before it waits\n\nWhen `stale_for_seconds >= deadlines.hard_seconds` (default three hours, `10800`) **and no other row below is selectable this tick**, select `NEEDS_HUMAN:stalled`, carrying the observed `stale_for_seconds` and the `deadlines.hard_seconds` it exceeded. **Fail-closed**.\n\n**This row outranks wait rows only.** Before selecting it, check whether any of the following would otherwise be selectable for this snapshot; if any one of them would, take that row instead and do not park:\n\n- pending work (Row 4's first spawn),\n- Row 5's **action** branches only \u2014 branch 1's parse request, branch 3's completion, and branch 5's causal `parse_failed` park,\n- a targeted respawn (Rows 7, 9, and 11),\n- CI-red handling (Row 9) and review-remediation handling (Row 11),\n- conflict handling (Row 12),\n- ready-to-merge handling (Row 13),\n- a closed, unmerged pull request (Row 13a),\n- Row 10's **action** branch only \u2014 a verdictless review at or above `review.verdictless_ceiling`, whichever disposition it then applies. Row 10's below-ceiling branch is a wait and stays subordinate to this row, exactly as the old unbounded wait did.\n\n`stale_for_seconds` counts from the last observed head or status change, not from the last useful event \u2014 so an old but green and approved pull request accumulates staleness while being perfectly actionable. Parking that is the exact defect this guard removes. The row remains ahead of every wait row, because without it a wait would match forever and the epic would sit silent instead of asking for a human.\n\n**Row 5's wait branches are deliberately NOT in that list.** Branches 2, 4, and 6 \u2014 a parse that is queued or in progress, a non-causal `succeeded` or `failed`, an inconsistent request record \u2014 are waits, and exempting them would mean a merged ticket whose parse never starts waits forever with no human ever asked. They accumulate staleness like any other wait and park as `stalled` once `deadlines.hard_seconds` is exceeded.\n\n### Row 4 \u2014 Pending ticket: spawn the first worker\n\nWhen `ticket.status` is `pending`, spawn the ticket's session:\n\n```\n/review-and-start --auto --base-branch 'epic/<EPIC>' <KEY>\n```\n\nThen prepare the Stage 4 checkpoint values `spawned_at` (now, ISO-8601), `status=in_progress`, and `counters.sessions_spawned` = the Stage 2 value plus one.\n\n**Fail-closed**: refuse this spawn if the lock is foreign (Stage 2 has already parked in that case). The pull-request base of the spawned worker comes from BAPI-801's `BAPI_BASE_BRANCH` export \u2014 `/review-and-start --base-branch` forwards it into the spawned worker shell, and the worker's create-PR step resolves the base from it. That export is what makes the first pull request land on `epic/<EPIC>`; this loop never relies on it alone, because Row 2 independently re-checks the observed `pr.base` on every later tick.\n\n### Row 5 \u2014 Merged ticket: refresh the scope index, then mark done\n\nWhen `pr.state` is `MERGED`, or `merged_externally` is `true`, or `ticket.status` is `merged`, the ticket's code is on the epic branch. An **external merge is successful reconciliation, not an error** \u2014 a human who merged the pull request by hand did the loop's work for it, and `merged_externally` records exactly that.\n\n**The evidence this row acts on is `scope`, and only `scope`.** The epic's index scope is refreshed by the server the moment it observes the merge: it advances its own `required_commit_sha` to the merge commit and re-parses incrementally. So the question \"has this merge been indexed?\" is a question the scope can answer directly, and this row asks it instead of reconstructing an answer.\n\nThat is a deliberate replacement of the older mechanism. This row used to record the time it called `parse_repository` and the head SHA it called it for, then compare that timestamp against a repository-wide parse run's `started_at` / `finished_at` \u2014 because `parse.status` is repository-level and stays `succeeded` from any earlier parse of any earlier ticket, so \"succeeded\" alone proved nothing. Timestamp ordering was the only causality available. It is no longer needed, and inference is strictly worse than an answer: **do not call `parse_repository` from this row, and do not read `parse`, `ticket.parse_requested_at`, or `ticket.parse_requested_for_sha` as freshness evidence.** The server owns the refresh; this loop observes it.\n\nThis row is an **ordered state machine**, evaluated top to bottom, and the first matching branch is the tick's action:\n\n1. **`scope` is `null`** \u2014 this epic declares no index scope, so there is nothing to refresh and no freshness to establish. Call `update_jira_status` for the ticket with `target_status` set to the Jira `Done` state, and prepare `status=done`. Journal that the ticket completed with no declared scope. **Fail-open.** An epic that never had a scope must not be blocked by one.\n\n2. **`scope.freshness_status` is `fresh`, and `scope.indexed_commit_sha` equals `scope.required_commit_sha`, both non-null** \u2014 the scope's index provably covers the commit the server is holding it to. Only then call `update_jira_status` for the ticket with `target_status` set to the Jira `Done` state, and prepare `status=done`. Journal both observed watermarks.\n\n **Compare the scope's two watermarks against each other \u2014 never against `pr.head_sha` or `branch_head`.** Both of those are the *worker's* pre-merge branch tip: `pr.head_sha` is `headRefOid`, and `branch_head` is `git ls-remote` of the ticket's own branch. What lands on `epic/<EPIC>` is the merge commit GitHub creates, and that differs from the worker's tip under every merge strategy \u2014 merge, squash, and rebase alike. Comparing an indexed watermark against either one is therefore false essentially always, and a branch that waits on an always-false condition never marks anything done. For the same reason, do not invent a merge-commit field: the `scope` object carries exactly the seven fields named above, and none of them is one.\n\n The identity that IS causal runs between the scope's own two watermarks, and it is what replaces the old timestamp ordering. The server advances `required_commit_sha` the moment it observes this merge, and **only the parse** writes `indexed_commit_sha`; the two fields are owned by different writers precisely so their agreement means something. So `indexed == required` is the server's own statement that it has finished indexing everything it was asked to cover. A scope that finished refreshing for a **previous** ticket reads `fresh` too \u2014 but it reads it at that previous required commit, and the moment this merge is observed `required` moves ahead of `indexed` and `freshness_status` drops to `pending` until the re-parse lands. If either watermark is `null` the comparison cannot be made, so this branch does not match and the tick falls to branch 6 and waits.\n\n **The one gap this cannot see through** is the interval between the merge and the server observing it: in that window the scope still reads `fresh` at the previous ticket's watermark, and no field in the contract tells it apart from this ticket's. It is narrow in practice \u2014 the same merge event that makes `pr.state` read `MERGED` is the one that notifies the server, so a tick that reaches this row has almost always been preceded by that notification \u2014 and it closes on its own. It is not zero: a merge the server never observed at all would leave the watermarks agreeing at the previous commit, and this branch would mark the ticket done against an index that does not contain it. Treat a `done` whose journaled watermarks match the *previous* ticket's as that failure, not as a fresh index.\n\n3. **`scope.freshness_status` is `pending`, `unavailable`, or missing** \u2014 the refresh is still in flight, or the scope could not be read. Wait. Journal the observed `scope.lifecycle_state`, `scope.required_commit_sha`, and `scope.indexed_commit_sha`. Do not spawn anything and do not advance the next ticket. **An unread scope is never a fresh one.**\n\n4. **`scope.freshness_status` is `blocked`** \u2014 the server REFUSED to index this advance, and waiting will never change that. Select `NEEDS_HUMAN:shadow_stale_deadline`, with `scope.blocked_reason` as bounded string evidence, and state plainly in the evidence that **no epic advance was indexed**. **Fail-closed.**\n\n The controlled reasons and what each one means to a human:\n\n - `advance_blocked_base_merge` \u2014 the base branch was merged forward into the epic branch. The epic branch is pinned at its cut point; a base merge would move that pin.\n - `advance_blocked_unexpected_parent` \u2014 the merge commit does not descend directly from the branch head the scope pinned. Something other than a worker pull request landed on the branch.\n - `advance_blocked_history_changed` \u2014 the pinned head is gone from the branch's history. A force-push or rewrite.\n - `advance_blocked_unverifiable` \u2014 the advance could not be verified at all. Doubt blocks; it never indexes.\n\n **This park is immediate, and that is deliberate** \u2014 it is the one place the pilot escalates faster than v2. The v2 reconciler routes a blocked advance through the same `shadow.stale_deadline_seconds` clock it uses for an ordinary refresh hold, because its hold is anchored on a single durable episode timestamp that every hold reason shares. The pilot has no such episode and no typed `RunPolicy` deadline, and none of the four reasons above resolves by waiting, so waiting out a deadline would only delay a human by up to that deadline and change nothing else. Both conductors emit the **same** `shadow_stale_deadline` reason so one grep finds a refused advance either way; only the latency to the park differs. An operator comparing the two should expect the pilot to ask sooner, not to have asked for a different thing.\n\n5. **`scope.freshness_status` is `failed`** \u2014 the scope's own generation failed, which is a different problem from a refused advance. Select `NEEDS_HUMAN:parse_failed`, with `scope.lifecycle_state` and `scope.last_error` as bounded string evidence. **Fail-closed.**\n\n6. **None of branches 1\u20135 matched** \u2014 including a `fresh` scope whose indexed commit still trails its required commit, and a tick where either watermark is missing so no comparison can be made. Wait, and journal the observed scope fields. Neither advance nor park: hard liveness (Row 3) is what eventually escalates a wait that never resolves.\n\n**No next ticket is spawned until this one reaches `done`.** A merged ticket stays in flight until its scope is fresh for its own merge commit, so `ticket` still points at it and Row 4 cannot match for its successor \u2014 which is the whole point: the next ticket's review and plan must see this ticket's merged code.\n\n### Row 6 \u2014 Worktree working: wait\n\nWhen a worktree exists (`worktree_exists` is `true`), the pull request is **confirmed absent** (`pr` is `null` **and** `probe_errors` carries no `{probe: \"pr\"}` entry), and `worker_commits_since_spawn > 0`, the worker is making observable progress. Wait, and journal the observed `branch_head` and commit count. **Fail-open.**\n\nA `pr: null` accompanied by a PR probe error is unavailable evidence, not absence, and does not match this row \u2014 it falls through to Row 15 and waits.\n\n### Row 7 \u2014 Soft deadline with no progress: one targeted continuation\n\nWhen the pull request is **confirmed absent** (`pr` is `null` **and** no `{probe: \"pr\"}` entry), `worker_commits_since_spawn` is `0`, and `deadlines.elapsed_since_spawn_seconds >= deadlines.soft_seconds` (default one hour, `3600`), spend the single targeted respawn on kind `continue`, with the prompt:\n\n```\nBranch <b> for <KEY>: continue the existing plan; do not regenerate it; push when done\n```\n\nPrepare `respawns` = the Stage 2 value plus one. `respawns` is **one shared per-ticket budget**, not one allowance per row: Rows 7, 9, and 11 all spend the same single counter, so spending it here leaves nothing for a later CI fix or review fix on this ticket. The attempt **counts only if it pushed** \u2014 a later tick observing a non-null `branch_head` is the proof. A respawn that produces no push is a no-op, and a no-op respawn stops the loop rather than spinning: once the one targeted respawn is spent and the ticket still shows no pushed head, select `NEEDS_HUMAN:stalled`. **Fail-closed after one attempt**, which is what keeps a dead worker from being respawned without bound.\n\n### Row 8 \u2014 Pull request open, CI not settled: wait\n\nWhen a pull request is open and `ci.complete` is `false` **and no required check in `ci.checks` has already reached a terminal unsuccessful conclusion**, wait; or when `ci.complete` is `true` and green but `ci.stable_across_two_polls` is `false`, wait. **Fail-open.**\n\nThe boolean alone is not the condition. `ci.complete` is `false` both while checks are still running and once a required check has definitively failed, and those are opposite situations: the first is worth waiting on and the second never becomes green on its own. This row therefore covers pending and not-yet-stable checks **only** \u2014 a required check with a terminal unsuccessful conclusion is **not** consumed here and falls through to Row 9.\n\n### Row 9 \u2014 Pull request open, CI red: one targeted fix\n\nWhen a pull request is open, one or more required checks in `ci.checks` have a terminal unsuccessful conclusion, and there has been no new commit for over 60 minutes (`stale_for_seconds > 3600` is the authoritative no-new-commit duration), spend the single targeted respawn on kind `ci_fix`. Take the failing check names from `ci.checks` \u2014 the entries whose `required` is `true` \u2014 and use the prompt:\n\n```\nPR #N is red on <checks>: read the check annotations, fix, push; do not regenerate the plan\n```\n\nPrepare `respawns` = the Stage 2 value plus one; the attempt counts only if it pushed. A bare `/implement-ticket --auto` is **prohibited** here: it regenerates the plan, costs a full plan generation, and discards the failure detail the annotations already carry.\n\n`respawns` is **one shared per-ticket budget** across Rows 7, 9, and 11. A continuation respawn spent earlier on this ticket therefore leaves **no** CI-fix attempt: with the counter already at its limit, persistent red CI parks immediately as `NEEDS_HUMAN:ci_red` rather than getting a fix session of its own. Once the shared respawn is spent and CI is still red, select `NEEDS_HUMAN:ci_red` with the failing check names as bounded string evidence. **Fail-closed after one attempt.**\n\n### Row 10 \u2014 Review opted in and verdictless: count, then decide\n\nWhen `pr.state` is `OPEN`, `review.opted_in` is `true`, and the review is **verdictless for the current head** \u2014 that is, `review.available` is `false`, **or** `review.verdict` is neither `approved` nor `changes_requested` at `pr.head_sha` \u2014 the review has produced no usable answer for this code. Count the observation, then act on the count.\n\nThis row covers **both** verdictless shapes on purpose. `review.available` is `false` only when the review read itself failed. A reviewer that ran and died before publishing anything is a different shape: the read succeeds, `review.available` is `true`, and `review.verdict` is `unknown`. Both mean the same thing to this loop \u2014 no verdict exists for `pr.head_sha` \u2014 and a row that covered only the first would leave the second matching nothing at all.\n\n`changes_requested` at the current head is **explicitly excluded**, so Row 11 stays reachable: a reviewer that asked for changes produced a verdict, and that verdict is Row 11's business. A `changes_requested` verdict whose `review.head_sha` does not equal `pr.head_sha` is about code that no longer exists, so it is verdictless for the current head and does match here.\n\nThe `pr.state` is `OPEN` guard is load-bearing: without it a `CLOSED` pull request whose review is verdictless matches here, ahead of Row 13a, and the loop counts tick after tick on abandoned work instead of parking it.\n\n**Prepare the counter, bound to the current head.**\n\n- If `ticket.review_verdictless_for_sha` does **not** equal `pr.head_sha`, prepare `review_verdictless_observations` = `1` and `review_verdictless_for_sha` = `pr.head_sha`. **The counter resets on a new head.** Observations made against an abandoned head must never spend the budget belonging to the head that replaced it \u2014 a later push replaces the code the reviewer failed on, and the new code deserves its own full budget.\n- Otherwise prepare `review_verdictless_observations` = the Stage 2 value plus one, absolute, and leave `review_verdictless_for_sha` at `pr.head_sha`.\n\nWrite both prepared fields through the ordinary single `checkpoint set` for this tick, in every direction below \u2014 waiting, parking, and the waived merge alike.\n\n**Compare the prepared count with `review.verdictless_ceiling`**, which the Stage 2 snapshot carries. Compare two numbers read from the snapshot; never compare against a bound written into this prose.\n\n- **Below the ceiling** \u2014 wait one tick and journal the observation, naming the prepared count, the ceiling, and the observed `review.available` / `review.verdict`. This is today's behaviour, unchanged. This branch is a **wait**, so Row 3's hard-liveness park still outranks it exactly as it does now.\n- **At or above the ceiling** \u2014 apply `review.verdictless_disposition`. This branch is an **action**, so it outranks Row 3, and the ceiling is what an operator actually sees instead of a three-hour `stalled` that names the wrong failure.\n\n**At or above the ceiling, the disposition decides:**\n\n- **`park`** \u2014 the default, and the value used whenever `review.verdictless_disposition` is `null`, including when `review.config_invalid` is `true` (a review policy that could not be read carries no readable disposition, so it gets the safe one). Select `NEEDS_HUMAN:review_verdictless_ceiling_reached`, carrying the observed count, the ceiling, `pr.head_sha`, and `review.available` / `review.verdict` as bounded string evidence.\n- **`fail_open`** \u2014 treat the ticket as **review-opted-out for this tick** and fall through to Row 13's merge conditions. Row 13 still requires everything else it always required: `pr.state` is `OPEN`, `pr.base` is `epic/<EPIC>`, `ci.complete` is `true`, `ci.stable_across_two_polls` is `true` at `pr.head_sha`, and a non-conflicting pull request. **CI, not a verdict, is the whole of the evidence in that case** \u2014 journal `review_waived_verdictless_fail_open` and say the merge proceeded on stable CI evidence alone. Never journal, print, or record it as a review that passed or approved anything.\n\nAny value other than exactly `fail_open` resolves to `park`. There is no third direction, and an unreadable disposition is never treated as permission.\n\n### Row 11 \u2014 Changes requested for the current head: one targeted review fix\n\nWhen `pr.state` is `OPEN`, `review.verdict` is `changes_requested`, **and** `review.head_sha` equals `pr.head_sha`, spend the single targeted respawn on kind `review_fix`. The `OPEN` guard is what stops a `changes_requested` verdict left on a **closed** pull request's head from spending this ticket's one respawn on work nobody will merge \u2014 that snapshot belongs to Row 13a. The prompt carries the authoritative Stage 2 review evidence: the ticket key, the pull-request number, the reviewed head SHA, and the requested changes. A stale `review.head_sha` (one that does not equal `pr.head_sha`) is a verdict about code that no longer exists and never triggers this row. Prepare `respawns` = the Stage 2 value plus one.\n\n`respawns` is **one shared per-ticket budget** across Rows 7, 9, and 11. Any earlier continuation or CI-fix respawn on this ticket therefore leaves **no** review-fix attempt: with the counter already at its limit, requested changes on the current head park immediately. Once the shared respawn is spent and the verdict still stands for the current head, select `NEEDS_HUMAN:review_changes_requested`. **Fail-closed after one attempt.**\n\n### Row 12 \u2014 Conflicting pull request: at most two conflict sessions\n\nWhen `pr.state` is `OPEN` **and** either `pr.mergeable` is `CONFLICTING` or `pr.merge_state` is `DIRTY`, spawn a session of kind `conflict` with the prompt:\n\n```\nrebase onto origin/epic/<EPIC>, resolve, run tests, push\n```\n\nPrepare `conflict_attempts` = the Stage 2 value plus one. The conflict budget is **two** sessions and is counted separately from the single targeted respawn of Rows 7, 9, and 11 \u2014 a rebase is a different failure mode from a stalled or red worker. After the second conflict session, if the pull request is still `CONFLICTING`/`DIRTY`, select `NEEDS_HUMAN:conflict`. **Fail-closed after two attempts.**\n\nA **closed** pull request is frequently left `CONFLICTING`/`DIRTY` by GitHub, so without the `pr.state` is `OPEN` guard this row would match ahead of Row 13a and spend a rebase session resolving conflicts on a branch nobody will merge.\n\n### Row 13 \u2014 Ready to merge\n\nMerge only when **all** of the following hold on the fresh Stage 2 snapshot: the pull request is open (`pr.state` is `OPEN`); `pr.base` is `epic/<EPIC>`; `ci.complete` is `true` and `ci.stable_across_two_polls` is `true` for `ci.head_sha` equal to `pr.head_sha`; the pull request is not conflicting; and review is either opted out (`review.opted_in` is `false`), approved (`review.verdict` is `approved`) with `review.head_sha` equal to `pr.head_sha`, **or** waived by a `fail_open` verdictless ceiling reached in Row 10 this tick.\n\nWhen the waiver path is what reached this row, journal the shared token `review_waived_verdictless_fail_open` alongside the merge line and say plainly that the merge proceeded **on stable CI evidence alone**. That token is the same string the v2 conductor records for the same degradation, so one grep finds every merge that advanced without a verdict whichever conductor drove the epic. Never write it in language that claims the review passed or approved the pull request \u2014 it names what was missing, not what was satisfied.\n\nThen call the `merge_pull_request` MCP tool with exactly `pr_number` set to `pr.number` and `expected_head_sha` set to `pr.head_sha`. **The expected SHA is derived only from the fresh Stage 2 status** \u2014 never from the checkpoint, never from a worker's report, never from an earlier tick. The checkpoint deliberately stores no expected head; merge identity always comes from a freshly observed `pr.head_sha`. Prepare `counters.merge_attempts` = the Stage 2 value plus one for **every** invocation of the tool, successful or not.\n\nMap the returned envelope:\n\n- **`merged` is `true`** \u2014 the only success. It covers `outcome: merged` and `outcome: already_merged`, both of which carry that boolean. Prepare `status=merged` and top-level `counters.merges` = the Stage 2 value plus one.\n- **`outcome: refused` with `reason: head_sha_drift`** \u2014 the head moved under the merge. Journal the complete envelope (including `actual_head_sha`) and take a fresh status snapshot on the next tick. Never retry with the stale SHA.\n- **Outcome `lease_held`, `review_not_approved`, or `unknown`, or any envelope carrying `retry_hint: retry_later`** \u2014 journal it and wait for the next reconciliation tick.\n- **Outcome `dry_run`, `pending_approval`, `gate_unresolved`, `action_key_mismatch`, `review_unavailable`, `review_source_unsupported`, `error`, or any `refused` result carrying `retry_hint: needs_human`** \u2014 select `NEEDS_HUMAN:merge_blocked`. Preserve the **complete** envelope as the evidence, including `hint`, `actual_head_sha`, `ci_summary`, `paths`, and `http_status` whenever those are present; `hint` is usually the exact operator fix. **JSON-stringify that envelope into a bounded, secret-free string** \u2014 `evidence` is string data, never an object (see Stage 4).\n\n**Fail-closed**: only `merged: true` is success. A missing, `false`, or malformed `merged` value is never treated as a merge, no matter what `outcome` says alongside it.\n\n### Row 13a \u2014 Pull request closed without being merged\n\nWhen `pr.state` is `CLOSED` and the pull request was not merged, the ticket's work has been abandoned on GitHub and nothing this loop does can advance it. Select `NEEDS_HUMAN:merge_blocked`, with bounded string evidence that identifies `pr.state: CLOSED` along with `pr.number`. **Fail-closed** \u2014 a closed pull request is never respawned into, reopened, or merged by this loop.\n\n### Row 14 \u2014 Local-mode ticket operation refused\n\nWhen a ticket operation returns `409 UNSUPPORTED_IN_LOCAL_MODE`, tolerate it and journal it. The repository is running the local ticket backend, where that response is the documented terminal answer rather than a failure. It introduces **no** new parking reason. **Fail-open.**\n\n### Row 15 \u2014 No row matched: journal the snapshot and do nothing else\n\nWhen no row above matches, that is the tick's outcome, not a licence to improvise. Journal a concise summary of the Stage 2 snapshot, take **no** external action \u2014 no MCP tool call, no spawn, no merge, no parse \u2014 and change **no** row-specific checkpoint field. The single `checkpoint set` this tick writes therefore carries only the universal `counters.iterations` update and its one journal line.\n\nThis row exists because unmatched snapshots are real and reachable: a `stale_for_seconds` or `elapsed_since_spawn_seconds` that is `null` because nothing has been observed yet; a pull request whose CI is complete but not yet stable across two polls. Each of those is a legitimate \"wait for reality to move\" state, and a tick that improvised an action for it would be acting on evidence it does not have. **Fail-open.**\n\nAn open pull request awaiting a review whose `verdict` is still `unknown` is **no longer** one of these. Row 10 now matches that snapshot, counts it, and eventually decides \u2014 falling through to here would be the unbounded wait the ceiling exists to end.\n\n### Shared mechanics for every targeted session\n\nRows 7, 9, 11, and 12 spawn a session the same way. The four kinds are exactly `continue`, `ci_fix`, `review_fix`, and `conflict`.\n\n**First, write the prompt file** with the Write tool, at:\n\n```\n~/.config/bridge/conduct/<repo>/<EPIC>/prompts/<KEY>-<kind>-<n>.md\n```\n\nwhere `<EPIC>` and `<KEY>` are the validated keys, `<kind>` is one of the four kinds above, and `<n>` is the applicable absolute attempt number. **`<repo>` is the repository component of the resolved `checkpoint_path` that Stage 2's `status` returned** \u2014 read it from there rather than re-deriving it from credentials, from `BAPI_REPO_NAME`, or from anything remembered in conversation. `status` resolves that path itself, including any `--checkpoint-path` override and any `XDG_CONFIG_HOME` redirection, so it is the only value guaranteed to match where the CLI actually keeps this epic's state.\n\n**End every prompt with this exact wording**, so the spawned worker releases its worktree cleanly instead of lingering:\n\n```\nAfter the final pipeline step completes, cleanly end your worker session (for example by issuing the `/exit` command) so the worktree is released and no idle process lingers \u2014 but **only when no follow-up remains that you still own**. Do **not** exit while any of the following is true:\n\n- there are unresolved CI failures you are still correcting (the post-PR CI-correction loop in the CI-monitoring step still owns work),\n- review changes were requested and you have not yet addressed them,\n- there is a merge conflict on your PR that you still own,\n- you have unpushed local commits.\n\nExit only after your final branch state is pushed, the done-gate / CI-monitoring workflow required by the recipe has completed, and no CI/review follow-up remains. A clean `SessionEnd` is both the correct terminal lifecycle signal and the point at which the worker should exit.\n```\n\n**Then spawn**, forwarding `--checkpoint-path '<p>'` only when supplied:\n\n```\n<launcher> conduct-epic spawn '<EPIC>' --ticket '<KEY>' --prompt-file '<path>'\n```\n\n`spawn` opens exactly **one** agent tab in the ticket's `worktree_path` running the prompt file's contents. It refuses when the worktree is missing, the prompt file is unreadable, or the lock is held by another live process.\n\n**The budgets are this command's job, not the CLI's.** `spawn` never checks them: it will happily open a fifth tab if asked. One targeted respawn **shared** across Rows 7, 9, and 11 \u2014 a single per-ticket `respawns` counter, not one allowance per row \u2014 and two conflict sessions in Row 12, are enforced here, by reading the Stage 2 `respawns` and `conflict_attempts` before choosing the row.\n\nAfter a **successful** spawn, prepare `counters.sessions_spawned` = the Stage 2 value plus one. If the spawn command itself fails, do **not** advance `respawns`, `conflict_attempts`, or `counters.sessions_spawned` \u2014 a session that never opened has consumed no budget.\n\nKeep credentials, raw environment values, and unrelated command output out of prompt files and out of the spawn command's arguments. The spawned agent resolves its own credentials.\n\n## Stage 4 \u2014 Checkpoint and Stop\n\nEvery tick that reaches this stage ends with **exactly one** checkpoint command and **exactly one** journal line. There are **three exemptions**, and they divide into two kinds:\n\n- **Two print-only parks, before Stage 3.** `init_failed` (Stage 1) and `foreign_lock` (Stage 2) stop the tick *before* Stage 3 and write nothing durable at all \u2014 they print their `NEEDS_HUMAN:` line and one stdout journal line and stop. Because they never reach Stage 3 they also never increment `counters.iterations`.\n- **The `all_done` tick, inside Stage 3.** Row 1 reaches Stage 3 but has **no in-flight ticket**: `all_done` is `true` exactly when `ticket` is `null`, and `checkpoint set` requires `--ticket <KEY>`. That tick runs `finish`, opens the epic pull request, writes **no** checkpoint, and \u2014 as the single stated exception to the rule below \u2014 does **not** increment `counters.iterations`.\n\nEvery other tick, including a Row 15 fallthrough, writes here. Run, forwarding `--checkpoint-path '<p>'` whenever the user supplied it:\n\n```\n<launcher> conduct-epic checkpoint set '<EPIC>' --ticket '<KEY>' --field <name> <absolute-value> \u2026 --journal '<line>'\n```\n\nRepeat `--field <name> <absolute-value>` once per changed field, and pass `--journal '<line>'` exactly once. Do not issue a second `checkpoint set` in the same tick, and do not split the fields across two invocations \u2014 one tick, one auditable write.\n\n**Every value is absolute, computed from the Stage 2 snapshot.** Relative or guessed increments are prohibited: the CLI stores what it is given, so a \"+1\" that was never resolved against a fresh read silently corrupts the count. Compute `n + 1` from the Stage 2 value for `counters.sessions_spawned`, `respawns`, `conflict_attempts`, `counters.merge_attempts`, `counters.iterations`, and `counters.merges`.\n\n`review_verdictless_observations` follows the same absolute rule with one addition: when `ticket.review_verdictless_for_sha` does not equal `pr.head_sha`, the absolute value is `1` rather than `n + 1`, because the counter is bound to a head and resets when the head moves. `review_verdictless_for_sha` is written as the observed `pr.head_sha`. Row 10 owns both fields; no other row writes them.\n\nInclude only the fields the selected row actually affected \u2014 typically some of `status`, `spawned_at`, `respawns`, `conflict_attempts`, `review_verdictless_observations`, `review_verdictless_for_sha`, `counters.sessions_spawned`, `counters.merge_attempts`, `counters.iterations`, and `counters.merges`.\n\n**`parse_requested_at` and `parse_requested_for_sha` are no longer written by any row.** The CLI still accepts them so an older checkpoint stays readable, but Row 5 now reads the `scope` sub-object \u2014 the server's own answer about whether this merge was indexed \u2014 rather than recording a request and timing it. Writing them would record evidence nothing reads.\n\n**`counters.iterations` increments exactly once for every tick that reaches Stage 3**, and it is written in that tick's single `checkpoint set` as the Stage 2 absolute value plus one. It is the one field every such tick updates, including a Row 15 fallthrough \u2014 which is why a fallthrough tick's checkpoint contains only `counters.iterations` and its journal line, with no status, retry, merge, or parking mutation. The two print-only parks never reach Stage 3 and so never increment it, and the `all_done` tick reaches Stage 3 but writes no checkpoint, so it does not increment it either.\n\n**Parking** adds two fields to the same single command:\n\n```\n--field status needs_human --field needs_human '{\"reason\":\"<reason>\",\"evidence\":\"<bounded secret-free JSON-stringified envelope or output>\",\"at\":\"<ISO-8601 timestamp>\"}'\n```\n\n**`evidence` is a JSON string, never an object.** The CLI's checkpoint schema accepts only `{reason: string, evidence: string, at: string}` and rejects anything else outright, so an object-valued `evidence` makes `checkpoint set` exit non-zero: the `NEEDS_HUMAN:` line prints, the park never persists, and the next tick repeats the failing action. When the evidence is structured \u2014 a merge envelope, a command's output \u2014 JSON-stringify it and escape every embedded quote and control character so the result is a single valid JSON string value. Keep it bounded and secret-free.\n\nThe `reason` is one of the closed list below and `at` is an ISO-8601 timestamp. Every `NEEDS_HUMAN:<reason>` line printed by a stage carries the **same** evidence that is persisted here \u2014 the printed line and the checkpoint never disagree.\n\nThe parking vocabulary is closed \u2014 **eleven reasons** and no others \u2014 and it has two partitions:\n\n- **Nine persisted reasons**, each written durably by the single `checkpoint set` above: `stalled`, `ci_red`, `review_changes_requested`, `merge_blocked`, `conflict`, `parse_failed`, `shadow_stale_deadline`, `wrong_base`, and `review_verdictless_ceiling_reached`. A persisted park is what makes the *next* tick report `already parked` and stop.\n - `shadow_stale_deadline` is Row 5 branch 4's reason, and it is deliberately **the same token the v2 conductor parks under** for the same condition. Both conductors reaching for one string is what lets an operator grep for a refused epic advance without first working out which conductor drove the epic. It is distinct from `parse_failed`: `parse_failed` means the index generation broke, while `shadow_stale_deadline` means the index refused to accept the branch advance at all.\n - `review_verdictless_ceiling_reached` is Row 10's park, and it is **byte-identical to v2's own token** for the same reason `shadow_stale_deadline` is shared: one grep finds a verdictless ceiling whichever conductor drove the epic. Four alternatives were considered and rejected. `stalled` is the label this row exists to stop emitting \u2014 it says the worker died when what actually died was the reviewer. `merge_blocked` is wrong because the merge tool was never called, and its evidence table is built entirely around merge envelopes. `review_changes_requested` is factually false: nobody requested changes, nobody said anything. And a fresh `review_unavailable` token would collide with the merge tool's existing `review_unavailable` *outcome*, which Row 13 already maps to `merge_blocked` \u2014 two different conditions answering to one string is exactly the confusion a closed vocabulary exists to prevent.\n- **Two print-only reasons**, which are printed and journaled to stdout for the current invocation only and write nothing durable: `init_failed` and `foreign_lock`. Neither may call `checkpoint set`. A print-only park leaves no durable record, so it does not produce an `already parked` tick \u2014 the next tick reconciles from scratch and reports the condition again if it persists.\n\nDo not invent a new reason; a genuinely new failure mode is a change to this command and to the BAPI-805 runbook together.\n\nThe journal line is one line containing the ISO-8601 time, the selected action, and concise evidence. Print it **last**, after the checkpoint command has succeeded, so the operator's final line of output is the tick's durable record.\n\nEvery dynamic value in this stage follows the Stage 0 single-quote rule \u2014 the epic key, the ticket key, the checkpoint path, the `needs_human` JSON, and the journal line are each escaped (`'` \u2192 `'\\''`) and wrapped in single quotes. Credentials never appear in a checkpoint argument or in journal evidence.\n\n## Operational Guarantees\n\n- **Spec freshness is `/review-and-start`'s job, not a separate check.** Each ticket's review phase runs in a worktree cut from the current `epic/<EPIC>` tip, so its review and its plan already see every predecessor's merged code. This command runs no separate spec-freshness check and needs none.\n- **The checkpoint plus GitHub are the resume point.** Nothing relies on conversation memory. A sleeping laptop merely misses ticks; the next invocation reconciles from scratch and continues where reality actually is.\n- **This command never creates an `epic_run`.** It must never be combined with `setup-epic` on the same epic \u2014 the v2 conductor stays active there, and two authorities transitioning one epic is exactly the failure this pivot removes.\n- **`/loop 5m /conduct-epic <EPIC>` is the driver.** The operator runbook is BAPI-805's, not this file's.\n- **Recovery is bounded**: one targeted respawn *shared* across Rows 7, 9, and 11, and two conflict sessions, then park. There is no third chance and no escalating retry.\n- **The first spawn relies on BAPI-801's `BAPI_BASE_BRANCH` contract**, while every tick still independently verifies the observed `pr.base` (Row 2). The export makes the right thing happen; the check catches it when it does not.\n","council.md":'Convene a multi-perspective council on a task via Bridge API and save the resulting report locally.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\nExecute all steps in this command as a simple linear sequence of MCP tool calls.\n\n## Step 1 \u2014 Parse Arguments\n\nParse `$ARGUMENTS`. The supported invocation is exactly:\n\n```text\n/council <task description> [--mode technical|design|discovery|general] [--debate] [--lenses a,b] [--ticket PROJ-123]\n```\n\nParsing rules:\n\n- Keep every non-flag token in its original order; the joined result is the required `task_description`. Remove each recognized flag, and the value token that belongs to it, from that text.\n- `--mode <value>` accepts exactly `technical`, `design`, `discovery`, or `general`. When `--mode` is omitted, the selected mode is `technical`.\n- `--debate` is a valueless boolean flag. It takes no following token.\n- `--lenses <a,b>` takes one comma-separated value. Split it on commas and keep the non-empty entries as the `lenses` array.\n- `--ticket <KEY>` captures the immediately following token as the ticket key.\n- A missing value for `--mode`, `--lenses`, or `--ticket` \u2014 including a value position occupied by another recognized flag \u2014 is a validation failure. Never let the next flag become a flag\'s value.\n\nValidation must finish before any MCP tool call. Stop immediately, display the usage response below, and make no tool call when `$ARGUMENTS` is empty, when it contains only flags, when a flag that needs a value has none, or when `--mode` is given an unsupported value:\n\n```text\nUsage: /council <task description> [--mode technical|design|discovery|general] [--debate] [--lenses a,b] [--ticket PROJ-123]\nExample: /council "How should we add rate limiting to the LLM client?" --mode technical\n```\n\n## Step 2 \u2014 Resolve Docs Directory\n\nCall `get_docs_dir` (no parameters). Store the returned path as `docs_dir`. This is context only \u2014 do not slugify it, predict a filename from it, or otherwise construct a report path yourself.\n\n## Step 3 \u2014 Convene the Council\n\nBefore calling the tool, tell the user calmly what to expect:\n\n```text\nConvening the council. This commonly takes around 15 minutes, and may continue in the background if the client deadline expires.\n```\n\nThen call `request_council` with:\n\n- `task_description`: the parsed task text\n- `mode`: the selected mode\n- `wait_for_result`: `true`\n- `save_locally`: `true`\n- `debate`: `true` \u2014 include this parameter **only** when `--debate` was supplied\n- `lenses`: the parsed array \u2014 include this parameter **only** when `--lenses` was supplied\n- `ticket_number`: the captured key \u2014 include this parameter **only** when `--ticket` was supplied\n\nOmit an optional parameter entirely rather than sending a placeholder: never send `debate` with a false value, never send an empty `lenses` array, and never send an empty `ticket_number` string. Do not send any other parameter \u2014 no `providers`, no `concerns`, no prior `brainstorm_id` to refine, and no lens pair of your own. Omitted `lenses` already defaults server-side; do not re-implement that default here.\n\n## Step 4 \u2014 Report the Outcome\n\nKeep the report status-first and compact: status, then the next action, then supporting detail such as the saved path, `brainstorm_id`, or mode.\n\n**Completed.** The tool appends a `Saved files:` block listing one `- <path>` line per saved report. Collect those lines as `saved_paths`; each entry is a `saved_path` reported by the tool. Display them before any optional task, mode, or `docs_dir` context, and never invent or predict a filename:\n\n```text\nCouncil complete.\nSaved to: {saved_path}\n```\n\n**Backgrounded.** A response that exceeded the client deadline but carries a `brainstorm_id` is a successful submission, not a failure. Do not display "failed", an error banner, or unrecoverable-error wording for it. Display the exact returned id and the recovery action:\n\n```text\nCouncil submitted and still running in the background.\nRetrieve it with `get_council` using {"brainstorm_id": "<the exact id returned>", "save_locally": true}.\n```\n\n**Not indexed.** When a `technical` or `discovery` request reports that the repository is not indexed, say so and name the workaround \u2014 those two modes are codebase-grounded and need an indexed repository, while `general` needs no index:\n\n```text\nThis repository is not indexed, and {mode} mode needs an indexed repository.\nRerun the same task with `--mode general`.\n```\n\n**Failed.** A tool error that carries no `brainstorm_id` is a genuine failure. Surface the tool\'s own actionable message, stop, and do not invent a retrieval handle:\n\n```text\nCouncil failed: <error message from the tool>\n```\n\n## Final Summary\n\nDisplay a summary block:\n\n```text\n## Council Report\n\n- **Saved to**: {saved_path}\n- **Task**: <task_description>\n- **Mode**: <selected mode>\n- **Status**: Completed\n```\n\nFor a backgrounded council, replace the saved-path line with the returned `brainstorm_id` and the `get_council` recovery action, and set the status to `Submitted \u2014 running in the background`.\n',"create-doc.md":'Generate a design document (TDD, FSD, or PRD) for a Jira ticket, wait for the result, and save it locally.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\nExecute all steps in this command as a simple linear sequence of MCP tool calls.\n\n## Step 1 \u2014 Parse Arguments\n\n1. **Parse `$ARGUMENTS`**: Extract a required `ticket_key`, a required `--doc-type` flag, an optional `--second-opinion` flag, and an optional `--provider` flag.\n - Split `$ARGUMENTS` on whitespace.\n - If `--doc-type` appears followed by one of `tdd`, `fsd`, or `prd`, capture that as `doc_type`.\n - If `--doc-type` is absent, or is followed by anything other than `tdd`/`fsd`/`prd` (or is the last token), stop immediately and report: "Usage error: --doc-type requires a document type (tdd, fsd, or prd)."\n - If `--second-opinion` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `second_opinion_value`.\n - If `--second-opinion` appears without a provider name following it (or is the last token), set `second_opinion_value = "auto"`.\n - If `--second-opinion` is absent, set `second_opinion_value = null`.\n - If `--provider` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `provider_value`.\n - If `--provider` appears without a valid provider name following it (or is the last token), stop immediately and report: "Usage error: --provider requires a provider name (openai, anthropic, or gemini)."\n - If `--provider` is absent, set `provider_value = null`.\n - If both `--second-opinion` and `--provider` are present, `--second-opinion` takes precedence (set `provider_value = null`).\n - The remaining token (after removing flags and their arguments) is the `ticket_key`.\n\n2. **Validate ticket key format**: Confirm the ticket key matches the Jira key pattern `[A-Za-z][A-Za-z0-9]+-\\d+`. If it does not match (or `ticket_key` is empty or missing), stop immediately and display:\n\n ```\n Usage: /create-doc <ticket_key> --doc-type <tdd|fsd|prd> [--second-opinion [provider]] [--provider <name>] (e.g., /create-doc BAPI-150 --doc-type fsd)\n ```\n\n## Step 2 \u2014 Resolve Docs Directory\n\nCall the `get_docs_dir` MCP tool (no parameters). Store the returned path as `docs_dir`.\n\n## Step 3 \u2014 Generate Design Document\n\nCall the `create_doc` MCP tool with:\n- `ticket_number`: the validated `ticket_key`\n- `doc_type`: the parsed `doc_type` (`tdd`, `fsd`, or `prd`)\n- `wait_for_result`: `true`\n- `save_locally`: `true`\n- `second_opinion`: set to `second_opinion_value` if non-null; omit entirely if null\n- `provider`: set to `provider_value` if non-null; omit entirely if null\n\nThis step may take 2-4 minutes while the backend processes the document.\n\nIf the tool returns an error, stop immediately and display:\n\n```\nDesign document generation failed: <error message from the tool>\n```\n\nIf generation did not finish, the document can be retrieved later with the `get_doc` MCP tool using the same `ticket_number` and `doc_type`.\n\n## Step 4 \u2014 Confirm Success\n\nResolve the local file path from `doc_type`:\n- `tdd` \u2192 `{docs_dir}/architecture/<ticket_key>-architecture-plan.md`\n- `fsd` \u2192 `{docs_dir}/fsd/<ticket_key>-fsd-plan.md`\n- `prd` \u2192 `{docs_dir}/prd/<ticket_key>-prd-plan.md`\n\nDisplay a confirmation message:\n\n```\nDesign document generated successfully for <ticket_key>\nSaved to: <local file path>\n```\n\n## Final Summary\n\nDisplay a summary block:\n\n```\n## Design Document Report\n\n- **Ticket**: <ticket_key>\n- **Doc Type**: <doc_type>\n- **Status**: Generated successfully\n- **Local File**: <local file path>\n```\n\nOn failure at any step, stop immediately, display which step failed and the error details, and do not proceed.\n',"create-pr.md":'# Create PR: $ARGUMENTS\n\n$ARGUMENTS\n\nThis command takes a Jira ticket key (e.g., `BAPI-150`), fetches the ticket summary, determines the base branch, and creates a pull request on the configured VCS provider. It is designed to run after `/commit-ticket` completes.\n\nIf any critical stage fails (Stage 0), stop immediately and report which stage failed and why. Non-critical stages (Stage 1 and Stage 2) should log a warning but not stop the pipeline.\n\n---\n\n# Instructions\n\nYou are executing a 3-stage pipeline to create a pull request for a Jira ticket. Execute all stages in sequence.\n\n## Stage 0 \u2014 Setup and Argument Parsing\n\n1. **Parse `$ARGUMENTS`**: Extract a single required `ticket_key` argument. The expected format is a Jira ticket key such as `BAPI-150` or `PROJ-123` \u2014 one or more uppercase letters, a hyphen, and one or more digits (regex: `[A-Z]+-\\d+`). If `$ARGUMENTS` is empty or the value does not match the expected format, stop immediately and display:\n\n ```\n Invalid ticket key format: \'<value>\'. Expected format: PROJ-123 (uppercase letters, hyphen, digits).\n Usage: /create-pr <ticket_key> (e.g., /create-pr BAPI-150)\n ```\n\n2. **Connectivity check**: Call the `ping` MCP tool (no parameters). If the ping fails or does not return `"status": "ok"`, stop immediately and display:\n\n ```\n Connectivity check failed. Please verify:\n - Check that the Bridge API MCP server is configured in your editor\'s MCP settings\n - Check that BAPI_BASE_URL is set and the server is reachable\n - Check that BAPI_API_KEY is valid\n - Check that BAPI_REPO_NAME matches a configured repository\n ```\n\n3. **Get current branch**: Run `git branch --show-current` in the terminal. Store the result as `head_branch`. Verify that `head_branch` contains the `ticket_key` (case-insensitive comparison). If the branch does not contain the ticket key, stop immediately and display:\n\n ```\n Current branch \'<head_branch>\' does not contain ticket key <ticket_key>.\n Please switch to the correct feature branch before running /create-pr.\n ```\n\n4. **Resolve base branch**: Resolve the base through this ordered precedence and take the first tier that yields a usable value.\n\n 1. **`BAPI_BASE_BRANCH` from the environment, when set and non-empty.** Read it first, explicitly, with Bash \u2014 never infer the base from branch ancestry or the repository default branch:\n\n ```bash\n echo "${BAPI_BASE_BRANCH:-}"\n ```\n\n The `:-` form returns an empty line when the variable is unset, so the read never fails the stage. The packaged `start-tickets` exports this variable into a worker\'s shell for **every** resolved run base \u2014 the ordinary `main` case included, not only an epic branch \u2014 so under a packaged spawn this tier always wins over the repository-wide configured value.\n 2. **The repository\'s configured base branch** \u2014 only when the environment value is unset. Call the `config_field` MCP tool with `operation` set to `"get"` and `field_name` set to `base_branch`.\n 3. **`main`** \u2014 the expected fallback default.\n\n Tiers 2 and 3 exist for a workflow where the environment contract is genuinely absent: `/create-pr` invoked by hand, or a legacy worker started outside packaged `start-tickets`. They are not the normal packaged-worker path \u2014 a packaged worker always arrives with `BAPI_BASE_BRANCH` set.\n\n Treat a null, empty, or whitespace-only value, an HTTP 400 Validation Error / Invalid field name, or any lookup error as not set, and fall back to `main` rather than failing the stage. Store the resolved value as `base_branch`.\n\n5. **Fetch ticket summary**: Call the `get_ticket` MCP tool with `ticket_number` set to the parsed `ticket_key`. Extract the ticket summary from the response. If the tool returns an error, log a warning and use a generic summary based on the ticket key.\n\n6. **Resolve docs directory**: Call the `get_docs_dir` MCP tool (no parameters). Store the returned path as `docs_dir`.\n\nThis stage is **critical** \u2014 stop immediately on failure. Do not proceed to Stage 1.\n\n## Stage 1 \u2014 Create Pull Request\n\n1. **Compose PR title**: Format the title as `<ticket_key>: <ticket_summary>`. Truncate to 72 characters if needed.\n\n2. **Compose PR body**: Build a PR body that includes, in this order:\n - A brief description derived from the ticket summary\n - A plain text reference to the local implementation plan: `Implementation Plan available locally at {docs_dir}/plans/{ticket_key}-plan.md` (do not use markdown hyperlink syntax \u2014 the local path is sufficient for team members pulling the branch)\n - The checklist text of `.github/PULL_REQUEST_TEMPLATE.md`, read from the current worktree when that file exists and appended after the plan reference without rewriting its markdown structure. Omit this part when the file is absent. GitHub\'s REST API does not automatically apply the repository pull request template \u2014 it is a web-UI affordance \u2014 so the checklist must be inlined into the body here or the created PR has none.\n\n3. **Create the pull request**: Call the `create_pull_request` MCP tool with:\n - `head_branch`: the current branch from Stage 0\n - `base_branch`: the resolved base branch from Stage 0\n - `title`: the composed PR title\n - `body`: the composed PR body\n\n4. **Handle the response with graceful degradation**:\n - If the response contains `available: false`: Report the reason to the user and skip to Stage 2. Do not halt the pipeline.\n - If the response contains `created: false`: Log "PR already exists" and store the returned PR URL. Continue to Stage 2.\n - If the response contains `created: true`: Store the PR URL. Continue to Stage 2.\n - If an HTTP error occurs: Warn the user with the error details and continue to Stage 2. Do not halt the pipeline.\n\nThis stage is **non-critical** \u2014 warn on failure, continue to Stage 2 regardless.\n\n## Stage 2 \u2014 Summary Report\n\nDisplay a structured report after all stages complete:\n\n```\n## Pull Request Report\n\n**Ticket**: <ticket_key>\n**Branch**: <head_branch>\n**Base Branch**: <base_branch>\n**PR URL**: <pr_url or "N/A \u2014 see warnings">\n\n**Warnings**:\n<If any non-critical stages had warnings (Stage 1: PR creation failed or unavailable),\nlist them here. If no warnings, omit this section.>\n```\n\nThis stage is **non-critical** \u2014 display the report regardless.\n\n## Final Report\n\nOn success, display the structured report from Stage 2 confirming that the pull request was created (or already existed), including the branch name, base branch, PR URL, and any warnings from earlier stages.\n\nOn failure at any critical stage (Stage 0), display which stage failed and the error details.\n',"critique-ticket.md":'Generate a ticket quality critique and save it locally.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\nThis command triggers an AI-powered critique of a Jira ticket and saves the result locally. **No human confirmation gates** \u2014 the command runs end-to-end without pausing. `$ARGUMENTS` should contain a single Jira ticket key in `PROJECT-NUMBER` format (e.g., `BAPI-123`).\n\nIf any step fails, stop immediately and report which step failed and why.\n\n## Step 1 \u2014 Parse Arguments\n\n1. **Parse `$ARGUMENTS`**: Extract a required `ticket_key`, an optional `--second-opinion` flag, and an optional `--provider` flag.\n - Split `$ARGUMENTS` on whitespace.\n - If `--second-opinion` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `second_opinion_value`.\n - If `--second-opinion` appears without a provider name following it (or is the last token), set `second_opinion_value = "auto"`.\n - If `--second-opinion` is absent, set `second_opinion_value = null`.\n - If `--provider` appears followed by a provider name (one of `openai`, `anthropic`, `gemini`), capture that provider as `provider_value`.\n - If `--provider` appears without a valid provider name following it (or is the last token), stop immediately and report: "Usage error: --provider requires a provider name (openai, anthropic, or gemini)."\n - If `--provider` is absent, set `provider_value = null`.\n - If both `--second-opinion` and `--provider` are present, `--second-opinion` takes precedence (set `provider_value = null`).\n - The remaining token (after removing flags and their arguments) is the `ticket_key`.\n\n2. **Validate the ticket key format**: Validate that `ticket_key` matches the regex pattern `^[A-Za-z][A-Za-z0-9]+-\\d+$`. If validation fails, stop immediately and report: "The argument does not match the expected `PROJECT-NUMBER` format. Example: `BAPI-123`."\n\n## Step 2 \u2014 Resolve Docs Directory\n\nCall the `get_docs_dir` MCP tool (no parameters). Store the returned path as `docs_dir`.\n\n## Step 3 \u2014 Generate Critique\n\nCall the `request_ticket_critique` MCP tool with:\n- `ticket_number`: the validated `ticket_key`\n- `wait_for_result`: `true`\n- `save_locally`: `true`\n- `second_opinion`: set to `second_opinion_value` if non-null; omit entirely if null\n- `provider`: set to `provider_value` if non-null; omit entirely if null\n\nIf the tool returns an error, stop immediately and report: "Critique generation failed." Include the error details.\n\n## Final Report\n\n**On success**, display a summary including:\n\n- Path to the saved critique document: `{docs_dir}/ticket-critiques/{ticket_key}-ticket-quality-critique.md`\n\nNote: The critique was NOT pushed to Jira. To incorporate the critique findings into the Jira ticket description, ask your agent to update the description for {ticket_key} using this document.\n\n**On failure at any step**, stop immediately and display the step that failed and the error details.\n',"decision-page.md":'Turn open decisions from this conversation into an interactive HTML decision page, then fold the answers back in.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\n`$ARGUMENTS` is a free-form description of what needs deciding \u2014 a topic ("how we handle rate limiting"), a list of specific questions, or empty. It is **not** a Jira ticket key.\n\nThis command exists so a decision page can be reached in ordinary conversation, without running a larger automation. A decision page frames each open choice as a card \u2014 the question, why it matters, 2-4 concrete options with the consequence of each, and a recommendation \u2014 and renders it as a local HTML page the user submits from their browser. The submitted JSON comes back to you and the decisions become settled.\n\nUse it whenever a conversation has accumulated more open choices than are comfortable to settle in prose. Do not use it to ask one simple question \u2014 ask that directly.\n\nRun every stage in the main conversation so the user sees the framing as it happens. If a stage fails, say which one and why.\n\n## Stage 1 \u2014 Frame the decisions\n\n1. **Gather the candidates.** Take the decisions named in `$ARGUMENTS` plus any open choice raised earlier in this conversation and not yet settled. If `$ARGUMENTS` is empty, use the conversation alone. If you find nothing genuinely open, say so and stop \u2014 do not manufacture cards to fill a page.\n\n2. **Write one card per decision.** Each card needs:\n - `id`: a short stable id, e.g. `D-1`, `D-2`. Ids must be unique \u2014 a duplicate is rejected, because the id is the key the user\'s answer is reported under.\n - `question`: the decision itself, phrased as a question.\n - `options`: 2-4 concrete option labels. Do **not** include "None of these" or "Ask about this" \u2014 the renderer appends both automatically, and passing "None of these" yourself is rejected.\n - `option_consequences`: one consequence per option, **parallel to and the same length as** `options`. Say what actually follows from choosing it, not a restatement of the label.\n - `why_it_matters`: the concrete impact of getting this wrong.\n - `recommendation_explanation`: why the recommended option is best.\n - `recommendation_index`: the 0-based index of the recommended option, within range of `options`.\n - `codebase_evidence` (optional): your assessment plus `file:line` citations, shown collapsed behind a disclosure.\n\n Give a real recommendation on every card. If one option is obviously right, still supply the strongest alternative as a second option so the user can see what they are ruling out.\n\n3. **Show the list and let the user correct it.** Present the questions and options in chat before rendering anything. The user may add a decision you missed, drop one that is already settled, or reject your framing of a question. Apply their corrections, then proceed. This check is cheap; a page built on the wrong questions is not.\n\n## Stage 2 \u2014 Render the page\n\n1. **Pick a slug.** Derive a kebab-case slug from the topic \u2014 a few meaningful words, lowercase, non-alphanumerics stripped, at most 60 characters. It **must** match `/^[A-Za-z][A-Za-z0-9_-]*$/`; if it would start with a digit or hyphen, prefix it with `decisions-`. This slug is the `ticket_key`, which accepts any such slug and does not have to be a Jira key.\n\n2. **Call `generate_decision_page`** with the routing fields at the root and everything else nested under `content`. **The nesting is required** \u2014 `actionable_items`, `system_goals`, `clear_improvements`, and `implementation_order` passed at the root are silently dropped by the tool\'s lean input schema, and a call with no `content` at all is rejected.\n - `ticket_key`: the slug.\n - `artifact_type`: `review_decisions` (the default).\n - `output_subdir`: `decisions`.\n - `output_filename`: `{slug}-decisions.html`.\n - `labels`: optional presentation overrides \u2014 `title`, `intro`, `section_heading`. Set a `title` that names the topic, and an `intro` that says what agreeing to these choices commits the user to.\n - `content`: an object holding `actionable_items`.\n\n ```typescript\n interface DecisionPageContent {\n actionable_items: Array<{\n id: string; // e.g. "D-1"; must be unique\n question: string;\n why_it_matters: string;\n recommendation_explanation: string;\n options: string[]; // 2-4 labels (no "None of these" / "Ask about this")\n option_consequences: string[]; // same length as options\n recommendation_index: number; // 0-based within options\n codebase_evidence?: string; // optional: assessment + file:line citations\n }>;\n }\n ```\n\n Example call:\n ```json\n {\n "ticket_key": "rate-limiting",\n "artifact_type": "review_decisions",\n "output_subdir": "decisions",\n "output_filename": "rate-limiting-decisions.html",\n "labels": { "title": "Rate Limiting Decisions", "section_heading": "Open Decisions" },\n "content": {\n "actionable_items": [\n {\n "id": "D-1",\n "question": "Where should the limit be enforced?",\n "why_it_matters": "Determines whether a burst is rejected before or after it reaches the database.",\n "recommendation_explanation": "Middleware keeps the limit in one place and protects every route without per-handler work.",\n "options": ["In middleware", "Per handler"],\n "option_consequences": ["One place to change; blunt for routes that need different budgets.", "Precise per route; every new route must remember to opt in."],\n "recommendation_index": 0,\n "codebase_evidence": "api/routes/__init__.py:41 already composes shared dependencies for every router."\n }\n ]\n }\n }\n ```\n\n3. **When the decisions come with framing worth showing**, use `artifact_type: "pre_ticket_planning"` instead and add a `system_goals` object inside `content` (`business_goal`, `desired_end_state`, `system_behavior`, and optionally `acceptance_criteria` and `nfrs`). Those render read-only above the cards, each with its own agree / ask / disagree control. Use this when the user needs to see the goal the decisions serve in order to answer them; the plain `review_decisions` page is the right default otherwise.\n\n4. **Handle the response `status`:**\n - `decision_page_generated`: surface the returned `file_path` and go to Stage 3.\n - `no_decisions_needed`: no page was written because there was nothing to render. Tell the user, and do not proceed to Stage 3.\n - `VALIDATION_ERROR`: the message names the field and restates the expected shape. Fix the payload and retry once. If it fails again, report the message verbatim rather than guessing further.\n\nIf the tool fails outright, **output a highly visible warning** (e.g. **\u26A0 WARNING: The decision page could not be generated** in bold) and fall back to settling the decisions in chat, one at a time. Do not continue silently \u2014 the failure must be visible in your output.\n\n## Stage 3 \u2014 Capture the answers (stop and wait)\n\n1. **Direct the user to the page.** Give them the `file_path` and tell them to open it in their browser. Explain that they can accept a recommendation, pick another option, reject them all, or flag a card for discussion, and that they can ask you questions in chat before submitting.\n\n2. **Treat each message as a commit or a discussion turn.**\n - **Commit:** trim the message and try to parse the whole trimmed message as JSON. Treat it as a commit only when the result is an object with all three top-level fields: `ticket_key` (string), `decisions` (object), and `general_comment` (string). The first valid commit-shaped paste commits \u2014 do not over-validate the individual cards.\n - **Discussion:** anything else. Answer it, then keep waiting. If a JSON-shaped paste is missing one of the three fields, say which one rather than treating it as a freeform question.\n - **In-flight overrides:** if the user changes an answer in chat ("go with per-handler for D-1"), record it as an override. On commit, the submitted JSON is the baseline and your recorded overrides win; acknowledge each overridden card in one line.\n\n3. **Resolve every "ask" (hard rule).** After accepting a commit, find every item in `decisions` where `choice === "ask"`. For each, present the evidence and keep discussing until the user gives an explicit answer. Do not proceed while any `ask` is unresolved, and do not honor "just skip those" \u2014 an unanswered card is an unmade decision.\n\n4. **Handle "None of these".** A `choice` of `"none"` means every option you offered was wrong. Ask what the user would do instead and record their answer as the decision. Do not re-render the page for this.\n\n**You MUST stop and wait for the user here.** Do not assume answers, do not proceed on the recommendations, and do not move to Stage 4 until the user commits or explicitly declines. If they decline, say the decisions are unsettled and stop.\n\n## Stage 4 \u2014 Fold the answers back\n\n1. **Review the wider implications, then gate on a decision.** Build the review from the complete settled set: the submitted `decisions`, any in-flight overrides recorded during the conversation (these take precedence over the submission), every `"none"` answer together with the reason given for it, `general_comment`, and \u2014 where this surface tracks acceptance-criterion or NFR stances \u2014 those stances too. Do not start the review until every `ask` has an explicit recorded resolution and every in-flight override has been applied.\n\n Consider three fixed categories, regardless of whether a decision was framed as technical, user-facing, or business-oriented:\n - **Program / application** \u2014 architecture, code paths, operability, maintenance burden, and requirements imposed on other parts of the software.\n - **User** \u2014 end users, new users performing setup, operators, and developers, including prerequisites, setup friction, and additional steps.\n - **Business** \u2014 cost, adoption, support load, compliance, and reversibility.\n\n Emit only the categories with material second-order implications. For each included category, write at most four one-line bullets of about 25 words, each naming who or what is affected and how \u2014 never a restatement of the selected decision. Close with a line naming every considered category that was omitted, e.g. `Considered, nothing material: business.` \u2014 omit this closing line only when all three categories have material implications.\n\n If the review cannot be produced, report that in one line and continue without stalling the workflow or presenting the gate below.\n\n This review stays in chat: there is no document for this command to update.\n\n Then present the gate, verbatim: `Implications reviewed. Proceed, or name a decision to revisit.` Accept only a normalized `proceed`, `yes`, `y`, or `go` as a continuation token. Any other response names a decision to reopen: re-settle it in chat, record the new override, rerun the entire implications review against the changed settled set, and present the gate again.\n\n Literal `auto_approve = true` emits the review but skips this gate entirely; a missing or non-true `auto_approve` value follows the human-in-the-loop path above.\n\n2. **Restate every decision as settled**, in a short list: the question, the chosen answer, and \u2014 where the choice went against your recommendation or came from an override \u2014 one line on what changes as a result.\n\n3. **Carry `general_comment` as overarching guidance.** It applies across all the decisions, not to any one card. Say plainly how it changes the picture.\n\n4. **Name what these decisions now constrain.** One or two sentences on what is now fixed for the rest of the conversation. From here on, treat the settled answers as the contract \u2014 if later work would contradict one, say so and ask rather than quietly re-deciding.\n\nThere is no document to rewrite. The conversation is where the decisions live, unless the user asks you to record them somewhere.\n',"estimate-epic.md":"Estimate an entire Jira Epic or an explicit ticket-key group via the shared epic estimation orchestrator.\n\n$ARGUMENTS\n\n---\n\n# Instructions\n\n`$ARGUMENTS` is either a Jira Epic key (e.g. `BAPI-518`) or an explicit `--tickets` key list \u2014 never both. This command calls the `estimate_epic` MCP tool, which delegates to the Bridge API epic estimation orchestrator, and renders the structured result.\n\nIf any step fails, stop immediately and report which step failed and why, preserving the user's originally entered epic key or ticket list in the report.\n\n## Step 1 \u2014 Parse Arguments\n\n1. **Parse `$ARGUMENTS`**: Extract exactly one key-source input, plus an optional `--allow-partial` flag.\n - Split `$ARGUMENTS` on whitespace.\n - If `--tickets` appears, every token after it (up to the next flag or end of input) is the explicit ticket-key list \u2014 this is the `ticket_keys` mode.\n - Otherwise, the first token matching the Jira key pattern (`[A-Z][A-Z0-9]+-\\d+`) is the `epic_key` \u2014 this is the epic mode.\n - `--allow-partial` may appear anywhere; if present, set `allow_partial_value = true`. If absent, omit `allow_partial` entirely (do not pass `false`).\n - Never resolve both an `epic_key` and a `ticket_keys` list from the same invocation \u2014 usage is one mode or the other.\n\n2. **Validate input**:\n - Usage forms: `/estimate-epic EPIC-KEY` or `/estimate-epic --tickets KEY-1 KEY-2 ...`, plus optional `--allow-partial`.\n - If neither an `epic_key` nor a `--tickets` list can be resolved, stop immediately and report:\n ```\n Usage: /estimate-epic EPIC-KEY [--allow-partial]\n /estimate-epic --tickets KEY-1 KEY-2 ... [--allow-partial]\n ```\n - If `--tickets` is present but followed by zero keys, stop immediately and report: \"`--tickets` requires at least one ticket key.\"\n - Do not invent or pass a `mode` parameter \u2014 there isn't one; the tool infers the source from whichever of `epic_key`/`ticket_keys` is supplied.\n\n## Step 2 \u2014 Call the Tool\n\nCall the `estimate_epic` MCP tool with:\n- `epic_key`: the resolved epic key \u2014 **only** when in epic mode. Omit entirely in ticket-key mode.\n- `ticket_keys`: the resolved ticket-key list \u2014 **only** when in ticket-key mode. Omit entirely in epic mode.\n- `allow_partial`: `allow_partial_value` if `--allow-partial` was passed; omit entirely otherwise (never pass `null`, an empty string, or an empty array for any absent field).\n\nNever pass both `epic_key` and `ticket_keys` in the same call.\n\nIf the tool returns an error envelope (a JSON object with an `error` field), stop and report the error message, preserving the epic key or ticket list the user originally entered.\n\n## Step 3 \u2014 Render the Result\n\nRender the successful result as a structured report \u2014 do not dump raw JSON by default:\n\n1. **Top**: the final estimate and its scale label (`estimate_label`) as the primary heading \u2014 this is the strongest element of the report.\n2. **Immediately after the summary**: `math_source`.\n3. **Next**: resolved child ticket keys (`child_ticket_keys`) and the per-child breakdown, presented compactly.\n4. **Only if non-empty**: a compact warning section listing `failed_child_keys` and `skipped_child_keys`.\n\nKeep the happy-path report concise and scannable. Use backticks for Jira keys and technical identifiers (e.g. `BAPI-518`).\n\n> Note: this tool does not accept a `recreate` parameter \u2014 the underlying epic estimation orchestrator (BAPI-522) always reuses cached child estimates and has no recreate knob to forward to.\n\n## Final Report\n\nOn successful completion, display a structured summary per Step 3 above. On failure, display the error message returned by the tool (or the usage error from Step 1), preserving the user's originally entered epic key or ticket list.\n","explore-ticket.md":`Explore the codebase for a task, settle its acceptance criteria with the user, then propose a design that meets them.
389
389
 
390
390
  $ARGUMENTS
391
391
 
@@ -848,7 +848,7 @@ drafting.
848
848
  Requirements ratified and design proposed. Create the ticket(s) now? (y/N)
849
849
  \`\`\`
850
850
 
851
- Treat an empty response, any negative response, or any ambiguous/unrecognized response as **decline** \u2014 do not guess intent. On decline, report that the exploration doc is the artifact and point at \`/write-ticket\` for later. Never create a ticket without an explicit affirmative (\`y\` or \`yes\`) \u2014 creation is irreversible.
851
+ Treat an empty response, any negative response, or any ambiguous/unrecognized response as **decline** \u2014 do not guess intent. On decline, report that the exploration doc is the artifact, and tell the user they can later ask their agent to use the Jira Ticket Writer to create ticket drafts from it. Never create a ticket without an explicit affirmative (\`y\` or \`yes\`) \u2014 creation is irreversible.
852
852
 
853
853
  3. **Render every body through \`jira-ticket-writer\`, then create.** Nothing calls \`create_ticket\` before the gate in step 2 resolves with an explicit affirmative.
854
854
 
@@ -6081,7 +6081,7 @@ If the call fails, fix what it reports and call it again.
6081
6081
  {The recommended order in which to implement the sub-tasks, reconciling the provisional order from goals-and-nfrs.md with the approved decomposition. For each sub-task give the position, its hard prerequisites (depends on), any soft sequencing preferences (recommended after), and a one-line rationale. This is recommended sequencing only \u2014 no Jira dependency links are created.}
6082
6082
 
6083
6083
  ## Next Steps
6084
- {One-line summaries for each sub-task, specifically formatted so they can be copy-pasted directly into the \`/write-ticket\` command. Each line should be a self-contained ticket description.}
6084
+ {One-line summaries for each sub-task, specifically formatted so they can be handed directly to the Jira Ticket Writer / ticket-authoring workflow as input. Each line should be a self-contained ticket description.}
6085
6085
  \`\`\`
6086
6086
 
6087
6087
  4. After writing the overview, display the file path to the user and summarize the epic plan.
@@ -6091,7 +6091,7 @@ If the call fails, fix what it reports and call it again.
6091
6091
  ## Return
6092
6092
 
6093
6093
  Confirm the overview was written to \`{docs_dir}/epic-plans/{epic_slug}/overview.md\` and report the total sub-task count along with a one-line summary of the epic plan. State whether the goals/NFRs + recommended order were posted as a comment on \`{epic_key}\` or skipped because no epic key was provided.
6094
- `};init_version_generated();var README='# @bridge_gpt/mcp-server\n\nThe Bridge MCP is an end-to-end accelerator for shipping code within SFCC, powered by [Bridge API](https://bridgegpt-api.com). Works with Claude Code, Github Copilot, Cursor, Windsurf, and OpenAI Codex.\n\n## Install\n\nFrom your **project root**, run:\n\n```bash\nnpx -y @bridge_gpt/mcp-server install\n```\n\nThat is the whole setup command. It works whether or not you already have a Bridge\naccount \u2014 it will ask.\n\n> We recommend **the command above** instead of the `npm i @bridge_gpt/mcp-server`\n> one in npm\'s sidebar, because it **will make set up much easier**.\n\n**What it will do**\n\n- **Bootstraps the Bridge MCP for you** \u2014 one command and your editor\'s agent can\n use Bridge\'s tools and slash commands on this project.\n- Registers a `bridge` MCP server in your editor\'s MCP config, leaving any\n other servers you have configured untouched.\n- Creates and updates the files it needs inside your project root: slash commands\n and agent definitions for your editor (`.claude/commands/`, `.cursor/commands/`,\n and the equivalents your editor uses), your editor\'s MCP config, and `.bridge/`\n for your project manifest and pipeline definitions.\n- Stores your Bridge credential outside the project, so the MCP server and the\n tooling that spawns its own shells can find it without you configuring anything.\n Re-running `install` still asks for the credential unless you supply it through\n `--api-key` or `BAPI_API_KEY` \u2014 the installer writes that store, it does not read\n it back.\n- Writes outside your project root only when you pick a host whose configuration is\n global: OpenAI Codex (`~/.codex/config.toml`) and GitHub Copilot CLI\n (`~/.copilot/mcp-config.json`).\n\n**Prerequisites**\n\n- **Node.js 18 or newer** (`node --version`), which is what provides `npx`.\n- **A project directory** \u2014 run the command from the folder your editor opens: your\n repository root, the one containing `.git`. No `package.json` is required \u2014 SFCC\n cartridge repos, Python, Go, Rust, and other non-Node projects work the same way.\n- **An MCP-capable editor or CLI**: Claude Code, GitHub Copilot in VS Code, GitHub\n Copilot CLI, Cursor, Windsurf, or OpenAI Codex.\n- **No Bridge account needed.** The installer can create one for you from just an\n email address.\n\n## Contents\n\n- [Install](#install)\n- [Installation details](#installation-details)\n - [Installing, step by step](#installing-step-by-step)\n - [What to expect](#what-to-expect)\n - [Troubleshooting](#troubleshooting)\n- [Usage Documentation](#usage-documentation)\n - [Regularly useful](#regularly-useful)\n - [Occasionally useful](#occasionally-useful)\n - [Now and then](#now-and-then)\n - [Workflow commands](#workflow-commands)\n - [Operational commands](#operational-commands)\n - [Extra Capabilities](#extra-capabilities)\n- [Salesforce B2C Commerce (SFCC) Tools](#salesforce-b2c-commerce-sfcc-tools)\n- [CLI Subcommands](#cli-subcommands)\n- [Custom Pipelines](#custom-pipelines)\n- [Environment Variables](#environment-variables)\n- [Worktree credentials and the `mcp-invoke` shim](#worktree-credentials-and-the-mcp-invoke-shim)\n- [Reference](#reference)\n\nFor advanced epic/multi-agent orchestration, see [CONDUCTOR.md](./docs/CONDUCTOR.md).\n\n## Installation details\n\n### Installing, step by step\n\n**1. Open a terminal in your project root.** This matters: the installer writes\nyour slash commands and MCP config relative to the directory you run it from. If\nyou run it in your home directory, your editor will not find any of it.\n\n**2. Run the command.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server install\n```\n\n**3. Answer the sign-in question.** On a first run it asks whether you already have\na token:\n\n```\n1. Yes, I have received a token\n2. No, I need one\n```\n\n- Choose **2** if you have nothing yet. It asks for your email address and a name\n for your new Bridge project, then creates both for you.\n- Choose **1** if someone gave you a token \u2014 either a Bridge API key or an invite\n code. Paste it at the hidden prompt; you do not have to say which kind it is,\n because the installer recognizes it. Nothing is echoed as you type.\n\nThere is no default answer, so pressing Enter alone selects nothing. If you would\nrather not be asked, pass the answer up front instead \u2014 see\n[Choosing how you sign in](#choosing-how-you-sign-in).\n\n**4. Pick which editors to configure.** The installer detects the MCP hosts on your\nmachine and asks which ones to set up. Pick every editor you actually use for this\nproject; you can re-run the command later to add another.\n\n**5. Reload your MCP host.** Editors read their MCP configuration at startup, so a\nfreshly written config is not live until you reload. Restart the editor, or use its\n"reload MCP servers" action. In Claude Code you will also be asked to trust the\nproject\'s `.mcp.json` the first time.\n\n**6. Finish in the agent session the installer opens \u2014 when it opens one.** The\nlast thing the installer does is offer to open a fresh agent session running\n`/install-bridge`, which reads your codebase, fills in the remaining project\nsettings, and prints a short report of what Bridge can help with. Let it finish.\n\nThree things all have to hold for that session to open: your selection has to\ninclude a host the installer can launch, the run has to be on an interactive\nterminal, and you have to accept the consent prompt (*"Bridge can configure and set\nup this project for you automatically. Open a `<tool>` session to do that now?\n(Y/n)"*). Claude Code is the only selection that launches on its own. A\nCursor-only, Copilot, Copilot CLI, Codex, or Windsurf selection, a non-interactive\nrun, or a declined prompt all print the command to continue by hand instead. Pass\n`--agent claude` or `--agent cursor-agent` to override the decision outright.\n\n**7. Follow the next step the session shows you, if it shows one.** The installer\nasks the server what should happen next and shows that command only when there is\none to show \u2014 most often `/learn-repository`, which it recommends when the project\nstill needs its architecture, testing, review, and correctness standards documented\nand your key can run it. The installer deliberately does not run it for you. Those\nstandards are what make every later plan, critique, and review match how your\nproject actually works, and they only need to be gathered once per project \u2014 the\nresult is shared with everyone on the team. If the session shows no next step,\nthere is nothing for you to run.\n\nWant to see what would happen without changing anything? Add `--dry-run`.\n\n<details>\n<summary id="what-to-expect"><strong>What to expect</strong></summary>\n\n**Files that appear in your project**\n\n| Path | What it is | Commit it? |\n|---|---|---|\n| `.claude/commands/`, `.cursor/commands/` | The slash commands your editor runs | Yes |\n| `.claude/agents/` and editor equivalents | Agent definitions used by those commands | Yes |\n| `.bridge/config` | Your project manifest \u2014 the repository name and which MCP targets to provision. Deliberately secret-free | Yes |\n| `.bridge/pipelines/`, `.bridge/instructions/` | Editable pipeline definitions | Yes |\n| `.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json` | MCP registrations for your editor. These can carry your key, so the installer git-ignores them | No |\n\nThe installer tells you which of these are safe to commit and never recommends\ncommitting anything that can hold a credential.\n\n**Prompts you will see.** More than the sign-in question, in three groups:\n\n- *Always on a first bare interactive run:* the sign-in question, a hidden prompt\n for a token (or a visible one for an email), a project name for a brand-new\n project, a picker for which editors to configure, and an offer to connect GitHub\n (`Connect GitHub? [y/N]:`).\n- *Conditional on your situation:* a confirmation when the directory has no `.git`\n (default **No**, and declining aborts); a *"Which tool should open? [1-N]"*\n chooser when your selection contains more than one launchable tool; and the\n consent prompt before the final agent session.\n- *Overwrite confirmations, each default **No** and each skippable with `--force`:*\n a saved key for this project already exists; a host config already contains a\n `BAPI_API_KEY`; a **git-tracked** config would receive your real key; a saved but\n expired self-serve signup would be discarded.\n\n**A fresh agent session opens at the end \u2014 if your selection can launch one.** See\nstep 6 above for the three conditions. Use `--agent cursor-agent` if you want\nCursor\'s agent instead of Claude Code.\n\n**Selecting Windsurf prints instructions instead of writing config.** Windsurf\'s\nglobal `mcp_config.json` is never modified automatically; the installer reports the\nentry for you to paste yourself. Codex and Copilot CLI *are* written automatically,\neven though their files are global too.\n\n**Your key is stored for the tools that read the store.** The MCP server and the\nshell-spawned tooling (`start-tickets` and its model routing) resolve it from\n`~/.config/bridge/credentials.json` on their own. The **installer** does not: a\nrepeat `install` prompts for the credential again unless you pass `--api-key` or\nset `BAPI_API_KEY` in the environment.\n\n**A next step, when the project needs one.** The session closes with whatever\ncommand the server says comes next, and stays quiet when there is nothing to\nrecommend. `/learn-repository` is the usual one: it is recommended when the project\nstill needs its conventions documented and your key can run it. It is never\nautomatic \u2014 until someone runs it, Bridge\'s agents work from your code alone rather\nthan from your project\'s documented conventions.\n\n**Indexing happens on its own.** There is no "index my repository?" question. Once\nyour project has the settings it needs, indexing starts server-side. You never have\nto ask for it.\n\n</details>\n\n<details>\n<summary id="troubleshooting"><strong>Troubleshooting</strong></summary>\n\n**"My editor doesn\'t see any Bridge tools."** Two usual causes. First, the config\nwas written somewhere your editor is not looking \u2014 re-run the installer from the\ndirectory your editor actually opens, and check that a `bridge` entry exists in\nthat project\'s MCP config. Second, the editor has not been reloaded since the file\nwas written; restart it. In Claude Code, also confirm you accepted the trust prompt\nfor the project\'s `.mcp.json`.\n\n**"I ran it in the wrong folder."** Nothing is broken. Depending on which editors\nwere detected, a run can leave `.bridge/`, `.bridge/install-state.json`, `.claude/`,\n`.cursor/commands/`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.github/agents/`,\n`.mcp.json`, and appended `.gitignore` lines. Remove only what that run created and\nre-run the command from the right directory \u2014 if you already had a `.vscode/`,\n`.cursor/`, or `.gitignore` there, keep the parts you had before.\n\n**"It seems to hang with no output."** If you ran the bare command\n(`npx -y @bridge_gpt/mcp-server`) with no subcommand, you started the MCP *server*,\nnot the installer. It is waiting for an editor to connect over stdio, which is\nexactly what it should do when your editor launches it \u2014 but from a terminal it\nlooks like a hang. It prints a line saying so. Press Ctrl-C and run\n`npx -y @bridge_gpt/mcp-server install` instead. The explicit spelling\n`npx -y @bridge_gpt/mcp-server serve` starts the server on purpose.\n\n**"It can\'t reach Bridge" or "my key was rejected."** The installer checks\nconnectivity before it saves your **credential** anywhere, so a failure here has not\nwritten your key into a config or stored it for later. It has already\nscaffolded the project files by then \u2014 slash commands, agents, pipelines,\n`.bridge/config`, and secret-free per-host MCP placeholders \u2014 so expect those to\nexist; re-running is safe and refreshes them. A\nrejected key means the credential is not valid for that project \u2014 check the project\nname you gave, and generate a fresh key on the Bridge web UI\'s **Security** page if\nneeded. A network failure usually means a proxy or VPN is in the way.\n\n**"Which repository name should I use?"** The one registered with Bridge. If you\nhave an existing key, the installer usually resolves it for you; when it cannot, it\nasks, and `--repo <name>` answers it up front.\n\n**Still stuck? Ask the installer to diagnose itself.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server doctor\n```\n\n`doctor` is strictly read-only. It reports what it found \u2014 configs, registrations,\ncredential availability, prerequisites \u2014 and changes nothing.\n\n</details>\n\n<details>\n<summary id="choosing-how-you-sign-in"><strong>Choosing how you sign in</strong></summary>\n\nThree routes lead to the same place. The interactive question above picks one for\nyou; these flags pick it up front and skip the question entirely.\n\n**No account yet \u2014 sign up with an email.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server install --email you@example.com\n```\n\nCreates a brand-new Bridge project for that address and your first admin key in one\ncommand. No account, no key, and no invite needed beforehand. The address labels\nyour new workspace and may receive a setup message; delivery is best-effort, so\nnothing waits on it. The email is visible as you type (it is not a secret) and is\nnever written to a log. This is the same route as answering **2** at the prompt.\n\n**You were sent an invite code.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server install --invite\n```\n\nRedeems the invite, creates your project, and mints your first admin key. Run it\n*without* a value, as shown: the installer then asks for the code at a hidden\nprompt, so the code never lands in your shell history. `--invite <code>` and the\n`BAPI_INVITE` environment variable exist for scripting, but both expose the code to\nyour shell history and to the process list.\n\n**Your team already has a project and gave you an API key.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server install --api-key <key>\n```\n\nOr omit the flag and paste the key at the hidden prompt. Generate a key on the\nBridge web UI\'s **Security** page (**Create New Key**, role **Admin**) and copy it\nimmediately \u2014 it is shown once. `BAPI_API_KEY` works too.\n\nIf you paste an invite code where a key was expected, or a key where an invite was\nexpected, the installer recognizes the mismatch and tells you before anything is\ncreated or spent.\n\n</details>\n\n<details>\n<summary><strong>Installer flags</strong></summary>\n\n| Flag | What it does |\n|---|---|\n| `--email <addr>` | Sign up for a new Bridge project with just an email address |\n| `--invite [code]` | Redeem an invite code. Omit the value for the hidden prompt (recommended) |\n| `--api-key <key>` | Use an existing Bridge API key |\n| `--repo <name>` | Name the registered repository instead of resolving or asking for it |\n| `--tools <list>` | Configure specific MCP hosts without the picker. Accepted IDs are exactly `claude-code`, `cursor`, `copilot-vscode`, `copilot-cli`, `codex`, and `windsurf` (e.g. `claude-code,cursor`); any other value is a parse error |\n| `--agent claude\\|cursor-agent` | Which agent to open for the final configuration step. **No default** \u2014 without this flag the agent is derived from the hosts you selected, and an explicit value always wins, including for a host you did not select |\n| `--dry-run` | Preview every step without writing, contacting Bridge, resolving or prompting for a credential, or opening anything. Genuinely inert: it returns before the project-root prompt, before the repository is resolved, and before any tool-selection prompt, so a value it cannot know locally (an unresolved repository name, an unselected tool) is shown as **not yet known** rather than guessed |\n| `--force` | Overwrite an existing stored key without asking |\n| `-h`, `--help` | Full usage |\n\n`--email`, `--invite`, and `--api-key` are mutually exclusive \u2014 each names a\ndifferent way to arrive, and the installer will not guess between them.\n\n</details>\n\n<details>\n<summary><strong>Setting up an MCP host by hand</strong></summary>\n\nThe installer configures your editors for you. Do this only if you would rather\nwrite the config yourself, or if you use a host it cannot write automatically.\n\nScaffold the project files and write a secret-free MCP registration. Run it from\nthe same project root `install` uses \u2014 your repository root, the one containing\n`.git`. No `package.json` is required:\n\n```bash\nnpx -y @bridge_gpt/mcp-server --init\n```\n\n`--init` always creates `.mcp.json`, and adds `.vscode/mcp.json` or\n`.cursor/mcp.json` when it detects those editors. Each generated entry carries\n`BAPI_BASE_URL`, `BAPI_REPO_NAME`, `BAPI_DOCS_DIR`, and `BAPI_PROJECT_ROOT`, and\n**never** `BAPI_API_KEY` \u2014 the server resolves the credential itself at runtime.\n\nSo the manual work left after `--init` is narrower than writing an entry from\nscratch: correct `BAPI_REPO_NAME` if it was written as the `YOUR_REPO_NAME`\nplaceholder, and supply your credential through a supported source (`BAPI_API_KEY`\nin the entry\'s `env` block, `BAPI_API_KEY` in the server\'s environment, or the\n`~/.config/bridge/credentials.json` store).\n\nWrite the entry yourself instead \u2014 for a host `--init` does not touch, or because\nyou would rather \u2014 using the shapes below. Add `"serve"` as the last launcher\nargument, as shown: it is the explicit way to say "start the MCP server." Pin the\npackage to an exact version and pass `--prefer-offline`, which is what the\ngenerated entries do and what keeps npx from resolving a different build on some\nlater boot.\n\n<details>\n<summary><strong>Claude Code (.mcp.json)</strong></summary>\n\n```json\n{\n "mcpServers": {\n "bridge": {\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.44", "serve"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>VS Code / Copilot (.vscode/mcp.json)</strong></summary>\n\n```json\n{\n "servers": {\n "bridge": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.44", "serve"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>Cursor (.cursor/mcp.json)</strong></summary>\n\n```json\n{\n "mcpServers": {\n "bridge": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.44", "serve"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>GitHub Copilot CLI (~/.copilot/mcp-config.json)</strong></summary>\n\nCopilot CLI reads a single global file. The installer writes this one for you when\nyou select `copilot-cli`; the shape below is what it produces.\n\n```json\n{\n "mcpServers": {\n "bridge": {\n "type": "local",\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.44", "serve"],\n "tools": ["*"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>Windsurf (~/.codeium/windsurf/mcp_config.json)</strong></summary>\n\nWindsurf only supports global MCP configuration.\n\n```json\n{\n "mcpServers": {\n "bridge": {\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.44", "serve"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>OpenAI Codex (~/.codex/config.toml)</strong></summary>\n\n```toml\n[mcp_servers.bridge]\ncommand = "npx"\nargs = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.44", "serve"]\n\n[mcp_servers.bridge.env]\nBAPI_BASE_URL = "https://bridgegpt-api.com"\nBAPI_REPO_NAME = "your-repo"\nBAPI_API_KEY = "your-api-key"\nBAPI_DOCS_DIR = "docs/tmp"\n```\n\n> Codex users: set `BAPI_PROJECT_ROOT` manually in your config (see [Environment Variables](#environment-variables)).\n</details>\n\nAfter saving, reload your editor and ask your assistant to call the `ping` tool to\nconfirm the connection.\n\nAn entry with no trailing `serve` still starts the server \u2014 bare invocation means\n"server" permanently, and nothing rewrites an existing config to add the token.\n\n</details>\n\n<details>\n<summary><strong>Upgrading Bridge</strong></summary>\n\n```bash\nnpx -y @bridge_gpt/mcp-server@latest --upgrade\n```\n\n`upgrade` fetches the latest published version, refreshes your scaffolded slash\ncommands, agents, and pipelines, updates the version pin in your MCP config, and\nopens a session so you can reconnect. It is also available as the\n`/upgrade-bridge` slash command.\n\nUse the `@latest` form. It applies to the short-lived *upgrader* process: without\nit, npx may reuse a cached older copy of the package and "upgrade" you with the\nbuild you are trying to replace. The exact `MAJOR.MINOR.PATCH` pin the upgrader\nwrites into your MCP config is deliberately different \u2014 host configs stay pinned\nto an exact release so a project\'s server is reproducible.\n\n`upgrade` reports **per config file**, because a project can have several\n(`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`) and they can disagree:\n\n```\nLauncher pins:\n .mcp.json: 0.2.16 -> 0.2.36\n .cursor/mcp.json: already 0.2.36\n```\n\nWhen every applicable launcher pin was already at the target, it prints\n`Already up-to-date.` \u2014 that status comes from comparing your configs, not from\nthe version of the CLI process. A non-zero exit means the upgrade did **not**\nconverge, and nothing is reported as complete in that case. The causes:\n\n- the npm registry lookup failed **and** this process was not started from\n `@latest`, so the target version could not be confirmed \u2014 the likeliest one\n offline, and why the canonical command uses `@latest`;\n- a launcher pin is already **newer** than the target, which an automated repin\n must never downgrade;\n- an unreadable or unparseable config, a launcher carrying a version range or a\n dist-tag rather than an exact release, or two Bridge registrations in one file;\n- a competing local install it could not remove, or a pin that failed post-write\n verification;\n- the upgrade finished but left an **unconfigured** MCP entry \u2014 one that would\n authenticate as nobody.\n\nThe server checks for updates on startup. The check is cached for a day and never\nblocks startup. When a newer version is known, it surfaces in two places you do\nnot have to go looking for: a one-line warning on the server\'s **stderr**, and a\nshort advisory attached to the ordinary `tools/list` response so the agent in the\nsession can see that some tools may be missing or renamed in the older build.\nNeither requires calling `ping` or `doctor`.\n\nRe-running `install` on an already-configured project is safe: it refreshes the\nscaffolded files without overwriting your stored credential unless you pass\n`--force`.\n\n</details>\n\n## Usage Documentation\n\nThis is the Bridge API tooling worth knowing about as a software engineer \u2014 the things you\'d ask an agent to do \u2014 grouped by how often you would use them. Each entry covers **what it does**, **when it\'s useful**, **how to use it**, and its **flags**. The behind-the-scenes plumbing is summarized at the end under [Extra Capabilities](#extra-capabilities), and a full enumeration lives in [Reference](#reference).\n\nWorking in a Salesforce B2C Commerce codebase? Bridge also ships SFCC platform tools \u2014 read-only introspection under the `sfcc` profile, and nine destructive writes under the separate `sfcc-write` opt-in. See [Salesforce B2C Commerce (SFCC) Tools](#salesforce-b2c-commerce-sfcc-tools).\n\nFor invocation, prefer the slash command \u2014 it\'s deterministic. A free-text example is shown only where natural-language phrasing reliably maps to the right automation; high-consequence or easily-misread automations show only the slash command on purpose.\n\n<!-- The three tier sections below are GENERATED from TWO catalogs by\n scripts/sync_mcp_server_readme.py: api/library/config/mcp_tool_catalog.json,\n the authoritative MCP tool catalog, and api/library/config/workflow_catalog_lib.py,\n the immutable catalog of slash-command workflows (which have no MCP registration\n and therefore cannot live in the JSON artifact). Edit the curated tool metadata in\n scripts/sync_mcp_tool_catalog.py and the workflow definitions in\n workflow_catalog_lib.py \u2014 never the JSON artifact and never the text between the\n markers. Generation order is: sync_mcp_tool_catalog.py, then\n sync_mcp_server_readme.py, then `cd mcp_server && npm run build` (which bundles this\n file into readme.generated.ts, served as the MCP resource bridge://readme).\n Everything outside the marker pair \u2014 including the sections below it \u2014 is hand-written. -->\n\n<!-- BEGIN GENERATED: mcp-tool-documentation (managed by scripts/sync_mcp_server_readme.py \u2014 DO NOT EDIT BY HAND) -->\n### Regularly useful\n\nThe tools worth knowing for most tickets.\n\n**1. Review Ticket**\n- **What it does:** Runs a full quality review of a ticket: clarifying questions and a critique plus an alternate-model second opinion, then evaluates the findings and produces a decision page for accepting or rejecting them.\n- **When it\'s useful:** (Refinement) Right after a ticket is drafted, before anyone starts building \u2014 to surface gaps and tighten it.\n- **How to use it:** `/review-ticket BAPI-123` (command only \u2014 "review" as free text is easily mistaken for a freehand agent review). For several tickets at once, `/review-tickets BAPI-123 BAPI-456` opens one terminal tab per ticket and reviews them in parallel with no worktrees; every `/review-ticket` flag applies, and `--review KEY=auto,rounds=N` sets per-ticket overrides.\n- **Flags:** `--auto` auto-accept findings and skip the approval gates \xB7 `--rounds=1` a cheaper single-pass review that still evaluates findings and captures decisions \xB7 `--rounds=2` force the full second-opinion review \xB7 omit `--rounds` to let the difficulty-adaptive review policy decide.\n\n**2. Start Tickets**\n- **What it does:** Creates one git worktree per ticket and spawns an agent session in each to implement them in parallel.\n- **When it\'s useful:** (Implementation | Automation) When you\'re ready to start building one or more refined tickets concurrently.\n- **How to use it:** `/start-tickets BAPI-248 BAPI-250` (see [CLI Subcommands](#cli-subcommands) for the full flag table and cross-platform behavior).\n- **Flags:** `--auto` skip the approval gates \xB7 `--base-branch <branch>` branch off something other than the default \xB7 `--workflow implement|review-and-implement` selects which slash command each spawned worktree runs \xB7 `--rounds=1|2` forwarded to the review phase, valid only with `--workflow review-and-implement` \xB7 `--tier cheap|basic|premium` coarse model-routing override.\n\n**3. Review and Start**\n- **What it does:** Spawns one worktree per ticket; each session reviews the ticket inline and, after a per-ticket proceed/halt gate, hands off to a **fresh implementation session** that reuses the same worktree \u2014 review and implementation run in two separate agent contexts, not one shared session.\n- **When it\'s useful:** (Refinement | Implementation | Automation) The **recommended front door** for "review these tickets, then implement the ones that pass," starting from existing ticket keys (unlike `/full-automation`, which only accepts an idea).\n- **How to use it:** `/review-and-start BAPI-248 BAPI-250` (single or multiple keys flow through the identical code path).\n- **Flags:** `--auto` auto-approves both the review and the implementation phase of every spawned session \xB7 `--rounds=1|2` forwarded to the review phase \xB7 `--agent`, `--base-branch`, `--max-parallel`, `--dry-run` mirror `/start-tickets`.\n\n**4. Explore Ticket**\n- **What it does:** Maps the code paths, dependencies, and project conventions a task would touch, settles its acceptance criteria with you on a decision page, then compares the viable implementation approaches and their trade-offs and writes up a proposed design. Along the way it surfaces the ambiguities that still need deciding and can pull in optional web or deep research where the answer is not in the code.\n- **When it\'s useful:** (Architecture | Refinement) Before writing a ticket or a plan, when you\'re unsure how a change would fit the existing code and want the open questions and the realistic options laid out first.\n- **How to use it:** `/explore-ticket <task>` \u2014 *"Explore the codebase for how we\'d add a Mistral LLM provider and recommend 2\u20133 implementation options."*\n- **Flags:** None.\n\n**5. Council**\n- **What it does:** Fans your problem out to two different models and returns their approaches, in technical, design, discovery, or general mode.\n- **When it\'s useful:** (Architecture | Refinement) Early, when you want a spread of approaches \u2014 technical for how to build it, design for how it should look, discovery for what still needs figuring out before a real ticket exists, general for a quick brief-driven pass before the repository is indexed.\n- **How to use it:** `/council <question>`\n- **Flags:** `--mode` selects one of four modes, passed to the underlying `request_council` tool as e.g. `mode: "discovery"`: `technical` (the default \u2014 implementation/architecture approaches), `design` (UI/UX and visual direction), `discovery` (stakeholder discovery questions, grouped into `Technical Discovery Questions` and `Business / Stakeholder Discovery Questions` and tagged `[HUMAN]`/`[CODE]`/`[TICKET]`), and `general` (brief-driven ideation from your task description alone). `technical` and `discovery` are codebase-grounded and need an indexed repository; `general` needs no code index at all, so it works immediately after install. The legacy boolean `design=true` still works and maps to `mode: "design"`.\n\n**6. Deep Research**\n- **What it does:** Runs multi-source, fact-checked web research on a technical topic and returns a cited report.\n- **When it\'s useful:** (Architecture | Refinement) When a decision hinges on outside knowledge \u2014 libraries, best practices, standards \u2014 that you do not already have.\n- **How to use it:** `/bridge-research <question>`\n- **Flags:** None.\n\n### Occasionally useful\n\nGood to know, but not needed every day.\n\n**1. Upload Ticket**\n- **What it does:** Creates a real Jira issue from a drafted ticket, including child tickets under an epic; your agent should confirm with you before creating it.\n- **When it\'s useful:** (Refinement) The final step after drafting \u2014 to get the ticket into your tracker so it can be tracked and worked.\n- **How to use it:** Ask your agent to create the ticket; it should confirm with you before creating the live issue.\n- **Flags:** Name the issue type (Bug / Story / Task / Epic) and, for a child ticket under an epic, the parent key.\n\n**2. Plan Ticket**\n- **What it does:** Generates a step-by-step implementation plan for a ticket that references real files in your codebase.\n- **When it\'s useful:** (Refinement | Implementation) Once a ticket is solid and you want a concrete build plan before \u2014 or instead of \u2014 auto-implementing it.\n- **How to use it:** `/plan-ticket BAPI-123`\n- **Flags:** `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check the plan with a second provider.\n\n**3. Clarify Ticket**\n- **What it does:** Generates clarifying questions for a ticket, or debugging guidance when the ticket is a bug.\n- **When it\'s useful:** (Refinement) When a ticket feels under-specified and you want the open questions made explicit.\n- **How to use it:** `/clarify-ticket BAPI-123`\n- **Flags:** `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check with a second provider.\n\n**4. Critique Ticket**\n- **What it does:** Critiques a ticket against your project\'s standards and lists the deviations and improvements it found.\n- **When it\'s useful:** (Refinement) When you want a quality gate on a ticket before anyone works it.\n- **How to use it:** `/critique-ticket BAPI-123`\n- **Flags:** `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check with a second provider.\n\n**5. Create Doc**\n- **What it does:** Generates a design document for a ticket \u2014 a technical design document, a functional spec, or a product requirements document.\n- **When it\'s useful:** (Architecture | Refinement) When a ticket needs a fuller design write-up before planning or implementation, in the shape that fits your audience.\n- **How to use it:** `/create-doc BAPI-123 --doc-type tdd` (or `fsd` / `prd`)\n- **Flags:** `--doc-type tdd|fsd|prd` which document to generate (required) \xB7 `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check with a second provider.\n\n**6. Second Opinion**\n- **What it does:** Gets an immediate critique of any text from a different model family, without saving an artifact.\n- **When it\'s useful:** (Architecture | Refinement | Implementation) Any time you want a quick sanity check on a plan, draft, or decision from a fresh perspective.\n- **How to use it:** Ask your agent \u2014 "Get a second opinion from Gemini on whether the BAPI-123 plan\'s migration step is safe to run against production."\n- **Flags:** Pick the provider (anthropic / openai / gemini) and the tier (cheap / basic / premium).\n\n**7. Generate Image**\n- **What it does:** Generates an image from a text prompt using a provider image model, spending provider credits on every call.\n- **When it\'s useful:** (Architecture | Refinement) When you want a quick visual \u2014 a UI mockup, diagram, or illustration \u2014 to anchor a design discussion or attach to a ticket.\n- **How to use it:** Ask your agent \u2014 "Generate an image of a dashboard showing SOC2 evidence freshness as a traffic-light grid."\n- **Flags:** `provider` openai (`gpt-image-2`) / gemini (Imagen, which adds an invisible SynthID watermark) \xB7 `quality` low (default, cheapest) / medium / high \xB7 `size` 1024x1024 / 1024x1536 / 1536x1024. The image is saved to `BAPI_DOCS_DIR/images/` and also returned inline.\n\n**8. Request PRD**\n- **What it does:** Generates a product requirements document for a ticket covering the problem, the goals, and the success metrics.\n- **When it\'s useful:** (Architecture | Refinement) When a piece of work needs its problem, goals, and success metrics written down before anyone designs a solution.\n- **How to use it:** `/create-doc BAPI-123 --doc-type prd`\n- **Flags:** `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check with a second provider.\n\n**9. Full Automation**\n- **What it does:** Drives the whole chain from a raw idea through tickets and reviews to implementation sessions.\n- **When it\'s useful:** (Automation) When you want to go from a raw idea to in-progress implementation with minimal hands-on steps.\n- **How to use it:** `/full-automation <idea>` (command only \u2014 it creates tickets, spawns worktrees, and carries scheduling flags free text cannot).\n- **Flags:** `--require-approval` re-enable the approval gates; the chain runs end to end by default \xB7 `--max-children <n>` cap how many child tickets an epic decomposes into.\n\n**10. Update Ticket Description**\n- **What it does:** Rewrites a ticket\'s description with AI, using the ticket\'s own content and its reference material. A rewrite that changes more than 60% of the description is held for review instead of applied.\n- **When it\'s useful:** (Refinement) When a ticket has accumulated comments, attachments, or links and its description no longer reflects them.\n- **How to use it:** Ask your agent \u2014 "Update the description for BAPI-123."\n- **Flags:** None. Poll the ticket\'s state for the outcome; if the update was held for review, read the proposal instead of applying it blind.\n\n### Now and then\n\nUseful once in a while.\n\n**1. Reimplement Ticket**\n- **What it does:** Gathers the context and attachments added since the last pass so a targeted follow-up change can be made.\n- **When it\'s useful:** (Implementation) After review feedback or new screenshots, when you need a targeted second pass rather than a fresh build.\n- **How to use it:** `/reimplement-ticket BAPI-123`\n- **Flags:** None.\n\n**2. Update Ticket**\n- **What it does:** Rewrites a ticket\'s description, fully replacing what is there today.\n- **When it\'s useful:** (Refinement) After review, to fold the resolved questions and fixes back into the ticket itself.\n- **How to use it:** `/update-ticket BAPI-123` (command only \u2014 it fully overwrites the live description, which is hard to reverse).\n- **Flags:** None.\n\n**3. Get Ticket**\n- **What it does:** Retrieves the full details of a ticket, including its summary, status, and description.\n- **When it\'s useful:** (Refinement | Implementation) Any time you want the agent to read a ticket before acting on it.\n- **How to use it:** Ask your agent \u2014 "Pull up BAPI-123 and show me its description, status, and acceptance criteria."\n- **Flags:** None.\n\n**4. Search Tickets**\n- **What it does:** Searches across the tickets in your project.\n- **When it\'s useful:** (Refinement) When you need to find tickets by project, status, or wording rather than by key.\n- **How to use it:** Ask your agent \u2014 "Search our project for open tickets mentioning rate limiting."\n- **Flags:** Narrow the search by project, status, issue type, or free text.\n\n**5. Write Comment**\n- **What it does:** Posts a comment on a ticket.\n- **When it\'s useful:** (Refinement | Implementation) To leave context, status, or a decision trail on the ticket.\n- **How to use it:** Ask your agent \u2014 "Post a comment on BAPI-123: blocked on the expired Atlassian token \u2014 will retry after it is rotated."\n- **Flags:** A long comment can be attached as a file instead of inlined.\n\n**6. Read Comments**\n- **What it does:** Reads the comment thread on a ticket.\n- **When it\'s useful:** (Refinement | Implementation) When the discussion on a ticket matters and you want the agent to read it before acting.\n- **How to use it:** Ask your agent \u2014 "Read the comments on BAPI-123 and summarize what was decided."\n- **Flags:** None.\n\n**7. Ticket Attachments**\n- **What it does:** Downloads files from a ticket to your disk, or attaches a local file to a ticket.\n- **When it\'s useful:** (Refinement | Implementation) When a ticket has design files or logs you need locally, or you want to attach output back to it.\n- **How to use it:** Ask your agent \u2014 "Download the design mockups attached to BAPI-123 into my docs folder," or "Attach build-log.txt to BAPI-123."\n- **Flags:** Choose the direction (download from the ticket, or upload to it) and, for a download, where the files should land.\n\n**8. Estimate Ticket**\n- **What it does:** Estimates the development effort for one ticket. Use Estimate Epic instead for a whole epic or a named group of tickets.\n- **When it\'s useful:** (Refinement) When you need a size for a single ticket before committing to it.\n- **How to use it:** Ask your agent \u2014 "Estimate BAPI-123."\n- **Flags:** Ask for a fresh estimate to regenerate rather than reuse a stored one.\n\n**9. Estimate Epic**\n- **What it does:** Estimates an epic, or an explicit group of tickets you name.\n- **When it\'s useful:** (Architecture | Refinement) When you need a sizing pass across an epic, or across a set of tickets you name explicitly.\n- **How to use it:** `/estimate-epic BAPI-123`\n- **Flags:** Pass an epic key, or an explicit list of ticket keys to estimate as one group.\n<!-- END GENERATED: mcp-tool-documentation -->\n\n### Workflow commands\n\nSlash commands that drive several tools at once. Start Tickets, Review and Start, and Explore Ticket are documented above under [Regularly useful](#regularly-useful) \u2014 the rest live here.\n\n**1. Implement Ticket**\n- **What it does:** Full build for one ticket: generate a plan, write the code, commit, open a PR, and monitor CI.\n- **When it\'s useful:** (Implementation) When a ticket is ready and you want it taken from plan to open PR in one go.\n- **How to use it:** `/implement-ticket BAPI-123` (command only \u2014 "implement X" as free text almost always triggers a freehand build instead of the Bridge plan\u2192code\u2192PR\u2192CI pipeline).\n- **Flags:** `--auto` skip the approval gates (e.g. auto-commit/push).\n\n**2. Jira Ticket Writer**\n- **What it does:** An agent that drafts a well-structured Jira ticket from a plain description, applying your project\'s standards.\n- **When it\'s useful:** (Refinement) When you have an idea in your head and want a properly-formatted ticket draft without writing it by hand.\n- **How to use it:** Ask your agent, *"Use the jira ticket writer to turn our conversation into a ticket."* The other ticket commands draft through it automatically.\n- **Flags:** None \u2014 name a specific standards file in your request to have it applied when drafting.\n\n**3. Idea to Ticket**\n- **What it does:** Turns a one-line idea into a Jira Task/Spike (or an Epic plus child tickets), with research, duplicate detection, and a critique pass built in.\n- **When it\'s useful:** (Refinement | Automation) When you have a rough idea and want a fully-formed, uploaded ticket without the manual draft-and-refine loop.\n- **How to use it:** `/idea-to-ticket <idea>`\n- **Flags:** None.\n\n**4. Plan Epic**\n- **What it does:** Decomposes a large epic into sub-tasks with a structured exploration doc for each.\n- **When it\'s useful:** (Architecture | Refinement) When a feature is too big for one ticket and you need it broken down and scoped.\n- **How to use it:** `/plan-epic <epic>` \u2014 *"Decompose the epic \'migrate PayPal token storage off Custom Objects\' into sub-tasks with an exploration doc for each."*\n- **Flags:** None.\n\n#### Ticket-authoring posture\n\n`/explore-ticket`, `/idea-to-ticket`, and `/plan-epic` all decide ticket shape\nthe same way, as does the `jira-ticket-writer` agent they draft through. A fresh install inherits this with no configuration\nstep and no server call; the full rationale and the closed exception list ship as\n`docs/bridge-ticket-authoring.md`.\n\n- **Drafted by the writer.** Every ticket body \u2014 epic parent, epic child, and\n ordinary sibling alike \u2014 goes through the `jira-ticket-writer` agent. Nothing\n composes a ticket description inline.\n- **Sized toward L, overflowing upward.** `L` (target) \u2192 `XL` (when the work\n does not fit in `L`) \u2192 `M` (third choice) \u2192 `S` (only when unavoidable). A\n slice that outgrows `L` becomes one `XL` ticket rather than two `L` ones \u2014\n splitting a coherent slice to fit a band buys another worktree, another PR, and\n another rebase for nothing. This binds a standalone ticket and an epic child\n alike. Past roughly 40 files or ~3000 LOC it splits anyway, into the largest\n coherent pieces available.\n- **Grouped at three.** Three or more tickets is an epic: an epic parent plus an\n ordered child manifest, shown in full at an approval gate before anything is\n created. One or two are ordinary siblings \u2014 no epic parent, no manifest. The\n threshold is exactly three.\n- **Decomposed once, rendered many.** One pass freezes the split; body drafting\n then fans out one writer invocation per entry against that frozen manifest. A\n rendering invocation never re-splits, merges, reorders, or rescopes.\n- **Handed off once.** An epic handoff names exactly one entry point,\n [`drive-epic`](#drive-epic) \u2014 never a choice between conductors.\n\n**5. Run Tests**\n- **What it does:** Runs the unit and E2E suites and autonomously triages/fixes failures (via the test-correction agent).\n- **When it\'s useful:** (Implementation) After making changes, to confirm everything passes and auto-fix straightforward breakages.\n- **How to use it:** `/run-tests`\n- **Flags:** `--unit-only` skip the E2E suite \xB7 `--skip-e2e` same, phrased the other way.\n\n**6. Learn Repository**\n- **What it does:** Researches and documents the repo\'s architecture, testing, review, and correctness standards, then saves them to Bridge for future agents.\n- **When it\'s useful:** (Setup/Learning) When onboarding a new repo, or after big changes, so Bridge\'s agents follow your conventions.\n- **How to use it:** `/learn-repository`\n- **Flags:** None.\n\n**7. Teach Bridge**\n- **What it does:** Takes a plain-English instruction, figures out which standards field it belongs to, and merges it in (admin only).\n- **When it\'s useful:** (Setup/Learning) When you notice the agents missing a convention and want to correct it in one sentence.\n- **How to use it:** `/teach-bridge <teaching>` \u2014 *"Teach Bridge: always use data-testid selectors in E2E tests."*\n- **Flags:** None.\n\n### Operational commands\n\nWorkflow commands you\'ll reach for during implementation and CI, beyond the tiers above:\n\n| Command | What it does |\n|---|---|\n| `/code-ticket PROJ-123` | Download the implementation plan and questions, then execute the plan inline |\n| `/commit-ticket PROJ-123` | Stage, commit, and push changes; transition Jira status; post a smoke-test comment |\n| `/create-pr PROJ-123` | Resolve the base branch and open a pull request for the ticket\'s branch (run after `/commit-ticket`) |\n| `/check-ci PROJ-123` | Monitor CI checks for the current branch, triage failures, apply fixes, and report results |\n| `/parse-repository` | Queue a background job to index the repository for Bridge AI agents |\n| `/check-parse-status` | Check whether a background repository parse job is still running |\n| `/scan-tickets` | Sync recently-updated Jira tickets and backfill workflow timestamps |\n\n> Commands are designed for Claude Code. Other editors may support slash commands differently \u2014 check your editor\'s documentation for how to invoke prompt files.\n\n### Extra Capabilities\n\nBehind-the-scenes capabilities an agent gains from the MCP tools \u2014 mostly invoked automatically by the commands above, rarely requested by name:\n\n- **Ship a PR end-to-end:** commit & push, open a pull request, transition the Jira status, and discover/poll CI checks (powers `commit-ticket`, `create-pr`, `check-ci`, `implement-ticket`).\n- **Architecture plan** for a ticket (design-level guidance, separate from the implementation plan).\n- **Index the codebase** so Bridge\'s agents can reason about it: queue/parse the repo, check parse status, regenerate the directory map.\n- **Read & tune project config/standards:** list/read/update config fields, fetch project standards, and the per-topic `learn-*` commands that populate them.\n- **Ticket lifecycle bookkeeping:** track tickets and backfill workflow-state timestamps (`scan-tickets`), search across tickets, read comments, list attachments.\n- **Pipeline machinery:** list/inspect pipeline recipes, run/resume/list/delete pipeline runs (the engine under the orchestration commands), and resume a full-automation chain that stopped at an approval gate or was interrupted.\n- **Decision page** generation for capturing human review decisions as structured data.\n- **Connectivity & identity checks:** ping Bridge, check your role, resolve the local docs directory.\n- **Retrieve any generated artifact** (`get_*` for plans, critiques, questions, councils, research, architecture) without regenerating it.\n- **Tiered-section execution telemetry** recording (internal measurement).\n\n## Salesforce B2C Commerce (SFCC) Tools\n\nSalesforce\'s official MCP server, `@salesforce/b2c-dx-mcp`, covers developer-experience tasks \u2014 cartridge deploy, step debugging, MRT bundle push, and SCAPI Custom API scaffolding. **As of `@salesforce/b2c-dx-mcp` 1.1.2 (published 2026-05-20)** it has no way to *read* the platform\'s object model, custom objects, or site configuration \u2014 exactly the context an AI coding agent needs to write correct Commerce code and to inspect a sandbox before touching it. That comparison is dated on purpose: its basis is this repository\'s hand-maintained [vendor manual](../docs/mcp/b2c-commerce-developer.md), pinned to the same version, so a new Salesforce toolset ages the claim visibly instead of rotting silently. Bridge\'s SFCC tools install side-by-side with `b2c-dx-mcp` (they don\'t duplicate its surface) and close that gap with **OCAPI Data API introspection** of system objects, custom object definitions, and site preferences \u2014 plus, behind a separate opt-in, a set of sandbox-bounded writes.\n\n**Every SFCC tool is restricted to a developer sandbox, and the restriction is checked at invocation time against the hostname your credentials actually resolve to** \u2014 not against anything the caller passes in. If `dw.json` or `SFCC_HOSTNAME` names a host Bridge does not recognize as a developer sandbox, every SFCC tool refuses with a `403` before contacting it. See [Sandbox enforcement](#sandbox-enforcement).\n\n**Credentials stay local** \u2014 in `dw.json` or `SFCC_*` env vars \u2014 and are never sent to Bridge. The `sfcc` profile registers read-only tools; the nine destructive write tools require the separate `sfcc-write` opt-in (see [Read and write profiles](#read-and-write-profiles)).\n\nFor a step-by-step OCAPI client setup guide (including the Business Manager permissions grant), see [docs/install/sfcc-integration.md](./docs/install/sfcc-integration.md).\n\n<details>\n<summary><strong>Setup</strong></summary>\n\nThe two diagnostic tools (`sfcc_setup_status`, `check_permissions`) are always available. The read tools, the write tools, and `sfcc_log_query` must be enabled with a profile (step 3). Changing `BRIDGE_MCP_PROFILE` requires an MCP client restart.\n\n**Prerequisites:** a running SFCC **sandbox** and its hostname, plus an Account Manager API client (`client-id` + `client-secret`).\n\n**1. Set the repo `version` config field** to your SFCC project type \u2014 one of `sfra | pwakit | sitegenesis | storefrontnext | hybrid`. The call-time gate reads this; a non-SFCC value blocks the OCAPI tools \u2014 the eight reads, the nine writes, and `check_permissions`. It does **not** block `sfcc_setup_status`, and it does not block `sfcc_log_query`: log query runs on its own gate, which reads neither the `version` field nor `dw.json` and instead probes the backend log capability (log access is WebDAV Basic auth, a different boundary from OCAPI\'s OAuth). Set the field via your normal config path, the `config_field` MCP tool (operation `update`, field `version`), or the `/teach-bridge` skill.\n\n**2. Provide SFCC credentials.** Create a `dw.json` in your project root:\n\n```json\n{\n "hostname": "zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com",\n "client-id": "<account-manager-client-id>",\n "client-secret": "<account-manager-client-secret>"\n}\n```\n\nAccepted key spellings: `hostname`/`host`, `client-id`/`clientId`/`client_id`, `client-secret`/`clientSecret`/`client_secret`. `dw.json` is auto-added to git exclude and must never be committed.\n\nCredentials resolve in **three tiers, highest first** \u2014 the environment wins over `dw.json`, not the other way round:\n\n1. An explicit dotted `instance` argument on the call, **plus** `SFCC_CLIENT_ID` and `SFCC_CLIENT_SECRET` in the environment. Secrets are never read from `dw.json` on this tier, so an explicit instance without those two env values is an error.\n2. `SFCC_HOSTNAME` **and** `SFCC_CLIENT_ID` **and** `SFCC_CLIENT_SECRET`, all three set.\n3. `dw.json`.\n\nBecause tier 2 outranks tier 3, a stale `SFCC_HOSTNAME` left in the environment silently wins over the `dw.json` you are looking at. Check both when a tool reports an unexpected host.\n\n**Use a single-config `dw.json`, or set all three `SFCC_*` variables.** A multi-entry `configs[]` array is **rejected outright** \u2014 it is not a working setup that merely requires an explicit `instance` on every call. Two things make that workaround unavailable: an explicit `instance` takes tier 1, which needs the client id and secret in the environment anyway, and most tools cannot accept a hostname at all \u2014 the value must contain a dot, and the site-preference tools constrain `instance` to `staging | development | sandbox | production`, none of which is a hostname.\n\n**3. Enable the tools you want.** Add the groups to `BRIDGE_MCP_PROFILE` in the MCP server `env` block (it is comma-separated), then **restart the MCP client**:\n\n```json\n"env": { "BRIDGE_MCP_PROFILE": "sfcc" }\n```\n\n`sfcc` gives the eight read tools plus `sfcc_log_query`. For the nine destructive write tools as well, use `"sfcc,sfcc-write"`; `full` expands to every group and is therefore write-capable. Without any of these, only the diagnostic tools are registered.\n\n**4. Verify and grant OCAPI access.** Ask your agent to run `sfcc_setup_status` (expect all checks \u2713), then `check_permissions`. If it reports HTTP 401/403, it prints the exact OCAPI Settings JSON to paste in Business Manager (**Administration \u2192 Site Development \u2192 Open Commerce API Settings \u2192 Data API** tab); save it there and re-run. Restart the MCP client after any credential or env change \u2014 a running session does not pick them up.\n\nRead what it prints before pasting it. The output is **two labelled blocks**, and they are not equivalent:\n\n- **READ/SEARCH TOOL GRANTS** \u2014 what the `sfcc` read tools need: `get` on `/system_object_definitions`, and `get` + `post` on `/system_object_definitions/**`, `/site_preferences/**`, and `/custom_object_definitions/**`. The `post` is OCAPI\'s convention for its `*_search` endpoints, not a mutation \u2014 but it is a grant you are pasting, so it is labelled for what it is rather than as "read-only".\n- **MUTATION GRANTS** \u2014 required by the nine `sfcc-write` tools and by nothing else: `put`/`patch` on `/system_object_definitions/**` and `/custom_object_definitions/**`, and `patch` on `/site_preferences/**`. Paste this block only if you intend to enable `sfcc-write`.\n\nNeither block grants `delete`, and neither pastes the global `resource_id: "/**"` that would cover every Data API resource. Each entry names one resource family \u2014 `/system_object_definitions`, `/custom_object_definitions/**`, `/site_preferences/**` \u2014 so the wildcard is scoped to the family, not to the API. Within a family it is still broad, and `write_attributes` is `(**)`, so a throwaway sandbox is the right place for these.\n\n</details>\n\n### Sandbox enforcement\n\nEvery SFCC tool \u2014 all twenty, reads and writes alike, including the diagnostics \u2014\npasses through one check before its own logic runs: **the hostname your\ncredentials actually resolve to must be a recognized developer sandbox.**\n\n- The check reads `credentials.hostname`, the value that goes into the OCAPI\n URL. It does not read the `instance` tool argument. Omitting `instance`, or\n passing `instance: "sandbox"`, has no effect on the decision \u2014 neither one\n selects or proves anything about the target. A dotted `instance` still\n *selects* a host through the documented credential precedence, but the host it\n selects is then validated like any other, so `check_permissions` cannot be\n aimed at a named production instance.\n- It **fails closed.** An unrecognized, malformed, or unparseable hostname is\n refused with HTTP `403`, `error.code: "TARGET_NOT_SANDBOX"`, and\n `error.details.failure_class: "target-not-sandbox"`, before any request leaves\n your machine.\n\nThe accepted hostname forms are:\n\n| Form | Example |\n|---|---|\n| `<realm>-<nnn>.sandbox.<region>.dx.commercecloud.salesforce.com` | `zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com` |\n| `<realm>-<nnn>.sandbox.dx.commercecloud.salesforce.com` | `zzzz-001.sandbox.dx.commercecloud.salesforce.com` |\n| `<realm>-<nnn>.dx.commercecloud.salesforce.com` | `zyis-001.dx.commercecloud.salesforce.com` |\n\nAnything else is refused. In particular, hosts whose leading label names an\nenvironment (`production-\u2026`, `staging-\u2026`, `development-\u2026`) are rejected even\nwhen they otherwise fit a form above, and the legacy `*.demandware.net` domain\nis not accepted at all \u2014 sandbox, staging, and production instances share that\ndomain with no suffix that separates them.\n\n### Read and write profiles\n\n`sfcc` and `sfcc-write` are **independent** groups. Neither implies the other.\n\n| `BRIDGE_MCP_PROFILE` | SFCC tools registered |\n|---|---|\n| unset / `core` | `sfcc_setup_status`, `check_permissions` only |\n| `sfcc` | the above + 8 OCAPI read tools + `sfcc_log_query` |\n| `sfcc-write` | the above diagnostics + the 9 destructive write tools |\n| `sfcc,sfcc-write` | all 20 |\n| `full` | all 20 \u2014 `full` includes `sfcc-write` and is therefore write-capable |\n\n**Migration.** Enabling `sfcc` used to register the nine write tools as well. It\nno longer does. If you were relying on SFCC writes through\n`BRIDGE_MCP_PROFILE=sfcc`, change it to `BRIDGE_MCP_PROFILE=sfcc,sfcc-write`.\nUsers of `BRIDGE_MCP_PROFILE=full` keep write access and need no change.\n\n### Tools\n\nTwenty tools in total: two always-on diagnostics, the `sfcc` profile\'s **read-only** surface (eight OCAPI reads plus `sfcc_log_query`), and the nine destructive writes that only the separate `sfcc-write` profile registers \u2014 see [Read and write profiles](#read-and-write-profiles). Every one of them is bounded to a developer sandbox by the same invocation-time check. All twenty are enumerated below.\n\nAn oversized response is saved in full to `BAPI_DOCS_DIR/sfcc/` and replaced by a parseable JSON descriptor \u2014 `truncated: true`, the `saved_path` it was written to, and the `page` metadata (`returned`, `total` when OCAPI supplied one, `has_more`) \u2014 so the collection metadata survives even though the data itself is on disk. If that save fails, the complete payload is returned inline instead, still as parseable JSON.\n\nAttribute-definition reads and writes can return an attribute\'s `default_value` at `projection: "full"`, and Bridge withholds it \u2014 every key is preserved except that one, whose value becomes `[REDACTED_BY_BRIDGE]` \u2014 from the inline response, the saved file, and a successful write echo alike. Attribute defaults are intentionally unavailable through this MCP surface; Business Manager is the supported path to read one.\n\n**Diagnostics** (always available, no profile needed)\n- `sfcc_setup_status` \u2014 report on every prerequisite: Bridge API key, repo name, `version` config, `dw.json` presence/uniqueness, AM (OCAPI) token acquisition, and the independent **SFCC Log Query (WebDAV)** capability that gates `sfcc_log_query`.\n- `check_permissions` \u2014 probe OCAPI access via `GET /system_object_definitions`; on 401/403, print the exact OCAPI Settings JSON to paste in Business Manager (read/search grants for the `sfcc` tools, mutation grants for the `sfcc-write` tools). An explicit `instance` hostname is still subject to the sandbox check below.\n\n**System object model \u2014 reads** (needs the `sfcc` profile)\n- `system_object_list` \u2014 list system object types (Product, Order, Customer, \u2026).\n- `system_object_get` \u2014 fetch one system object type\'s definition.\n- `system_object_attribute_search` \u2014 search a type\'s attribute definitions; prefer this over a full dump when hunting a specific `c_` custom attribute.\n\n**System object model \u2014 writes** (needs the `sfcc-write` profile; every one is a destructive write, sandbox only)\n- `system_object_attribute_definition_create` \u2014 create an attribute definition via `PUT /system_object_definitions/{type}/attribute_definitions/{id}`.\n- `system_object_attribute_definition_update` \u2014 update one via `PATCH` on the same path.\n- `system_object_attribute_group_create` \u2014 create an attribute group via `PUT /system_object_definitions/{type}/attribute_groups/{id}`.\n- `system_object_attribute_group_update` \u2014 update one via `PATCH` on the same path.\n- `system_object_attribute_assign_to_group` \u2014 assign an existing attribute definition into a group via `PUT \u2026/attribute_groups/{group}/attribute_definitions/{def}`.\n- `custom_preference_definition_create` \u2014 define a custom site or organization preference via `PUT /system_object_definitions/{SitePreferences|OrganizationPreferences}/attribute_definitions/{id}`.\n\n**Custom object definitions** (reads need `sfcc`; the two writes need `sfcc-write`)\n- `custom_object_definition_attributes_get` \u2014 fetch attribute definitions for a known custom object type (`default_value` withheld). OCAPI cannot enumerate custom object type *IDs* directly, so `object_type` must be known \u2014 but it is discoverable: call `system_object_list` at `projection: "full"` for each custom type\'s `display_name` and `attribute_definition_count`, derive a candidate id (e.g. strip spaces from `"Product Quality Result"` \u2192 `ProductQualityResult`), and confirm it by checking that this tool\'s returned attribute count matches that row\'s `attribute_definition_count`.\n- `custom_object_definition_attribute_search` \u2014 search attribute definitions within a known custom object type (`default_value` withheld). Read-only \u2014 creating a custom object *type* isn\'t possible via OCAPI; that\'s a future v2 metadata-import capability. Same discovery path as above applies to `object_type`.\n- `custom_object_definition_attribute_create` \u2014 **write** (sandbox only): create an attribute definition on a known custom object type via `PUT /custom_object_definitions/{type}/attribute_definitions/{id}`. TYPE creation is never attempted (the type must pre-exist). Echoes paste-ready OCAPI grant JSON on 403.\n- `custom_object_definition_attribute_update` \u2014 **write** (sandbox only): update an attribute definition via an ETag-conditional `PATCH \u2026/attribute_definitions/{id}`; surfaces 409/412 conflicts and echoes grant JSON on 403.\n\n**Site preferences** (reads need `sfcc`; the write needs `sfcc-write`; sandbox only)\n- `site_preference_group_list` \u2014 list the preference groups on a site. This is the discovery tool the other two reads depend on: both take a group, and this is how you find one.\n- `site_preference_get` \u2014 list the preference **identifiers** in a group.\n- `site_preference_search` \u2014 search/filter preference identifiers within a group.\n- `site_preference_values_set` \u2014 **write** (sandbox only): set custom preference values via `PATCH /site_preferences/preference_groups/{group}/sandbox` with a flat map of `c_`-prefixed ids to string/number/boolean/string[] values. A bad group returns 404 `CustomPreferenceGroupNotFoundException`; echoes grant JSON on 403.\n\n> **Site preference values are write-only through this surface.** `site_preference_get` and `site_preference_search` return **ids only, never values** \u2014 an unset preference and one set to the empty string are indistinguishable. So you can *set* a value with `site_preference_values_set` and have no way to read it back through an MCP tool. Business Manager is the supported path to read a preference value.\n\n**On-demand log query** (needs the `sfcc` profile)\n- `sfcc_log_query` \u2014 query redacted, filtered SFCC logs on demand. `environment` and `time_range` (`start`/`end`) are **required** \u2014 production, "all environments", and an open-ended period are never inferred. The tool calls a Bridge backend endpoint that runs the pull \u2192 redaction \u2192 filter pipeline server-side and returns scoped, redacted findings; **WebDAV credentials, retrieval, redaction, and filtering all stay server-side and single-sourced.** It holds no credentials of its own.\n - **Guardrails.** Selection is bounded by log-file `prefixes` (max 5), the time range, a scanned-entry cap (`max_entries`, \u2264 2000), a finding cap, and a per-snippet length cap. High-volume prefix classes (`info`, `jobs`, `debug`, `customdebug`) impose a **stricter 6-hour** max range (vs. 24h for the error class) because `info-*` runs ~1 MB/day versus `error-*` at ~13 KB median \u2014 a wide window over a high-volume prefix is **rejected**, never silently narrowed.\n - **Response order.** Resolved scope (`environment`, `time_range`, `applied_prefixes`) and cap `status` first, redacted `findings` second, retrieval/truncation `metadata` last.\n - **Statuses & errors.** `ready`, `no_matching_findings`, `results_truncated`; plus `VALIDATION_ERROR` (bad/oversized scope, caught before any network call), `NOT_CONFIGURED` (503 \u2014 the log capability isn\'t set up; run `sfcc_setup_status`, whose step 6 reports it), and `BAD_GATEWAY`/`SERVICE_UNAVAILABLE` on a retrieval/backend failure.\n - **Auth is separate from OCAPI.** Log retrieval uses **HTTP Basic auth** \u2014 a Business Manager username + a **40-character WebDAV access key** \u2014 *not* the OCAPI Account Manager OAuth token the other SFCC tools use. A valid AM bearer token 401s on `/Logs`. `sfcc_setup_status` step 5 (AM/OCAPI token) and step 6 (WebDAV log access) are independent: one can be green while the other is not.\n - **Local / air-gapped fallback.** The primary path above is the only path this tool takes. For air-gapped development, the documented fallback is Salesforce\'s own **`@salesforce/b2c-cli`**, shelled out to directly: `b2c logs get --since <window> --search <q> --json`. There is no MCP alternative to reach for \u2014 `@salesforce/b2c-dx-mcp` ships **no `logs_*` tool** as of 1.1.2, and every log workflow in the vendor toolkit goes through the CLI anyway (see the [vendor manual](../docs/mcp/b2c-commerce-developer.md)). It is **not** the primary path because its credentials live client-side and its output has **not** passed Bridge\'s redaction/filter. If you use it, its output must be treated as raw: route it back through the same server-side Python `LogSource` composition and `RedactionPort`/T3 filter workflow \u2014 never paste or relay unredacted CLI output to an LLM.\n\n## CLI Subcommands\n\nBeyond `--init` / `--upgrade`, the package ships operational subcommands of the **single `bridge-api-mcp-server` bin** (not separate binaries) \u2014 so they travel with the package to every consumer. See [Usage Documentation \u2192 Start Tickets](#regularly-useful) for *when* to use `start-tickets`; this section is the full CLI reference.\n\n### `start-tickets`\n\nSpawns one Worktrunk worktree + selected-agent session per Jira ticket and backs the `/start-tickets` slash command. The agent defaults to **Claude Code** (`claude`) and is configurable via `--agent`. For existing ticket keys, `/review-and-start` (see [Usage Documentation \u2192 Review and Start](#regularly-useful)) is the recommended enriched front door over this CLI\'s `--workflow review-and-implement` seam; using the CLI directly (below) remains the advanced/lower-level path.\n\n```\nnpx -y @bridge_gpt/mcp-server start-tickets [flags] KEY [KEY ...]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--agent claude\\|cursor-agent` | `claude` | Agent command to launch in each worktree |\n| `--workflow implement\\|review-and-implement` | `implement` | Slash command each spawned worktree runs. `implement` is byte-identical to today\'s `/implement-ticket <KEY> [--auto]`. `review-and-implement` spawns `/review-and-implement <KEY> [--auto] [--rounds=<n>]`, which runs `/review-ticket` then, after a per-ticket halt gate, `/implement-ticket` in the same session. `--auto` applies to the selected workflow as a whole. |\n| `--rounds 1\\|2` | unset | Review round count forwarded to the review phase. Review-only \u2014 valid only with `--workflow review-and-implement`. |\n| `--tier cheap\\|basic\\|premium` | unset (difficulty routing) | Coarse model-routing override. Bypasses **only** the per-ticket difficulty/tier lookup (`GET /jira/tickets/{KEY}/model-tier`) and applies this one tier to every ticket; the tier is still mapped to a model through the centralized agent registry and any configured `difficulty_model_tier_overrides`, then validated. It is **not** a raw `--model` alias and never carries an API key or credential. A malformed value fails open to premium routing. Used by the `/review-and-implement` handoff to reuse the review-time tier. |\n| `--terminal terminal\\|iterm` | auto-detect via `$TERM_PROGRAM` | Override the macOS terminal app (honored on macOS only) |\n| `--dry-run` | off | Print intended actions; create no worktrees, open no tabs (any OS) |\n| `--branch KEY=BRANCH` | `feature/<KEY>` | Use a custom branch for that ticket (repeatable) |\n| `--base-branch <BRANCH>` | `main` | Cut new worktrees from `<BRANCH>` and refresh `origin/<BRANCH>` instead of `main` |\n| `--no-refresh-main` | off (the configured base branch is refreshed) | Skip refresh of the configured base branch (default `main`). Historical flag name preserved for backward compatibility \u2014 despite the name, it now skips refresh of whatever `--base-branch` resolves to. |\n| `--max-parallel N` | `3` | Max worktrees created concurrently |\n| `--conductor` | off | Opt into the Conductor system (per-worker `BAPI_CONDUCTOR_*` env + Claude hook injection, a supervisor peer tab, and the `check_messages` message-relay prompt). **Default off** \u2014 a plain run spawns `cd <worktree> && <agent> \'/implement-ticket <KEY>\'`. |\n| `-h`, `--help` | \u2014 | Show usage |\n\nEach `KEY` must match `[A-Z]+-[0-9]+` (e.g., `BAPI-248`). The CLI creates/switches each worktree up front (throttled by `--max-parallel`), then opens one tab/session per successful worktree running the selected agent\'s `\'/implement-ticket <KEY>\'` \u2014 `claude \'/implement-ticket <KEY>\'` by default, or `cursor-agent \'/implement-ticket <KEY>\'` with `--agent cursor-agent`. The `/implement-ticket <KEY>` prompt is unchanged for both agents. To launch Cursor Agent instead of Claude Code:\n\n```\nnpx -y @bridge_gpt/mcp-server start-tickets --agent cursor-agent BAPI-248\n```\n\n**Difficulty-based model routing.** Before launching each agent, the CLI selects an implementation **model tier** from the ticket\'s `difficulty` (1-2 \u2192 cheap, 3-5 \u2192 basic, 6+ \u2192 premium) and injects it as a `--model` flag at the spawn boundary. The Python backend returns only the coarse tier (`GET /jira/tickets/{KEY}/model-tier`, computing + caching difficulty on demand); this CLI alone maps a tier to the agent-specific alias (`claude`: `haiku`/`sonnet`/`opus`; `cursor-agent`: version-suffixed strings validated against `cursor-agent --list-models`). It is gated per repo by `difficulty_model_routing_enabled` (default **ON**) with an optional `difficulty_model_tier_overrides` JSON map (tier \u2192 alias). Routing is **fail-open**: missing credentials, an evaluation failure/timeout, a backend `fallback`, an invalid/unavailable alias, an unadvertised Cursor model, or an agent without `--model` support all default to the **premium** (Opus) model \u2014 and when even the premium alias cannot be resolved/validated for the agent, `--model` is omitted (the agent uses its default) \u2014 each surfaced as a per-ticket warning rather than failing the spawn. `--dry-run` does **not** create worktrees or open tabs, but it **does** resolve routing read-only to preview the `--model` each tab would use.\n\n**Coarse `--tier` override.** Passing `--tier cheap|basic|premium` bypasses **only** the per-ticket difficulty lookup above and applies that one tier to every ticket; the tier is still resolved to an alias through the same agent registry + `difficulty_model_tier_overrides` and validated the same way (including the live `cursor-agent --list-models` check). It is never treated as a raw `--model` alias, and no API key or credential belongs in the spawned command (the CLI resolves credentials itself via `resolveBapiCredentials`). A malformed/unrecognized `--tier` value is **fail-open**: the CLI logs one concise warning and routes every ticket on the premium (Opus) fallback rather than aborting. `/review-and-implement` uses this flag to hand its review-time tier snapshot to the fresh implementation session.\n\n**Conductor observability (opt-in via `--conductor`, BAPI-394).** Conductor is **off by default**. With `--conductor`, a run mints a conductor `run_id`, emits events into the local ledger (`~/.config/bridge/events.db`), opens a supervisor peer tab, and (for Claude Code) injects a secret-free lifecycle hook into each worktree; it also sets `BRIDGE_MCP_PROFILE=conductor` so workers get the conductor MCP tools. Observability is best-effort \u2014 a conductor failure never blocks a spawn, and `--dry-run` performs no conductor side effects. (Epic-tick dispatch always enables conductor internally.) See **[CONDUCTOR.md](./docs/CONDUCTOR.md)** for the full observability, git-hook, done-gate, and auto-merge reference.\n\n**Cross-platform spawning.** The CLI routes spawning per platform; `--dry-run` previews the platform-correct command form on any OS. An unsupported `process.platform` (not `darwin`/`win32`/`linux`) fails fast with a clear "unsupported platform" message.\n\n- **macOS** \u2014 opens a Terminal.app or iTerm tab via `osascript`.\n- **Windows** \u2014 creates worktrees with **`git-wt`** (Worktrunk\'s winget alias) and opens a tab via **Windows Terminal (`wt.exe new-tab`)**, falling back to **`Start-Process powershell.exe`** when Windows Terminal is absent. Requires **Git for Windows / Git Bash** (Worktrunk runs its `pre-start` / `post-start` hooks via Git Bash). The Worktrunk binary (`git-wt`) and the tab launcher (`wt.exe`) are resolved independently and never conflated.\n- **Linux** \u2014 creates one detached **tmux** session per ticket (pane kept open after the agent exits); attach with `tmux attach -t <session>`. A missing `tmux` produces a clear, actionable error.\n\nPer-OS prerequisites: macOS `wt`, `git`, `osascript`; Windows `git-wt`, Git for Windows / Git Bash, Windows Terminal or PowerShell; Linux `wt`, `git`, `tmux`. Set `BAPI_WORKTRUNK_BIN` to override the Worktrunk executable name/path for nonstandard installs (`doctor` honors it too). The read-only `doctor` subcommand (below) additionally surfaces a missing `uv` \u2014 Worktrunk\'s `pre-start` hook runs `uv`, but live preflight does not check it \u2014 and the selected agent\'s command; run `doctor --agent cursor-agent` to also check `cursor-agent` (it prints `cursor-agent login` as an informational auth reminder).\n\n### `doctor`\n\nThe package also ships a strictly **read-only** `doctor` subcommand \u2014 titled **`bridge doctor \u2014 read-only diagnostics`** \u2014 that diagnoses your whole Bridge install without changing anything:\n\n```\nnpx -y @bridge_gpt/mcp-server doctor [--agent <name>]\n```\n\nThe report always leads with the advisory **`Install status`** section (repo identity, credential resolution, server connectivity, bootstrap-field completeness, integration credentials, indexing state) **before** the `start-tickets` prerequisite diagnostics; the launcher-cache and MCP tool-surface sections follow. `Install status` is read-only GETs only and never affects the exit code.\n\nThe report also includes a **Claude login** advisory: whether the host\'s own\n`~/.claude.json` carries a login marker. This is informational only \u2014 it never\nblocks the doctor run and cannot guarantee the next worker spawn will\nauthenticate. See\n[Claude login for conductor workers](#claude-login-for-conductor-workers).\n\nIt is **read-only**: it never installs anything, modifies your system, adds an npm `postinstall`, spawns a terminal, or starts the MCP server, and there is no `--fix`. For each prerequisite it prints found/missing and, when missing, the exact per-OS install command **as a manual instruction you run yourself**. The checked set is the `start-tickets` preflight prerequisites **plus `uv`** **plus the selected agent\'s command** (`claude` by default, or `cursor-agent` with `--agent cursor-agent`). The Worktrunk binary is probed via the resolved name (honoring `BAPI_WORKTRUNK_BIN`), not a hard-coded one. **Exit code:** `0` when all required prerequisites are present, non-zero when any is missing or the platform is unsupported. A failing `start-tickets` preflight now hints you to run `doctor` for an actionable diagnostics report.\n\n### `drive-epic`\n\nThe one conductor entry point every Bridge surface names. Give it an epic key and\nit reads conductor readiness for your repository and routes to the single path\nyour project can actually run:\n\n```\nnpx -y @bridge_gpt/mcp-server drive-epic <EPIC>\n```\n\nYou are never asked to choose. Bridge currently has two conductors and a standing\nrule that they must never operate on the same epic \u2014 two transition authorities on\none epic wedge it permanently \u2014 so the choice is made structurally rather than by\njudgement. Readiness green routes to the v2 bootstrap below (pass `--plan-file`\nand `drive-epic` runs it for you); readiness not green prints the interactive\npilot instruction instead. If readiness is **unknown** \u2014 unreachable,\nunauthorized, or malformed \u2014 it escalates and prints no conductor invocation at\nall, because an unknown owner is not the same as a not-ready one. No branch,\nincluding every error path, ever offers you two paths.\n\nTwo conductors is a transitional state. When one is eliminated, `drive-epic` is\nthe only thing that changes.\n\n### `setup-epic`\n\nBootstraps an Epic Conductor v2 run in one command \u2014 creates the epic run, stores the plan DAG, and approves it:\n\n```\nnpx -y @bridge_gpt/mcp-server setup-epic --epic-key <KEY> --plan-file <path-to-epic-plan.dag.json>\n```\n\nThe plan sidecar is produced by the `decompose-epic` pipeline step. `setup-epic` validates it locally (unique ticket keys, resolvable dependency references, acyclicity) before sending anything, so a malformed plan fails legibly instead of as a bare HTTP 400. It is **idempotent**: re-running it on an epic that already has a live run reuses that run rather than minting a second one. `--dry-run` validates and previews the calls without mutating anything; `--json` emits a single machine-readable result object.\n\nOnce the plan is approved, the **server-side reconciler** picks the run up within ~30s. To execute claimed jobs on your machine, run `executor`:\n\n```\nnpx -y @bridge_gpt/mcp-server executor --repo <name>\n```\n\n### `conduct-epic`\n\nThe deterministic half of the `/conduct-epic` loop: it owns the epic branch, a\nversioned local checkpoint, a per-epic lock, and the read-only probes the loop\ndecides on. It never creates or mutates an `epic_run` \u2014 that is the server-side\nv2 reconciler\'s job, and `init` refuses to start when one is already active.\n\n```\nnpx -y @bridge_gpt/mcp-server conduct-epic <verb> [flags]\n```\n\n**Verbs**\n\n| Verb | Flags |\n| --- | --- |\n| `init <EPIC>` | `--tickets K1,K2,\u2026` (required), `--base-branch <b>`, `--checkpoint-path <p>`, `--dry-run`, `--json` |\n| `status <EPIC>` | `--json` (required), `--checkpoint-path <p>` |\n| `checkpoint set <EPIC>` | `--ticket <KEY>` (required), `--field <name> <value>` (repeatable), `--journal "<line>"`, `--checkpoint-path <p>` |\n| `finish <EPIC>` | `--checkpoint-path <p>`, `--json` |\n| `spawn <EPIC>` | `--ticket <KEY>` and `--prompt-file <path>` (required), `--agent claude\\|cursor-agent`, `--checkpoint-path <p>`, `--json` |\n\n**Local state.** Everything lives *outside* the repository, under\n`~/.config/bridge/conduct/<repo>/` (honoring `XDG_CONFIG_HOME`), so it resolves\nidentically from the main checkout and from any worktree and can never be\ncommitted by an agent running `git add`:\n\n| Path | Purpose |\n| --- | --- |\n| `<EPIC>.json` | the version-1 checkpoint (file `0600`, directory `0700`) |\n| `<EPIC>.json.prev` | the previous valid checkpoint, retained on every write |\n| `<EPIC>.lock` | the per-epic lock |\n| `<EPIC>/prompts/<KEY>-<kind>-<n>.md` | prompt files the caller writes for `spawn` |\n\n`status` prints the resolved `checkpoint_path`. To unpark a run a human edits the\ncheckpoint (`needs_human` \u2192 `null`, plus the ticket\'s `status`/counters);\n`last_seen_head`, `ci_last_poll`, and `lock` are observational and are never\nhand-edited.\n\n**`init` runs ONE preflight** that reports *every* failure in a single pass and\nwrites nothing unless all of them pass: `gh auth status`; Worktrunk resolves\n(honoring `BAPI_WORKTRUNK_BIN`); Bridge credentials resolve; `auto_merge_enabled`\nis on \u2014 or is turned on by PUTting the *complete* effective config back with just\nthat flag flipped, which prints a line beginning `announced:`; at least one\nrequired CI check exists (an empty required set would make the done gate pass\nvacuously); no active server-side `epic_run` for the key; the lock is free or its\nowner is provably dead; the base branch exists on `origin` after `git fetch`; and\nthe indexed-branch override is either absent or this epic\'s own \u2014 a re-`init`\nafter a crash is accepted and its `original_base_branch` becomes the default base,\nwhile a *foreign* override is refused by name. `resolve-ci-checks` is called\nexactly once either way, because that call is what warms the `poll-ci-checks`\ncache the first `status` depends on. Only then does `init` push\n`epic/<EPIC>` to `origin` at the fetched base tip (no local checkout), repoint the\nindex, write the checkpoint, and take the lock. `--dry-run` prints the validated\nplan and mutates nothing. A second `init` refuses with `already initialized`.\n\n**Failure posture is split on purpose.** In `status`, each probe fails *open*: a\n`gh`, CI, review, or parse failure leaves that sub-object `null`, adds an entry to\n`probe_errors`, and the command still exits `0` with a complete object \u2014 the loop\nmust be able to read its own checkpoint during a GitHub outage. Everything else\nfails *closed*: a corrupt or wrong-version checkpoint makes every verb but `init`\nexit non-zero **without rewriting it**, and `checkpoint set`, `spawn`, and\n`finish` refuse a lock held by another live process. `status` never takes the lock.\n\n**Exit codes.** `0` on success \u2014 including a missing checkpoint\n(`checkpoint_exists: false`) and an idempotent second `finish`. Non-zero on any\nother failure, with a one-line reason on stderr. With `--json`, stdout is exactly\none JSON object carrying `ok`.\n\n**Credentials** resolve only from `BAPI_API_KEY` or the user-scoped\n`bapi:<repo>` credential target, travel only in the `X-API-Key` header, and never\nappear in a command argument, in stdout/stderr, or in a journal line.\n\n### Conductor (epic & multi-agent orchestration)\n\nConductor is an **opt-in, off-by-default** layer for epic supervision, inter-agent messaging, done-gate evaluation, local git-hook event producers, and conditional auto-merge. Its full reference \u2014 the v2 architecture (server-side reconciler + local executor), `setup-epic`, `conductor install-git-hooks`, the supervisor `done_gate_config` / `auto_merge_enabled` settings, and the observability stream \u2014 lives in **[CONDUCTOR.md](./docs/CONDUCTOR.md)**.\n\n> The v1 `conductor epic-tick` command is **frozen** \u2014 it throws on every invocation. There is nothing to schedule locally, and `conductor doctor` flags any epic-tick schedule left over from an earlier release so you can cancel it.\n\n## Custom Pipelines\n\nYou can create your own pipelines by adding JSON files to `.bridge/pipelines/`. Running `--init` scaffolds this directory with a `README.md` and an example pipeline to get you started.\n\nThe easiest way to write a custom pipeline is to describe what you want to automate to your AI coding agent and have it draft the JSON for you. The schema is straightforward, and agents like Claude Code understand it well \u2014 just describe the steps you want, and the agent will produce a working pipeline file.\n\n**What you can build:**\n\n- Any sequence of Bridge MCP tool calls and free-form agent tasks\n- Parameterized workflows using variables (e.g., `{ticket_key}`)\n- Approval gates that pause for user confirmation before sensitive steps\n- Per-step error handling \u2014 halt immediately or warn and continue\n\n**Ideas for custom pipelines:**\n\n- A standup pipeline that fetches your open tickets and summarizes their status\n- A ticket triage pipeline that runs critiques on a batch of new tickets\n- A pre-merge checklist that runs tests, checks linting, and posts a summary comment\n\n**Step types:**\n\n| Type | What it does |\n|---|---|\n| `mcp_call` | Calls an MCP tool with the given params |\n| `agent_task` | Gives the AI a free-form instruction (inline or from a file in `.bridge/instructions/`) |\n\nVariables are declared in the `variables` array and referenced as `{variable_name}` in params and instructions. Each step supports `on_error: "halt"` (default) or `"warn_and_continue"`, and `requires_approval: true` to pause before execution.\n\n**System variables:**\n\nTwo variables are automatically available in every pipeline without declaring them:\n\n| Variable | What it does |\n|---|---|\n| `{provider}` | Routes AI generation to a specific LLM provider (`openai`, `anthropic`, or `gemini`). Pass it through to any `request_*` tool param to control which provider handles that step. Omit it (or leave it empty) to use the project default. |\n| `{second_opinion}` | Runs AI generation through a different provider than the default, acting as a cross-check. Set to `"auto"` to let Bridge pick the second provider automatically. When set, it takes precedence over `{provider}`. Use this when you want two independent AI perspectives on the same task \u2014 for example, running clarifying questions and a critique twice (once with each provider) produces better results than a single pass. |\n\nSee `.bridge/pipelines/README.md` for the full schema reference.\n\nIf a custom pipeline has the same key as a built-in pipeline, the custom version takes precedence (a warning is logged at startup).\n\n## Environment Variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `BAPI_BASE_URL` (server, installer) | No | `https://bridgegpt-api.com` | Bridge API base URL. The MCP server and the `install` CLI both fall back to the production default |\n| `BAPI_BASE_URL` (`executor` subcommand) | **Yes** | _(none)_ | The `executor` deliberately has **no** production fallback \u2014 it refuses to start rather than guess a target |\n| `BAPI_REPO_NAME` | Yes | _(none)_ | Jira project/repository identifier configured in Bridge API |\n| `BAPI_API_KEY` | No | _(none)_ | A Bridge credential **is** required; this environment variable is only the first place the server looks for it. When it is unset the server resolves the credential from the user-scoped store (`~/.config/bridge/credentials.json`, target `bapi:<repo>`), which is why generated MCP registrations are secret-free |\n| `BAPI_PROJECT_ROOT` | No | _(see fallback order)_ | Absolute path to project root. Anchors `BAPI_DOCS_DIR` and `BAPI_PIPELINES_DIR` resolution. Resolved once, in order: `BAPI_PROJECT_ROOT` \u2192 the connected client\'s MCP `roots/list` \u2192 `CLAUDE_PROJECT_DIR` \u2192 `process.cwd()`. Several paths *write* it into a generated registration (`--init`, host-config provisioning, the worktree `mcp-invoke` shim) \u2014 that is provenance, not a runtime default |\n| `SFCC_HOSTNAME` | No | _(none)_ | SFCC sandbox hostname. Part of the environment credential tier \u2014 `SFCC_HOSTNAME`, `SFCC_CLIENT_ID`, and `SFCC_CLIENT_SECRET` must **all three** be set for that tier to apply, and a complete tier takes precedence over `dw.json` |\n| `SFCC_CLIENT_ID` | No | _(none)_ | Account Manager API client id. See `SFCC_HOSTNAME` \u2014 all three are needed together. Also required on its own when a tool is called with an explicit dotted `instance` |\n| `SFCC_CLIENT_SECRET` | No | _(none)_ | Account Manager API client secret. See `SFCC_HOSTNAME` \u2014 all three are needed together. Never sent to Bridge; it goes only to the Account Manager token endpoint |\n| `CLAUDE_CODE_OAUTH_TOKEN` | No | _(none)_ | The supported headless authentication input for conductor workers. Export it into the **executor process\'s own** environment; Bridge forwards it unchanged into the worker and stores it nowhere \u2014 no credential-store entry, no disk, never sent to Bridge. See [Claude login for conductor workers](#claude-login-for-conductor-workers) |\n| `BAPI_INSTALL_DEBUG` | No | _(unset)_ | Set to any non-empty value to unlock raw diagnostics in `install` and the `apply_install_manifest` path \u2014 the underlying error message and stack behind an `unexpected error` summary. The installer\'s own failure text tells you to set it |\n| `BAPI_SIGNUP_EMAIL` | No | _(none)_ | Selects the self-serve signup route without `--email`. Precedence: `--email` first, then this variable, then the visible interactive prompt |\n| `BAPI_INVITE` | No | _(none)_ | Invite code for `install`, for scripting. Like `--invite <code>`, it exposes the code to your shell history and the process list \u2014 prefer bare `--invite` and the hidden prompt |\n| `BAPI_PLANE_PYTHON` | No | `python` | Executable used for the Python members of `plane up`. Point it at a venv interpreter when `python` on `PATH` is not the one you want |\n| `BAPI_PLANE_UVICORN` | No | `uvicorn` | Executable used for the server member of `plane up` |\n| `BAPI_DOCS_DIR` | No | `docs/tmp` | Local directory for saving plans, critiques, and research reports |\n| `BAPI_PIPELINES_DIR` | No | `.bridge/pipelines` | Directory for user-defined custom pipeline JSON files |\n| `BAPI_WORKTRUNK_BIN` | No | `wt` (`git-wt` on Windows) | Override the Worktrunk executable name/path used by `start-tickets` for nonstandard installs |\n| `BAPI_TMUX_SESSION` | No | `bridge-start-tickets` | Override the tmux session-name prefix used by `start-tickets` on Linux |\n| `BAPI_MCP_UPGRADE_ADVICE_ENABLED` | No | _(enabled)_ | MCP-local opt-out for proactively surfacing upgrade advice in pipeline recipe preambles. Set to `false`/`0`/`no`/`off`/`disabled` to suppress. Disabling it does **not** change the `/jira/ping` response or server-side upgrade computation \u2014 it only gates the recipe-preamble convention |\n| `BAPI_MCP_TOOL_SURFACE_GATING_ENABLED` | No | _(enabled)_ | MCP-local kill switch for **dynamic tool-surface capability gating** (see [Dynamic tool-surface gating](#dynamic-tool-surface-gating-capability-availability)). Default-on; set to `false`/`0`/`no`/`off`/`disabled` to skip the startup probe, the recurring poll, and the custom `tools/list` handler entirely, restoring the SDK\'s previous full profile-derived surface. Fail-open: any probe timeout, unreachable backend, non-2xx, malformed payload, incomplete evaluation, or unsupported schema advertises the full profile |\n| `BAPI_MCP_TOOL_SURFACE_POLL_ENABLED` | No | _(disabled)_ | Opt IN to the recurring tool-surface **poll**. Default-**off**: a session gates once via the startup probe and never re-probes. Set to `true`/`1`/`yes`/`on`/`enabled` to restore the jittered 12\u201318 s heartbeat that pushes `notifications/tools/list_changed` on mid-session capability changes. No effect when gating itself is disabled |\n| `CURSOR_API_KEY` | No | _(none)_ | API key used to authenticate `cursor-agent` when launching `start-tickets`/scheduled runs with `--agent cursor-agent`. Not needed for the default Claude Code agent |\n| `BRIDGE_MCP_PROFILE` | No | `core` | Startup-time tool registration profile \u2014 a **comma-separated** list of groups controlling which tool groups are registered when the server starts. `core` is always implicitly included. Valid groups: `core` (default \u2014 normal coding tools only), `conductor` (+ 8 conductor/event/supervisor tools), `pipeline-authoring` (+ 5 pipeline run/admin tools \u2014 `get_pipeline_recipe` is NOT gated; it stays in `core` because the recipe-driven slash commands depend on it), `sfcc` (+ the 8 heavy SFCC read tools and `sfcc_log_query` \u2014 read-only, see [Salesforce B2C Commerce (SFCC) Tools](#salesforce-b2c-commerce-sfcc-tools); the `sfcc_setup_status`/`check_permissions` diagnostics are always registered regardless), `sfcc-write` (+ the 9 destructive SFCC write tools \u2014 independent of `sfcc`, which does not enable them; see [Read and write profiles](#read-and-write-profiles)), and `full` (shortcut that expands to every group, **including `sfcc-write`**). Example: `sfcc,conductor`; use `sfcc,sfcc-write` for reads plus writes. Unknown, blank, or malformed tokens are dropped (falling back to `core`). Dynamic mid-session switching via `tools/list_changed` is unsupported \u2014 groups are resolved once at process startup. Conductor/epic sessions launched via `start-tickets --conductor` **merge** `conductor` into the parent process\'s already-resolved groups at the spawn boundary rather than replacing them \u2014 a project running on `sfcc` spawns workers on `core,sfcc,conductor`. A normal `start-tickets` run stays on `core`. |\n\nEnvironment values are **trimmed**, and only a non-empty result wins. A\nwhitespace-only `BAPI_API_KEY` therefore does not override anything: it falls\nthrough to credential-store resolution exactly as an unset variable would.\n\n## Dynamic tool-surface gating (capability availability)\n\nThe **effective advertised tool surface** is the intersection of three things:\n\n1. **Startup profile registration** \u2014 which tool groups `BRIDGE_MCP_PROFILE`\n registered at process start (see above).\n2. **Current SDK-enabled state** \u2014 a tool the server has disabled for another\n reason (e.g. `poll_ci_checks` when `ci_check_config` is unset) stays hidden.\n3. **Backend capability availability** \u2014 the set of tool IDs the backend would\n currently hard-block for this repo, reported by `GET /jira/mcp/tool-surface`.\n\nOn startup the server issues one bounded probe to that endpoint and installs a\ncustom `tools/list` handler that subtracts the backend-blocked IDs (intersected\nwith the locally advertised surface) from what it advertises. That single startup\nprobe is the default: the surface is gated once per session and the server does\nnot re-probe. Installed integrations change rarely and MCP clients re-list on\nreconnect, so a permanent per-session heartbeat \u2014 multiplied across every\nconcurrent worktree/agent session \u2014 was pure request noise against the backend.\nOpt in with `BAPI_MCP_TOOL_SURFACE_POLL_ENABLED=true` to restore the jittered\n12\u201318 s re-probe that emits `notifications/tools/list_changed` whenever the\neffective visible set actually changes, so a connected client converges to the\ncurrent surface mid-session without a reconnect.\n\n**Fail-open by design.** A probe timeout, unreachable backend, non-2xx response,\nmalformed payload, incomplete evaluation, or unsupported schema version all\nadvertise the **full** existing profile baseline \u2014 gating never removes a tool on\na doubtful signal.\n\n**Hidden \u2260 disabled.** A capability-hidden tool remains **registered and\ncallable**, including through in-process pipelines. Hiding affects `tools/list`\nprojection only; it never calls `.disable()` or mutates the SDK `enabled` flag,\nbecause doing so would also block `tools/call` and the in-process dispatch path \u2014\nthe backend remains the authoritative enforcement boundary, returning its own\nrefusal for a stale call rather than a local "disabled" error.\n\n**Kill switch.** Set `BAPI_MCP_TOOL_SURFACE_GATING_ENABLED` to an accepted false\ntoken (`false`/`0`/`no`/`off`/`disabled`) to skip the probe, the poll, and the\ncustom handler entirely, restoring the SDK\'s previous full profile-derived\nsurface.\n\n**Client convergence and the reconnect escape hatch.** Clients that honor\n`notifications/tools/list_changed` converge automatically. A client that does not\nhonor the notification must **reconnect or start a new MCP server session** to\nobserve the current surface; no project MCP configuration change is required.\n\n**Diagnosing the surface.** Run `doctor` (its advisory "MCP tool surface"\nsection reports the kill-switch state, reachability, decision reason, blocked\ncount, physical tool IDs, and catalog revision via a single read-only GET), or\nread the server\'s stderr gating decision lines (`tool-surface gating: reason=\u2026\nhidden=\u2026 revision=\u2026 hidden_tools=[\u2026]`).\n\n## Worktree credentials and the `mcp-invoke` shim\n\nWhen `start-tickets` creates a git worktree, it provisions a Bridge API MCP\nregistration into that worktree so Claude Code (`.mcp.json`) and Cursor\n(`.cursor/mcp.json`) can reach the server immediately. These registrations are\n**secret-free**: they contain no `env` block and no API key. Instead they point\nat an internal subcommand of the published single CLI bin, `mcp-invoke`:\n\n```bash\nnpx -y @bridge_gpt/mcp-server@<VERSION> mcp-invoke --target bapi --project-root <ABS_WORKTREE_PATH>\n```\n\n`mcp-invoke` is not a separate binary \u2014 it is a positional subcommand of\n`bridge-api-mcp-server`. It resolves the repo identity from the absolute\n`--project-root` (the committed `.bridge/config` manifest, falling back to the\ngit common dir), resolves credentials from the home-directory credential store,\nand then spawns the real MCP server with that environment.\n\n### Credential store\n\nCredentials live outside the repository, keyed by `bapi:<repo_name>`:\n\n```json\n{\n "bapi:<repo_name>": {\n "BAPI_API_KEY": "..."\n }\n}\n```\n\nResolution order:\n\n1. `BAPI_API_KEY` in the parent environment (overrides the file entirely).\n2. `$XDG_CONFIG_HOME/bridge/credentials.json`, else `~/.config/bridge/credentials.json`.\n3. `~/.bridge/credentials.json` (only when the primary path is absent).\n\nOn POSIX systems, lock the file down so only you can read it:\n\n```bash\nchmod 600 ~/.config/bridge/credentials.json\n```\n\n`mcp-invoke` warns (but continues) if the file is group/world-readable, and it\nnever creates or initializes the credential file for you.\n\n### Populating the credential store\n\nThe same store also backs the shell-spawned `start-tickets` CLI (its\ndifficulty\u2192model routing runs in a Bash process that cannot see an `env` block in\n`.mcp.json` / `.cursor/mcp.json`), so the store must hold the key for routing to\nwork. Two supported paths write it for you:\n\n- **Install-time upsert.** `/install-bridge`\'s final stage persists the validated\n routing credential into `~/.config/bridge/credentials.json` (target\n `bapi:<repo>`) via the `persist_routing_credential` MCP tool \u2014 the tool resolves\n the key inside the MCP process and writes the store, so no secret crosses the\n wire.\n- **One-shot migration.** If a key currently lives only in `.mcp.json` /\n `.cursor/mcp.json`, migrate it into the user-scoped store with the consent-gated\n command (a compatibility aid, not a live fallback):\n\n ```bash\n npx -y @bridge_gpt/mcp-server credentials migrate-agent-config [--write-credentials]\n ```\n\n Run it without `--write-credentials` to preview; add the flag to write the store.\n\n### Claude login for conductor workers\n\nConductor workers are **not** isolated into a private Claude configuration\ndirectory \u2014 they run with the executor host\'s own `HOME`, so a worker\nauthenticates the same way any interactive `claude` invocation on that host\ndoes. The prerequisite is simple: run\n\n```bash\nclaude login\n```\n\non the executor host, once, the normal way. Bridge never stores, resolves, mints,\nrotates, validates, or diagnoses this credential \u2014 it is entirely the operator\'s\nown Claude CLI state, exactly as if you were running `claude` at the terminal\nyourself.\n\n**Headless hosts.** If the executor host has no interactive login session\navailable (a service-launched executor, a CI-style runner), export\n`CLAUDE_CODE_OAUTH_TOKEN` into the **executor process\'s own environment**\nyourself before starting it:\n\n```bash\nexport CLAUDE_CODE_OAUTH_TOKEN="$(claude setup-token)" # run once, wherever you can browser-login\n```\n\nBridge forwards that value **unchanged**, byte-for-byte, into the direct worker\nprocess environment \u2014 nothing else. It is never written to disk, never placed in\na generated launchd/systemd service unit, never placed in project configuration\n(`.mcp.json` / `.cursor/mcp.json`), and never sent to Bridge servers. There is no\ncredential store entry for it and no lifecycle tracking: expiry, rotation, and\nvalidity are entirely the operator\'s own responsibility, the same as any other\nvalue you choose to export into a process environment.\n\n**`ANTHROPIC_API_KEY` is never forwarded to a worker**, under any circumstance \u2014\nthere is no fallback path for it.\n\n`mcp-server doctor` reports a single advisory **Claude login** line \u2014 whether\n`~/.claude.json` on the host it runs on carries a login marker. This is\ninformational only: it cannot confirm the next worker spawn will authenticate,\nand it never blocks the doctor run or changes its exit code.\n\n## Reference\n\nThe full surface, for when you need the complete enumeration. Day-to-day, use [Usage Documentation](#usage-documentation) instead \u2014 you don\'t call MCP tools directly; you ask your AI assistant to perform a task, or compose tools into a pipeline.\n\n### MCP tools\n\nThe authoritative tool catalog covers **92 tools** (enumerated below). What\'s actually registered in a session depends on `BRIDGE_MCP_PROFILE`: the default `core` profile loads a trimmed subset, and the conductor/pipeline-authoring/SFCC tools are added only under their respective profiles (see [Environment Variables](#environment-variables)). Async AI tools follow a request/get pattern: call the `request_*` tool to kick off generation, then the matching `get_*` tool to retrieve the result (or pass `wait_for_result: true` to poll automatically).\n\n- **Connectivity & identity** \u2014 `ping`, `get_my_role`, `get_docs_dir`\n- **Team & access** \u2014 `invite_member` (admin-only; mints a scoped access key for a teammate on an already-configured project \u2014 the plaintext key is shown exactly once)\n- **Jira tickets** \u2014 `get_tickets`, `get_ticket`, `create_ticket`, `update_ticket_description`, `add_comment`, `get_comments`\n- **Attachments** \u2014 `attachment` (operations: `upload`, `download`, `list`)\n- **AI generation (request/get)** \u2014 `request_plan_generation`/`get_plan`, `request_architecture`/`get_architecture`, `create_doc`/`get_doc` (design docs by `doc_type`: tdd/fsd/prd), `request_prd`/`get_prd`, `request_clarifying_questions`/`get_clarifying_questions`, `request_ticket_critique`/`get_ticket_critique`, `request_ticket_review`, `request_reimplement_context`/`get_reimplement_context`, `request_council`/`get_council`, `request_deep_research`/`get_deep_research`\n- **Other AI** \u2014 `second_opinion`, `generate_image`, `generate_decision_page`, `visual_diff` (deterministic pixel diff of a rendered URL vs a design comp)\n- **Ticket lifecycle** \u2014 `track_ticket`, `update_ticket_state`, `get_ticket_state`, `get_ticket_state_tree` (live repo-wide lifecycle + dependency tree; read-only, no mutation parameter)\n- **Jira status** \u2014 `get_jira_transitions`, `update_jira_status`, `resolve_target_status`\n- **Repository & CI** \u2014 `parse_repository`, `get_parse_status`, `regenerate_directory_map`, `create_pull_request`, `merge_pull_request`, `resolve_ci_checks`, `poll_ci_checks`\n- **Pipelines & automation** \u2014 `list_pipelines`, `get_pipeline_recipe`, `run_pipeline`, `resume_pipeline`, `list_pipeline_runs`, `delete_pipeline_run`, `run_full_automation`, `resume_full_automation`\n- **Config** \u2014 `get_project_standards`, `config_field` (operations: `get`, `update`, `list`)\n\n### Bundled pipelines\n\nPipelines are declarative, multi-step workflows your AI agent executes step-by-step \u2014 each a JSON recipe chaining MCP tool calls and free-form agent tasks, with variable substitution, per-step error handling, and optional approval gates. You can also write your own (see [Custom Pipelines](#custom-pipelines)).\n\n| Pipeline | Description | Invoke with |\n|---|---|---|\n| `implement-ticket` | Generate a plan, execute the implementation, commit, open a PR, and monitor CI | `/implement-ticket PROJ-123` |\n| `review-ticket` | Full ticket quality review: clarifying questions + critique plus an automatic second-opinion pass, then evaluation and decision capture. The backend owns round orchestration; pass `--rounds=1` for a single-pass review or `--rounds=2` to force the full second-opinion review, or omit `--rounds` to let the backend decide adaptively. | `/review-ticket PROJ-123` |\n| `idea-to-ticket` | Turn an idea into a Jira Task/Spike (or Epic + children) with research, dedup, and critique | `/idea-to-ticket "<idea>"` |\n| `plan-epic` | Decompose an epic into sub-tasks with a structured exploration doc for each | `/plan-epic "<epic>"` |\n| `full-automation` | Chain: idea \u2192 ticket(s) \u2192 review each \u2192 spawn worktrees to implement | `/full-automation "<idea>"` |\n| `pr-ticket` | Commit changes and open a pull request | `/create-pr PROJ-123` |\n| `check-ci-ticket` | Commit, open a PR, then monitor CI checks until they pass or fail | `/check-ci PROJ-123` |\n| `learn-repository` | Analyze codebase architecture, testing, review, and documentation standards, then upload to Bridge | `/learn-repository` |\n\n### Pipeline response envelope\n\n`run_pipeline`, `resume_pipeline`, and `list_pipeline_runs` share a unified envelope keyed on `status`:\n\n- `completed` \u2014 terminal success; `results` holds per-step output.\n- `needs_agent_task` \u2014 the orchestrator paused. Read `instruction`, perform the task, then call `resume_pipeline` with `pipeline_run_id` and a string `agent_result`.\n- `failed` \u2014 terminal failure. `error_code` is one of `VALIDATION`, `NOT_FOUND`, `EXPIRED`, `REPO_MISMATCH`, `TOOL_ERROR`.\n\nPaused runs auto-expire after an idle TTL (default 24 hours; override with `ttl_seconds`). The TTL is reset on every state transition. List output is metadata-only \u2014 it never includes resolved recipes, params, instructions, results, or agent outputs.\n';import{readdir,readFile}from"fs/promises";import path from"path";var EXECUTION_MODE_VARIABLE="execution_mode";function validatePipelineSchema(json){let errors=[];if(typeof json!="object"||json===null||Array.isArray(json))return{valid:!1,errors:["Pipeline must be a JSON object."]};let obj=json;if((typeof obj.name!="string"||obj.name.trim()==="")&&errors.push('Missing or empty required field "name" (string).'),!Array.isArray(obj.steps)||obj.steps.length===0)return errors.push('Missing or empty required field "steps" (non-empty array).'),{valid:!1,errors};obj.description!==void 0&&typeof obj.description!="string"&&errors.push('"description" must be a string if provided.'),obj.variables!==void 0&&(!Array.isArray(obj.variables)||!obj.variables.every(v=>typeof v=="string"))&&errors.push('"variables" must be an array of strings if provided.');let steps=obj.steps;for(let i=0;i<steps.length;i++){let prefix=`steps[${i}]`,step=steps[i];if(typeof step!="object"||step===null||Array.isArray(step)){errors.push(`${prefix}: must be an object.`);continue}let s=step;if((typeof s.description!="string"||s.description.trim()==="")&&errors.push(`${prefix}: missing or empty "description" (string).`),s.on_error!==void 0&&s.on_error!=="halt"&&s.on_error!=="warn_and_continue"&&errors.push(`${prefix}: "on_error" must be "halt" or "warn_and_continue".`),s.requires_approval!==void 0&&typeof s.requires_approval!="boolean"&&errors.push(`${prefix}: "requires_approval" must be a boolean if provided.`),s.id!==void 0&&(typeof s.id!="string"||s.id.trim().length===0)&&errors.push(`${prefix}."id" must be a non-empty string when provided`),s.type==="mcp_call")(typeof s.tool!="string"||s.tool.trim()==="")&&errors.push(`${prefix}: mcp_call step requires "tool" (string).`),(typeof s.params!="object"||s.params===null||Array.isArray(s.params))&&errors.push(`${prefix}: mcp_call step requires "params" (object).`);else if(s.type==="agent_task"){let hasInstruction=typeof s.instruction=="string"&&s.instruction.trim()!=="",hasInstructionFile=typeof s.instruction_file=="string"&&s.instruction_file.trim()!=="";hasInstruction&&hasInstructionFile?errors.push(`${prefix}: agent_task must have exactly one of "instruction" or "instruction_file", not both.`):!hasInstruction&&!hasInstructionFile&&errors.push(`${prefix}: agent_task must have exactly one of "instruction" or "instruction_file".`)}else errors.push(`${prefix}: "type" must be "mcp_call" or "agent_task", got "${String(s.type)}".`)}return{valid:errors.length===0,errors}}function hasTerminalReturnSection(markdown){if(typeof markdown!="string"||markdown.length===0)return!1;let h2Pattern=/^##\s+(.+?)\s*$/gm,match,lastHeading=null;for(;(match=h2Pattern.exec(markdown))!==null;)lastHeading=match[1].trim();return lastHeading===null?!1:/^return\b/i.test(lastHeading)}var variablePattern=()=>/\{([a-zA-Z_][a-zA-Z0-9_]*)}/g;function substituteVariables(template,variables){return template.replace(variablePattern(),(full,name)=>name in variables?variables[name]:full)}function substituteDeep(value,variables){if(typeof value=="string")return substituteVariables(value,variables);if(Array.isArray(value))return value.map(item=>substituteDeep(item,variables));if(typeof value=="object"&&value!==null){let result={};for(let[k,v]of Object.entries(value))result[k]=substituteDeep(v,variables);return result}return value}function substituteInParams(params,variables){return substituteDeep(params,variables)}var UPGRADE_ADVICE_SURFACING_INSTRUCTIONS=" Upgrade advice: when any `ping` MCP call in this run returns a non-empty second text content item, relay that server-provided text to the user verbatim \u2014 do not invent, prefix, suffix, summarize, or paraphrase it. Surface it at most once per pipeline run or session, including any chained sub-pipelines. Stay completely silent when the second content item is absent or empty. A ping failure, a non-OK ping response, missing advice, or empty advice is fail-open: it must never block, pause, or crash the run, and you must never synthesize advice of your own.";function resolveRecipe(pipeline,instructions,variables,skipSteps,autoApprove,options){let declared=pipeline.variables??[],missing=declared.filter(v=>!(v in variables));if(missing.length>0)throw new Error(`Missing required variable(s): ${missing.join(", ")}. Pipeline "${pipeline.name}" declares: [${declared.join(", ")}].`);let skip=new Set(skipSteps??[]),executionMode=options?.executionMode??"inline";variables={...variables,[EXECUTION_MODE_VARIABLE]:executionMode};let resolvedSteps=[],stepIndex=1;for(let step of pipeline.steps){let stepId=typeof step.id=="string"&&step.id.trim().length>0?step.id:void 0,skipKey=stepId??(step.type==="mcp_call"?step.tool:step.description);if(skip.has(skipKey))continue;let declaredApproval=step.requires_approval??!1,effectiveApproval=declaredApproval&&!autoApprove,isPingStep=step.type==="mcp_call"&&step.tool==="ping",base={step:stepIndex++,type:step.type,description:substituteVariables(step.description,variables),on_error:isPingStep?"warn_and_continue":step.on_error??"halt",requires_approval:effectiveApproval};if(declaredApproval!==effectiveApproval&&(base.requires_approval_declared=declaredApproval),stepId!==void 0&&(base.id=stepId),step.type==="mcp_call")base.tool=step.tool,base.params=substituteInParams(step.params,variables);else{let rawInstruction;if(step.instruction_file){let content=instructions[step.instruction_file];if(content===void 0)throw new Error(`Instruction file "${step.instruction_file}" not found in bundled instructions.`);rawInstruction=content,base.instruction_file=step.instruction_file}else rawInstruction=step.instruction;base.instruction=substituteVariables(rawInstruction,variables)}resolvedSteps.push(base)}let baseInstructions=`IMPORTANT: Execute every step below in exact sequential order. For mcp_call steps, call the specified tool with the provided params. For agent_task steps, follow the instruction text using any tools it specifies. If requires_approval is true, pause before executing. For agent_task steps, the instruction file's own approval format is authoritative \u2014 follow it verbatim and do not substitute your own short confirmation prompt. For mcp_call steps with no instruction file, present the resolved params as bullet points and ask for approval. For on_error "halt", stop the pipeline immediately on failure. For on_error "warn_and_continue", log a warning and proceed. Recipes are re-entrant: a recovery run may begin again at step 1, and a step whose tool reports a server-side reuse (e.g. reused: true) has completed successfully \u2014 treat that as the expected fast path, not a failure, and continue. A step can succeed (no on_error handling applies) while its own returned content is a JSON envelope shaped like error: "GATEWAY_TIMEOUT", status: 504, and a recovery_get field \u2014 recognize that shape and read it as "server-side processing may still be running", not as a failure or an invitation to retry. Poll the named retrieval tool (or the recovery_get URL) with the same artifact identifier until a terminal response is reached, and never reissue the original request tool. Only fall back to on_error handling if that retrieval itself terminally fails. Do not skip steps, reorder them, or substitute your own tool calls.`,upgradeAdviceConvention=options?.includeUpgradeAdviceSurfacing!==!1?UPGRADE_ADVICE_SURFACING_INSTRUCTIONS:"",autoApproveSuffix=autoApprove?" Auto-approve mode is ACTIVE: every approval gate has been pre-approved by the user via the auto_approve flag \u2014 proceed without pausing for confirmation, applying the default branching, file-staging, and recommendation choices documented in each instruction file's auto-approve branch.":"";return{pipeline:pipeline.name,description:pipeline.description??"",total_steps:resolvedSteps.length,agent_instructions:baseInstructions+upgradeAdviceConvention+autoApproveSuffix,auto_approve:!!autoApprove,execution_mode:executionMode,steps:resolvedSteps}}async function loadCustomPipelines(pipelinesDir,instructionsDir,bundledInstructions){let mergedInstructions={...bundledInstructions},userPipelines={},userPipelineKeys2=new Set;try{let instrFiles=await readdir(instructionsDir);for(let file of instrFiles)if(file.endsWith(".md"))try{let content=await readFile(path.join(instructionsDir,file),"utf-8");file in bundledInstructions&&console.error(`Warning: custom instruction "${file}" overrides a bundled instruction.`),mergedInstructions[file]=content}catch(readErr){let msg=readErr instanceof Error?readErr.message:String(readErr);console.error(`Warning: skipping instruction "${file}" \u2014 failed to read: ${msg}`)}}catch(err){err.code!=="ENOENT"&&console.error(`Warning: could not read instructions directory "${instructionsDir}": ${err.message}`)}try{let pipelineFiles=await readdir(pipelinesDir);for(let file of pipelineFiles){if(!file.endsWith(".json"))continue;let parsed;try{let raw=await readFile(path.join(pipelinesDir,file),"utf-8");parsed=JSON.parse(raw)}catch(parseErr){let msg=parseErr instanceof Error?parseErr.message:String(parseErr);console.error(`Warning: skipping "${file}" \u2014 failed to parse: ${msg}`);continue}let{valid,errors}=validatePipelineSchema(parsed);if(!valid){console.error(`Warning: skipping "${file}" \u2014 validation errors:
6094
+ `};init_version_generated();var README='# @bridge_gpt/mcp-server\n\nThe Bridge MCP is an end-to-end accelerator for shipping code within SFCC, powered by [Bridge API](https://bridgegpt-api.com). Works with Claude Code, Github Copilot, Cursor, Windsurf, and OpenAI Codex.\n\n## Install\n\nFrom your **project root**, run:\n\n```bash\nnpx -y @bridge_gpt/mcp-server install\n```\n\nThat is the whole setup command. It works whether or not you already have a Bridge\naccount \u2014 it will ask.\n\n> We recommend **the command above** instead of the `npm i @bridge_gpt/mcp-server`\n> one in npm\'s sidebar, because it **will make set up much easier**.\n\n**What it will do**\n\n- **Bootstraps the Bridge MCP for you** \u2014 one command and your editor\'s agent can\n use Bridge\'s tools and slash commands on this project.\n- Registers a `bridge` MCP server in your editor\'s MCP config, leaving any\n other servers you have configured untouched.\n- Creates and updates the files it needs inside your project root: slash commands\n and agent definitions for your editor (`.claude/commands/`, `.cursor/commands/`,\n and the equivalents your editor uses), your editor\'s MCP config, and `.bridge/`\n for your project manifest and pipeline definitions.\n- Stores your Bridge credential outside the project, so the MCP server and the\n tooling that spawns its own shells can find it without you configuring anything.\n Re-running `install` still asks for the credential unless you supply it through\n `--api-key` or `BAPI_API_KEY` \u2014 the installer writes that store, it does not read\n it back.\n- Writes outside your project root only when you pick a host whose configuration is\n global: OpenAI Codex (`~/.codex/config.toml`) and GitHub Copilot CLI\n (`~/.copilot/mcp-config.json`).\n\n**Prerequisites**\n\n- **Node.js 18 or newer** (`node --version`), which is what provides `npx`.\n- **A project directory** \u2014 run the command from the folder your editor opens: your\n repository root, the one containing `.git`. No `package.json` is required \u2014 SFCC\n cartridge repos, Python, Go, Rust, and other non-Node projects work the same way.\n- **An MCP-capable editor or CLI**: Claude Code, GitHub Copilot in VS Code, GitHub\n Copilot CLI, Cursor, Windsurf, or OpenAI Codex.\n- **No Bridge account needed.** The installer can create one for you from just an\n email address.\n\n## Contents\n\n- [Install](#install)\n- [Installation details](#installation-details)\n - [Installing, step by step](#installing-step-by-step)\n - [What to expect](#what-to-expect)\n - [Troubleshooting](#troubleshooting)\n- [Usage Documentation](#usage-documentation)\n - [Regularly useful](#regularly-useful)\n - [Occasionally useful](#occasionally-useful)\n - [Now and then](#now-and-then)\n - [Workflow commands](#workflow-commands)\n - [Operational commands](#operational-commands)\n - [Extra Capabilities](#extra-capabilities)\n- [Salesforce B2C Commerce (SFCC) Tools](#salesforce-b2c-commerce-sfcc-tools)\n- [CLI Subcommands](#cli-subcommands)\n- [Custom Pipelines](#custom-pipelines)\n- [Environment Variables](#environment-variables)\n- [Worktree credentials and the `mcp-invoke` shim](#worktree-credentials-and-the-mcp-invoke-shim)\n- [Reference](#reference)\n\nFor advanced epic/multi-agent orchestration, see [CONDUCTOR.md](./docs/CONDUCTOR.md).\n\n## Installation details\n\n### Installing, step by step\n\n**1. Open a terminal in your project root.** This matters: the installer writes\nyour slash commands and MCP config relative to the directory you run it from. If\nyou run it in your home directory, your editor will not find any of it.\n\n**2. Run the command.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server install\n```\n\n**3. Answer the sign-in question.** On a first run it asks whether you already have\na token:\n\n```\n1. Yes, I have received a token\n2. No, I need one\n```\n\n- Choose **2** if you have nothing yet. It asks for your email address and a name\n for your new Bridge project, then creates both for you.\n- Choose **1** if someone gave you a token \u2014 either a Bridge API key or an invite\n code. Paste it at the hidden prompt; you do not have to say which kind it is,\n because the installer recognizes it. Nothing is echoed as you type.\n\nThere is no default answer, so pressing Enter alone selects nothing. If you would\nrather not be asked, pass the answer up front instead \u2014 see\n[Choosing how you sign in](#choosing-how-you-sign-in).\n\n**4. Pick which editors to configure.** The installer detects the MCP hosts on your\nmachine and asks which ones to set up. Pick every editor you actually use for this\nproject; you can re-run the command later to add another.\n\n**5. Reload your MCP host.** Editors read their MCP configuration at startup, so a\nfreshly written config is not live until you reload. Restart the editor, or use its\n"reload MCP servers" action. In Claude Code you will also be asked to trust the\nproject\'s `.mcp.json` the first time.\n\n**6. Finish in the agent session the installer opens \u2014 when it opens one.** The\nlast thing the installer does is offer to open a fresh agent session running\n`/install-bridge`, which reads your codebase, fills in the remaining project\nsettings, and prints a short report of what Bridge can help with. Let it finish.\n\nThree things all have to hold for that session to open: your selection has to\ninclude a host the installer can launch, the run has to be on an interactive\nterminal, and you have to accept the consent prompt (*"Bridge can configure and set\nup this project for you automatically. Open a `<tool>` session to do that now?\n(Y/n)"*). Claude Code is the only selection that launches on its own. A\nCursor-only, Copilot, Copilot CLI, Codex, or Windsurf selection, a non-interactive\nrun, or a declined prompt all print the command to continue by hand instead. Pass\n`--agent claude` or `--agent cursor-agent` to override the decision outright.\n\n**7. Follow the next step the session shows you, if it shows one.** The installer\nasks the server what should happen next and shows that command only when there is\none to show \u2014 most often `/learn-repository`, which it recommends when the project\nstill needs its architecture, testing, review, and correctness standards documented\nand your key can run it. The installer deliberately does not run it for you. Those\nstandards are what make every later plan, critique, and review match how your\nproject actually works, and they only need to be gathered once per project \u2014 the\nresult is shared with everyone on the team. If the session shows no next step,\nthere is nothing for you to run.\n\nWant to see what would happen without changing anything? Add `--dry-run`.\n\n<details>\n<summary id="what-to-expect"><strong>What to expect</strong></summary>\n\n**Files that appear in your project**\n\n| Path | What it is | Commit it? |\n|---|---|---|\n| `.claude/commands/`, `.cursor/commands/` | The slash commands your editor runs | Yes |\n| `.claude/agents/` and editor equivalents | Agent definitions used by those commands | Yes |\n| `.bridge/config` | Your project manifest \u2014 the repository name and which MCP targets to provision. Deliberately secret-free | Yes |\n| `.bridge/pipelines/`, `.bridge/instructions/` | Editable pipeline definitions | Yes |\n| `.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json` | MCP registrations for your editor. These can carry your key, so the installer git-ignores them | No |\n\nThe installer tells you which of these are safe to commit and never recommends\ncommitting anything that can hold a credential.\n\n**Prompts you will see.** More than the sign-in question, in three groups:\n\n- *Always on a first bare interactive run:* the sign-in question, a hidden prompt\n for a token (or a visible one for an email), a project name for a brand-new\n project, a picker for which editors to configure, and an offer to connect GitHub\n (`Connect GitHub? [y/N]:`).\n- *Conditional on your situation:* a confirmation when the directory has no `.git`\n (default **No**, and declining aborts); a *"Which tool should open? [1-N]"*\n chooser when your selection contains more than one launchable tool; and the\n consent prompt before the final agent session.\n- *Overwrite confirmations, each default **No** and each skippable with `--force`:*\n a saved key for this project already exists; a host config already contains a\n `BAPI_API_KEY`; a **git-tracked** config would receive your real key; a saved but\n expired self-serve signup would be discarded.\n\n**A fresh agent session opens at the end \u2014 if your selection can launch one.** See\nstep 6 above for the three conditions. Use `--agent cursor-agent` if you want\nCursor\'s agent instead of Claude Code.\n\n**Selecting Windsurf prints instructions instead of writing config.** Windsurf\'s\nglobal `mcp_config.json` is never modified automatically; the installer reports the\nentry for you to paste yourself. Codex and Copilot CLI *are* written automatically,\neven though their files are global too.\n\n**Your key is stored for the tools that read the store.** The MCP server and the\nshell-spawned tooling (`start-tickets` and its model routing) resolve it from\n`~/.config/bridge/credentials.json` on their own. The **installer** does not: a\nrepeat `install` prompts for the credential again unless you pass `--api-key` or\nset `BAPI_API_KEY` in the environment.\n\n**A next step, when the project needs one.** The session closes with whatever\ncommand the server says comes next, and stays quiet when there is nothing to\nrecommend. `/learn-repository` is the usual one: it is recommended when the project\nstill needs its conventions documented and your key can run it. It is never\nautomatic \u2014 until someone runs it, Bridge\'s agents work from your code alone rather\nthan from your project\'s documented conventions.\n\n**Indexing happens on its own.** There is no "index my repository?" question. Once\nyour project has the settings it needs, indexing starts server-side. You never have\nto ask for it.\n\n</details>\n\n<details>\n<summary id="troubleshooting"><strong>Troubleshooting</strong></summary>\n\n**"My editor doesn\'t see any Bridge tools."** Two usual causes. First, the config\nwas written somewhere your editor is not looking \u2014 re-run the installer from the\ndirectory your editor actually opens, and check that a `bridge` entry exists in\nthat project\'s MCP config. Second, the editor has not been reloaded since the file\nwas written; restart it. In Claude Code, also confirm you accepted the trust prompt\nfor the project\'s `.mcp.json`.\n\n**"I ran it in the wrong folder."** Nothing is broken. Depending on which editors\nwere detected, a run can leave `.bridge/`, `.bridge/install-state.json`, `.claude/`,\n`.cursor/commands/`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.github/agents/`,\n`.mcp.json`, and appended `.gitignore` lines. Remove only what that run created and\nre-run the command from the right directory \u2014 if you already had a `.vscode/`,\n`.cursor/`, or `.gitignore` there, keep the parts you had before.\n\n**"It seems to hang with no output."** If you ran the bare command\n(`npx -y @bridge_gpt/mcp-server`) with no subcommand, you started the MCP *server*,\nnot the installer. It is waiting for an editor to connect over stdio, which is\nexactly what it should do when your editor launches it \u2014 but from a terminal it\nlooks like a hang. It prints a line saying so. Press Ctrl-C and run\n`npx -y @bridge_gpt/mcp-server install` instead. The explicit spelling\n`npx -y @bridge_gpt/mcp-server serve` starts the server on purpose.\n\n**"It can\'t reach Bridge" or "my key was rejected."** The installer checks\nconnectivity before it saves your **credential** anywhere, so a failure here has not\nwritten your key into a config or stored it for later. It has already\nscaffolded the project files by then \u2014 slash commands, agents, pipelines,\n`.bridge/config`, and secret-free per-host MCP placeholders \u2014 so expect those to\nexist; re-running is safe and refreshes them. A\nrejected key means the credential is not valid for that project \u2014 check the project\nname you gave, and generate a fresh key on the Bridge web UI\'s **Security** page if\nneeded. A network failure usually means a proxy or VPN is in the way.\n\n**"Which repository name should I use?"** The one registered with Bridge. If you\nhave an existing key, the installer usually resolves it for you; when it cannot, it\nasks, and `--repo <name>` answers it up front.\n\n**Still stuck? Ask the installer to diagnose itself.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server doctor\n```\n\n`doctor` is strictly read-only. It reports what it found \u2014 configs, registrations,\ncredential availability, prerequisites \u2014 and changes nothing.\n\n</details>\n\n<details>\n<summary id="choosing-how-you-sign-in"><strong>Choosing how you sign in</strong></summary>\n\nThree routes lead to the same place. The interactive question above picks one for\nyou; these flags pick it up front and skip the question entirely.\n\n**No account yet \u2014 sign up with an email.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server install --email you@example.com\n```\n\nCreates a brand-new Bridge project for that address and your first admin key in one\ncommand. No account, no key, and no invite needed beforehand. The address labels\nyour new workspace and may receive a setup message; delivery is best-effort, so\nnothing waits on it. The email is visible as you type (it is not a secret) and is\nnever written to a log. This is the same route as answering **2** at the prompt.\n\n**You were sent an invite code.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server install --invite\n```\n\nRedeems the invite, creates your project, and mints your first admin key. Run it\n*without* a value, as shown: the installer then asks for the code at a hidden\nprompt, so the code never lands in your shell history. `--invite <code>` and the\n`BAPI_INVITE` environment variable exist for scripting, but both expose the code to\nyour shell history and to the process list.\n\n**Your team already has a project and gave you an API key.**\n\n```bash\nnpx -y @bridge_gpt/mcp-server install --api-key <key>\n```\n\nOr omit the flag and paste the key at the hidden prompt. Generate a key on the\nBridge web UI\'s **Security** page (**Create New Key**, role **Admin**) and copy it\nimmediately \u2014 it is shown once. `BAPI_API_KEY` works too.\n\nIf you paste an invite code where a key was expected, or a key where an invite was\nexpected, the installer recognizes the mismatch and tells you before anything is\ncreated or spent.\n\n</details>\n\n<details>\n<summary><strong>Installer flags</strong></summary>\n\n| Flag | What it does |\n|---|---|\n| `--email <addr>` | Sign up for a new Bridge project with just an email address |\n| `--invite [code]` | Redeem an invite code. Omit the value for the hidden prompt (recommended) |\n| `--api-key <key>` | Use an existing Bridge API key |\n| `--repo <name>` | Name the registered repository instead of resolving or asking for it |\n| `--tools <list>` | Configure specific MCP hosts without the picker. Accepted IDs are exactly `claude-code`, `cursor`, `copilot-vscode`, `copilot-cli`, `codex`, and `windsurf` (e.g. `claude-code,cursor`); any other value is a parse error |\n| `--agent claude\\|cursor-agent` | Which agent to open for the final configuration step. **No default** \u2014 without this flag the agent is derived from the hosts you selected, and an explicit value always wins, including for a host you did not select |\n| `--dry-run` | Preview every step without writing, contacting Bridge, resolving or prompting for a credential, or opening anything. Genuinely inert: it returns before the project-root prompt, before the repository is resolved, and before any tool-selection prompt, so a value it cannot know locally (an unresolved repository name, an unselected tool) is shown as **not yet known** rather than guessed |\n| `--force` | Overwrite an existing stored key without asking |\n| `-h`, `--help` | Full usage |\n\n`--email`, `--invite`, and `--api-key` are mutually exclusive \u2014 each names a\ndifferent way to arrive, and the installer will not guess between them.\n\n</details>\n\n<details>\n<summary><strong>Setting up an MCP host by hand</strong></summary>\n\nThe installer configures your editors for you. Do this only if you would rather\nwrite the config yourself, or if you use a host it cannot write automatically.\n\nScaffold the project files and write a secret-free MCP registration. Run it from\nthe same project root `install` uses \u2014 your repository root, the one containing\n`.git`. No `package.json` is required:\n\n```bash\nnpx -y @bridge_gpt/mcp-server --init\n```\n\n`--init` always creates `.mcp.json`, and adds `.vscode/mcp.json` or\n`.cursor/mcp.json` when it detects those editors. Each generated entry carries\n`BAPI_BASE_URL`, `BAPI_REPO_NAME`, `BAPI_DOCS_DIR`, and `BAPI_PROJECT_ROOT`, and\n**never** `BAPI_API_KEY` \u2014 the server resolves the credential itself at runtime.\n\nSo the manual work left after `--init` is narrower than writing an entry from\nscratch: correct `BAPI_REPO_NAME` if it was written as the `YOUR_REPO_NAME`\nplaceholder, and supply your credential through a supported source (`BAPI_API_KEY`\nin the entry\'s `env` block, `BAPI_API_KEY` in the server\'s environment, or the\n`~/.config/bridge/credentials.json` store).\n\nWrite the entry yourself instead \u2014 for a host `--init` does not touch, or because\nyou would rather \u2014 using the shapes below. Add `"serve"` as the last launcher\nargument, as shown: it is the explicit way to say "start the MCP server." Pin the\npackage to an exact version and pass `--prefer-offline`, which is what the\ngenerated entries do and what keeps npx from resolving a different build on some\nlater boot.\n\n<details>\n<summary><strong>Claude Code (.mcp.json)</strong></summary>\n\n```json\n{\n "mcpServers": {\n "bridge": {\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.45", "serve"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>VS Code / Copilot (.vscode/mcp.json)</strong></summary>\n\n```json\n{\n "servers": {\n "bridge": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.45", "serve"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>Cursor (.cursor/mcp.json)</strong></summary>\n\n```json\n{\n "mcpServers": {\n "bridge": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.45", "serve"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>GitHub Copilot CLI (~/.copilot/mcp-config.json)</strong></summary>\n\nCopilot CLI reads a single global file. The installer writes this one for you when\nyou select `copilot-cli`; the shape below is what it produces.\n\n```json\n{\n "mcpServers": {\n "bridge": {\n "type": "local",\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.45", "serve"],\n "tools": ["*"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>Windsurf (~/.codeium/windsurf/mcp_config.json)</strong></summary>\n\nWindsurf only supports global MCP configuration.\n\n```json\n{\n "mcpServers": {\n "bridge": {\n "command": "npx",\n "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.45", "serve"],\n "env": {\n "BAPI_BASE_URL": "https://bridgegpt-api.com",\n "BAPI_REPO_NAME": "your-repo",\n "BAPI_API_KEY": "your-api-key",\n "BAPI_DOCS_DIR": "docs/tmp"\n }\n }\n }\n}\n```\n</details>\n\n<details>\n<summary><strong>OpenAI Codex (~/.codex/config.toml)</strong></summary>\n\n```toml\n[mcp_servers.bridge]\ncommand = "npx"\nargs = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.45", "serve"]\n\n[mcp_servers.bridge.env]\nBAPI_BASE_URL = "https://bridgegpt-api.com"\nBAPI_REPO_NAME = "your-repo"\nBAPI_API_KEY = "your-api-key"\nBAPI_DOCS_DIR = "docs/tmp"\n```\n\n> Codex users: set `BAPI_PROJECT_ROOT` manually in your config (see [Environment Variables](#environment-variables)).\n</details>\n\nAfter saving, reload your editor and ask your assistant to call the `ping` tool to\nconfirm the connection.\n\nAn entry with no trailing `serve` still starts the server \u2014 bare invocation means\n"server" permanently, and nothing rewrites an existing config to add the token.\n\n</details>\n\n<details>\n<summary><strong>Upgrading Bridge</strong></summary>\n\n```bash\nnpx -y @bridge_gpt/mcp-server@latest --upgrade\n```\n\n`upgrade` fetches the latest published version, refreshes your scaffolded slash\ncommands, agents, and pipelines, updates the version pin in your MCP config, and\nopens a session so you can reconnect. It is also available as the\n`/upgrade-bridge` slash command.\n\nUse the `@latest` form. It applies to the short-lived *upgrader* process: without\nit, npx may reuse a cached older copy of the package and "upgrade" you with the\nbuild you are trying to replace. The exact `MAJOR.MINOR.PATCH` pin the upgrader\nwrites into your MCP config is deliberately different \u2014 host configs stay pinned\nto an exact release so a project\'s server is reproducible.\n\n`upgrade` reports **per config file**, because a project can have several\n(`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`) and they can disagree:\n\n```\nLauncher pins:\n .mcp.json: 0.2.16 -> 0.2.36\n .cursor/mcp.json: already 0.2.36\n```\n\nWhen every applicable launcher pin was already at the target, it prints\n`Already up-to-date.` \u2014 that status comes from comparing your configs, not from\nthe version of the CLI process. A non-zero exit means the upgrade did **not**\nconverge, and nothing is reported as complete in that case. The causes:\n\n- the npm registry lookup failed **and** this process was not started from\n `@latest`, so the target version could not be confirmed \u2014 the likeliest one\n offline, and why the canonical command uses `@latest`;\n- a launcher pin is already **newer** than the target, which an automated repin\n must never downgrade;\n- an unreadable or unparseable config, a launcher carrying a version range or a\n dist-tag rather than an exact release, or two Bridge registrations in one file;\n- a competing local install it could not remove, or a pin that failed post-write\n verification;\n- the upgrade finished but left an **unconfigured** MCP entry \u2014 one that would\n authenticate as nobody.\n\nThe server checks for updates on startup. The check is cached for a day and never\nblocks startup. When a newer version is known, it surfaces in two places you do\nnot have to go looking for: a one-line warning on the server\'s **stderr**, and a\nshort advisory attached to the ordinary `tools/list` response so the agent in the\nsession can see that some tools may be missing or renamed in the older build.\nNeither requires calling `ping` or `doctor`.\n\nRe-running `install` on an already-configured project is safe: it refreshes the\nscaffolded files without overwriting your stored credential unless you pass\n`--force`.\n\n</details>\n\n## Usage Documentation\n\nThis is the Bridge API tooling worth knowing about as a software engineer \u2014 the things you\'d ask an agent to do \u2014 grouped by how often you would use them. Each entry covers **what it does**, **when it\'s useful**, **how to use it**, and its **flags**. The behind-the-scenes plumbing is summarized at the end under [Extra Capabilities](#extra-capabilities), and a full enumeration lives in [Reference](#reference).\n\nWorking in a Salesforce B2C Commerce codebase? Bridge also ships SFCC platform tools \u2014 read-only introspection under the `sfcc` profile, and nine destructive writes under the separate `sfcc-write` opt-in. See [Salesforce B2C Commerce (SFCC) Tools](#salesforce-b2c-commerce-sfcc-tools).\n\nFor invocation, prefer the slash command \u2014 it\'s deterministic. A free-text example is shown only where natural-language phrasing reliably maps to the right automation; high-consequence or easily-misread automations show only the slash command on purpose.\n\n<!-- The three tier sections below are GENERATED from TWO catalogs by\n scripts/sync_mcp_server_readme.py: api/library/config/mcp_tool_catalog.json,\n the authoritative MCP tool catalog, and api/library/config/workflow_catalog_lib.py,\n the immutable catalog of slash-command workflows (which have no MCP registration\n and therefore cannot live in the JSON artifact). Edit the curated tool metadata in\n scripts/sync_mcp_tool_catalog.py and the workflow definitions in\n workflow_catalog_lib.py \u2014 never the JSON artifact and never the text between the\n markers. Generation order is: sync_mcp_tool_catalog.py, then\n sync_mcp_server_readme.py, then `cd mcp_server && npm run build` (which bundles this\n file into readme.generated.ts, served as the MCP resource bridge://readme).\n Everything outside the marker pair \u2014 including the sections below it \u2014 is hand-written. -->\n\n<!-- BEGIN GENERATED: mcp-tool-documentation (managed by scripts/sync_mcp_server_readme.py \u2014 DO NOT EDIT BY HAND) -->\n### Regularly useful\n\nThe tools worth knowing for most tickets.\n\n**1. Review Ticket**\n- **What it does:** Runs a full quality review of a ticket: clarifying questions and a critique plus an alternate-model second opinion, then evaluates the findings and produces a decision page for accepting or rejecting them.\n- **When it\'s useful:** (Refinement) Right after a ticket is drafted, before anyone starts building \u2014 to surface gaps and tighten it.\n- **How to use it:** `/review-ticket BAPI-123` (command only \u2014 "review" as free text is easily mistaken for a freehand agent review). For several tickets at once, `/review-tickets BAPI-123 BAPI-456` opens one terminal tab per ticket and reviews them in parallel with no worktrees; every `/review-ticket` flag applies, and `--review KEY=auto,rounds=N` sets per-ticket overrides.\n- **Flags:** `--auto` auto-accept findings and skip the approval gates \xB7 `--rounds=1` a cheaper single-pass review that still evaluates findings and captures decisions \xB7 `--rounds=2` force the full second-opinion review \xB7 omit `--rounds` to let the difficulty-adaptive review policy decide.\n\n**2. Start Tickets**\n- **What it does:** Creates one git worktree per ticket and spawns an agent session in each to implement them in parallel.\n- **When it\'s useful:** (Implementation | Automation) When you\'re ready to start building one or more refined tickets concurrently.\n- **How to use it:** `/start-tickets BAPI-248 BAPI-250` (see [CLI Subcommands](#cli-subcommands) for the full flag table and cross-platform behavior).\n- **Flags:** `--auto` skip the approval gates \xB7 `--base-branch <branch>` branch off something other than the default \xB7 `--workflow implement|review-and-implement` selects which slash command each spawned worktree runs \xB7 `--rounds=1|2` forwarded to the review phase, valid only with `--workflow review-and-implement` \xB7 `--tier cheap|basic|premium` coarse model-routing override.\n\n**3. Review and Start**\n- **What it does:** Spawns one worktree per ticket; each session reviews the ticket inline and, after a per-ticket proceed/halt gate, hands off to a **fresh implementation session** that reuses the same worktree \u2014 review and implementation run in two separate agent contexts, not one shared session.\n- **When it\'s useful:** (Refinement | Implementation | Automation) The **recommended front door** for "review these tickets, then implement the ones that pass," starting from existing ticket keys (unlike `/full-automation`, which only accepts an idea).\n- **How to use it:** `/review-and-start BAPI-248 BAPI-250` (single or multiple keys flow through the identical code path).\n- **Flags:** `--auto` auto-approves both the review and the implementation phase of every spawned session \xB7 `--rounds=1|2` forwarded to the review phase \xB7 `--agent`, `--base-branch`, `--max-parallel`, `--dry-run` mirror `/start-tickets`.\n\n**4. Explore Ticket**\n- **What it does:** Maps the code paths, dependencies, and project conventions a task would touch, settles its acceptance criteria with you on a decision page, then compares the viable implementation approaches and their trade-offs and writes up a proposed design. Along the way it surfaces the ambiguities that still need deciding and can pull in optional web or deep research where the answer is not in the code.\n- **When it\'s useful:** (Architecture | Refinement) Before writing a ticket or a plan, when you\'re unsure how a change would fit the existing code and want the open questions and the realistic options laid out first.\n- **How to use it:** `/explore-ticket <task>` \u2014 *"Explore the codebase for how we\'d add a Mistral LLM provider and recommend 2\u20133 implementation options."*\n- **Flags:** None.\n\n**5. Council**\n- **What it does:** Fans your problem out to two different models and returns their approaches, in technical, design, discovery, or general mode.\n- **When it\'s useful:** (Architecture | Refinement) Early, when you want a spread of approaches \u2014 technical for how to build it, design for how it should look, discovery for what still needs figuring out before a real ticket exists, general for a quick brief-driven pass before the repository is indexed.\n- **How to use it:** `/council <question>`\n- **Flags:** `--mode` selects one of four modes, passed to the underlying `request_council` tool as e.g. `mode: "discovery"`: `technical` (the default \u2014 implementation/architecture approaches), `design` (UI/UX and visual direction), `discovery` (stakeholder discovery questions, grouped into `Technical Discovery Questions` and `Business / Stakeholder Discovery Questions` and tagged `[HUMAN]`/`[CODE]`/`[TICKET]`), and `general` (brief-driven ideation from your task description alone). `technical` and `discovery` are codebase-grounded and need an indexed repository; `general` needs no code index at all, so it works immediately after install. The legacy boolean `design=true` still works and maps to `mode: "design"`.\n\n**6. Deep Research**\n- **What it does:** Runs multi-source, fact-checked web research on a technical topic and returns a cited report.\n- **When it\'s useful:** (Architecture | Refinement) When a decision hinges on outside knowledge \u2014 libraries, best practices, standards \u2014 that you do not already have.\n- **How to use it:** `/bridge-research <question>`\n- **Flags:** None.\n\n### Occasionally useful\n\nGood to know, but not needed every day.\n\n**1. Upload Ticket**\n- **What it does:** Creates a real Jira issue from a drafted ticket, including child tickets under an epic; your agent should confirm with you before creating it.\n- **When it\'s useful:** (Refinement) The final step after drafting \u2014 to get the ticket into your tracker so it can be tracked and worked.\n- **How to use it:** Ask your agent to create the ticket; it should confirm with you before creating the live issue.\n- **Flags:** Name the issue type (Bug / Story / Task / Epic) and, for a child ticket under an epic, the parent key.\n\n**2. Plan Ticket**\n- **What it does:** Generates a step-by-step implementation plan for a ticket that references real files in your codebase.\n- **When it\'s useful:** (Refinement | Implementation) Once a ticket is solid and you want a concrete build plan before \u2014 or instead of \u2014 auto-implementing it.\n- **How to use it:** `/plan-ticket BAPI-123`\n- **Flags:** `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check the plan with a second provider.\n\n**3. Clarify Ticket**\n- **What it does:** Generates clarifying questions for a ticket, or debugging guidance when the ticket is a bug.\n- **When it\'s useful:** (Refinement) When a ticket feels under-specified and you want the open questions made explicit.\n- **How to use it:** `/clarify-ticket BAPI-123`\n- **Flags:** `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check with a second provider.\n\n**4. Critique Ticket**\n- **What it does:** Critiques a ticket against your project\'s standards and lists the deviations and improvements it found.\n- **When it\'s useful:** (Refinement) When you want a quality gate on a ticket before anyone works it.\n- **How to use it:** `/critique-ticket BAPI-123`\n- **Flags:** `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check with a second provider.\n\n**5. Create Doc**\n- **What it does:** Generates a design document for a ticket \u2014 a technical design document, a functional spec, or a product requirements document.\n- **When it\'s useful:** (Architecture | Refinement) When a ticket needs a fuller design write-up before planning or implementation, in the shape that fits your audience.\n- **How to use it:** `/create-doc BAPI-123 --doc-type tdd` (or `fsd` / `prd`)\n- **Flags:** `--doc-type tdd|fsd|prd` which document to generate (required) \xB7 `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check with a second provider.\n\n**6. Second Opinion**\n- **What it does:** Gets an immediate critique of any text from a different model family, without saving an artifact.\n- **When it\'s useful:** (Architecture | Refinement | Implementation) Any time you want a quick sanity check on a plan, draft, or decision from a fresh perspective.\n- **How to use it:** Ask your agent \u2014 "Get a second opinion from Gemini on whether the BAPI-123 plan\'s migration step is safe to run against production."\n- **Flags:** Pick the provider (anthropic / openai / gemini) and the tier (cheap / basic / premium).\n\n**7. Generate Image**\n- **What it does:** Generates an image from a text prompt using a provider image model, spending provider credits on every call.\n- **When it\'s useful:** (Architecture | Refinement) When you want a quick visual \u2014 a UI mockup, diagram, or illustration \u2014 to anchor a design discussion or attach to a ticket.\n- **How to use it:** Ask your agent \u2014 "Generate an image of a dashboard showing SOC2 evidence freshness as a traffic-light grid."\n- **Flags:** `provider` openai (`gpt-image-2`) / gemini (Imagen, which adds an invisible SynthID watermark) \xB7 `quality` low (default, cheapest) / medium / high \xB7 `size` 1024x1024 / 1024x1536 / 1536x1024. The image is saved to `BAPI_DOCS_DIR/images/` and also returned inline.\n\n**8. Request PRD**\n- **What it does:** Generates a product requirements document for a ticket covering the problem, the goals, and the success metrics.\n- **When it\'s useful:** (Architecture | Refinement) When a piece of work needs its problem, goals, and success metrics written down before anyone designs a solution.\n- **How to use it:** `/create-doc BAPI-123 --doc-type prd`\n- **Flags:** `--provider <name>` choose the model provider \xB7 `--second-opinion <provider>` cross-check with a second provider.\n\n**9. Full Automation**\n- **What it does:** Drives the whole chain from a raw idea through tickets and reviews to implementation sessions.\n- **When it\'s useful:** (Automation) When you want to go from a raw idea to in-progress implementation with minimal hands-on steps.\n- **How to use it:** `/full-automation <idea>` (command only \u2014 it creates tickets, spawns worktrees, and carries scheduling flags free text cannot).\n- **Flags:** `--require-approval` re-enable the approval gates; the chain runs end to end by default \xB7 `--max-children <n>` cap how many child tickets an epic decomposes into.\n\n**10. Update Ticket Description**\n- **What it does:** Rewrites a ticket\'s description with AI, using the ticket\'s own content and its reference material. A rewrite that changes more than 60% of the description is held for review instead of applied.\n- **When it\'s useful:** (Refinement) When a ticket has accumulated comments, attachments, or links and its description no longer reflects them.\n- **How to use it:** Ask your agent \u2014 "Update the description for BAPI-123."\n- **Flags:** None. Poll the ticket\'s state for the outcome; if the update was held for review, read the proposal instead of applying it blind.\n\n### Now and then\n\nUseful once in a while.\n\n**1. Reimplement Ticket**\n- **What it does:** Gathers the context and attachments added since the last pass so a targeted follow-up change can be made.\n- **When it\'s useful:** (Implementation) After review feedback or new screenshots, when you need a targeted second pass rather than a fresh build.\n- **How to use it:** `/reimplement-ticket BAPI-123`\n- **Flags:** None.\n\n**2. Update Ticket**\n- **What it does:** Rewrites a ticket\'s description, fully replacing what is there today.\n- **When it\'s useful:** (Refinement) After review, to fold the resolved questions and fixes back into the ticket itself.\n- **How to use it:** Ask your agent \u2014 "Replace BAPI-123\'s description with: <new text>." It fully overwrites the live description, which is hard to reverse.\n- **Flags:** None.\n\n**3. Get Ticket**\n- **What it does:** Retrieves the full details of a ticket, including its summary, status, and description.\n- **When it\'s useful:** (Refinement | Implementation) Any time you want the agent to read a ticket before acting on it.\n- **How to use it:** Ask your agent \u2014 "Pull up BAPI-123 and show me its description, status, and acceptance criteria."\n- **Flags:** None.\n\n**4. Search Tickets**\n- **What it does:** Searches across the tickets in your project.\n- **When it\'s useful:** (Refinement) When you need to find tickets by project, status, or wording rather than by key.\n- **How to use it:** Ask your agent \u2014 "Search our project for open tickets mentioning rate limiting."\n- **Flags:** Narrow the search by project, status, issue type, or free text.\n\n**5. Write Comment**\n- **What it does:** Posts a comment on a ticket.\n- **When it\'s useful:** (Refinement | Implementation) To leave context, status, or a decision trail on the ticket.\n- **How to use it:** Ask your agent \u2014 "Post a comment on BAPI-123: blocked on the expired Atlassian token \u2014 will retry after it is rotated."\n- **Flags:** A long comment can be attached as a file instead of inlined.\n\n**6. Read Comments**\n- **What it does:** Reads the comment thread on a ticket.\n- **When it\'s useful:** (Refinement | Implementation) When the discussion on a ticket matters and you want the agent to read it before acting.\n- **How to use it:** Ask your agent \u2014 "Read the comments on BAPI-123 and summarize what was decided."\n- **Flags:** None.\n\n**7. Ticket Attachments**\n- **What it does:** Downloads files from a ticket to your disk, or attaches a local file to a ticket.\n- **When it\'s useful:** (Refinement | Implementation) When a ticket has design files or logs you need locally, or you want to attach output back to it.\n- **How to use it:** Ask your agent \u2014 "Download the design mockups attached to BAPI-123 into my docs folder," or "Attach build-log.txt to BAPI-123."\n- **Flags:** Choose the direction (download from the ticket, or upload to it) and, for a download, where the files should land.\n\n**8. Estimate Ticket**\n- **What it does:** Estimates the development effort for one ticket. Use Estimate Epic instead for a whole epic or a named group of tickets.\n- **When it\'s useful:** (Refinement) When you need a size for a single ticket before committing to it.\n- **How to use it:** Ask your agent \u2014 "Estimate BAPI-123."\n- **Flags:** Ask for a fresh estimate to regenerate rather than reuse a stored one.\n\n**9. Estimate Epic**\n- **What it does:** Estimates an epic, or an explicit group of tickets you name.\n- **When it\'s useful:** (Architecture | Refinement) When you need a sizing pass across an epic, or across a set of tickets you name explicitly.\n- **How to use it:** `/estimate-epic BAPI-123`\n- **Flags:** Pass an epic key, or an explicit list of ticket keys to estimate as one group.\n<!-- END GENERATED: mcp-tool-documentation -->\n\n### Workflow commands\n\nSlash commands that drive several tools at once. Start Tickets, Review and Start, and Explore Ticket are documented above under [Regularly useful](#regularly-useful) \u2014 the rest live here.\n\n**1. Implement Ticket**\n- **What it does:** Full build for one ticket: generate a plan, write the code, commit, open a PR, and monitor CI.\n- **When it\'s useful:** (Implementation) When a ticket is ready and you want it taken from plan to open PR in one go.\n- **How to use it:** `/implement-ticket BAPI-123` (command only \u2014 "implement X" as free text almost always triggers a freehand build instead of the Bridge plan\u2192code\u2192PR\u2192CI pipeline).\n- **Flags:** `--auto` skip the approval gates (e.g. auto-commit/push).\n\n**2. Jira Ticket Writer**\n- **What it does:** An agent that drafts a well-structured Jira ticket from a plain description, applying your project\'s standards.\n- **When it\'s useful:** (Refinement) When you have an idea in your head and want a properly-formatted ticket draft without writing it by hand.\n- **How to use it:** Ask your agent, *"Use the jira ticket writer to turn our conversation into a ticket."* The other ticket commands draft through it automatically.\n- **Flags:** None \u2014 name a specific standards file in your request to have it applied when drafting.\n\n**3. Idea to Ticket**\n- **What it does:** Turns a one-line idea into a Jira Task/Spike (or an Epic plus child tickets), with research, duplicate detection, and a critique pass built in.\n- **When it\'s useful:** (Refinement | Automation) When you have a rough idea and want a fully-formed, uploaded ticket without the manual draft-and-refine loop.\n- **How to use it:** `/idea-to-ticket <idea>`\n- **Flags:** None.\n\n**4. Plan Epic**\n- **What it does:** Decomposes a large epic into sub-tasks with a structured exploration doc for each.\n- **When it\'s useful:** (Architecture | Refinement) When a feature is too big for one ticket and you need it broken down and scoped.\n- **How to use it:** `/plan-epic <epic>` \u2014 *"Decompose the epic \'migrate PayPal token storage off Custom Objects\' into sub-tasks with an exploration doc for each."*\n- **Flags:** None.\n\n#### Ticket-authoring posture\n\n`/explore-ticket`, `/idea-to-ticket`, and `/plan-epic` all decide ticket shape\nthe same way, as does the `jira-ticket-writer` agent they draft through. A fresh install inherits this with no configuration\nstep and no server call; the full rationale and the closed exception list ship as\n`docs/bridge-ticket-authoring.md`.\n\n- **Drafted by the writer.** Every ticket body \u2014 epic parent, epic child, and\n ordinary sibling alike \u2014 goes through the `jira-ticket-writer` agent. Nothing\n composes a ticket description inline.\n- **Sized toward L, overflowing upward.** `L` (target) \u2192 `XL` (when the work\n does not fit in `L`) \u2192 `M` (third choice) \u2192 `S` (only when unavoidable). A\n slice that outgrows `L` becomes one `XL` ticket rather than two `L` ones \u2014\n splitting a coherent slice to fit a band buys another worktree, another PR, and\n another rebase for nothing. This binds a standalone ticket and an epic child\n alike. Past roughly 40 files or ~3000 LOC it splits anyway, into the largest\n coherent pieces available.\n- **Grouped at three.** Three or more tickets is an epic: an epic parent plus an\n ordered child manifest, shown in full at an approval gate before anything is\n created. One or two are ordinary siblings \u2014 no epic parent, no manifest. The\n threshold is exactly three.\n- **Decomposed once, rendered many.** One pass freezes the split; body drafting\n then fans out one writer invocation per entry against that frozen manifest. A\n rendering invocation never re-splits, merges, reorders, or rescopes.\n- **Handed off once.** An epic handoff names exactly one entry point,\n [`drive-epic`](#drive-epic) \u2014 never a choice between conductors.\n\n**5. Run Tests**\n- **What it does:** Runs the unit and E2E suites and autonomously triages/fixes failures (via the test-correction agent).\n- **When it\'s useful:** (Implementation) After making changes, to confirm everything passes and auto-fix straightforward breakages.\n- **How to use it:** `/run-tests`\n- **Flags:** `--unit-only` skip the E2E suite \xB7 `--skip-e2e` same, phrased the other way.\n\n**6. Learn Repository**\n- **What it does:** Researches and documents the repo\'s architecture, testing, review, and correctness standards, then saves them to Bridge for future agents.\n- **When it\'s useful:** (Setup/Learning) When onboarding a new repo, or after big changes, so Bridge\'s agents follow your conventions.\n- **How to use it:** `/learn-repository`\n- **Flags:** None.\n\n**7. Teach Bridge**\n- **What it does:** Takes a plain-English instruction, figures out which standards field it belongs to, and merges it in (admin only).\n- **When it\'s useful:** (Setup/Learning) When you notice the agents missing a convention and want to correct it in one sentence.\n- **How to use it:** `/teach-bridge <teaching>` \u2014 *"Teach Bridge: always use data-testid selectors in E2E tests."*\n- **Flags:** None.\n\n### Operational commands\n\nWorkflow commands you\'ll reach for during implementation and CI, beyond the tiers above:\n\n| Command | What it does |\n|---|---|\n| `/code-ticket PROJ-123` | Download the implementation plan and questions, then execute the plan inline |\n| `/commit-ticket PROJ-123` | Stage, commit, and push changes; transition Jira status; post a smoke-test comment |\n| `/create-pr PROJ-123` | Resolve the base branch and open a pull request for the ticket\'s branch (run after `/commit-ticket`) |\n| `/check-ci PROJ-123` | Monitor CI checks for the current branch, triage failures, apply fixes, and report results |\n| `/parse-repository` | Queue a background job to index the repository for Bridge AI agents |\n| `/check-parse-status` | Check whether a background repository parse job is still running |\n| `/scan-tickets` | Sync recently-updated Jira tickets and backfill workflow timestamps |\n\n> Commands are designed for Claude Code. Other editors may support slash commands differently \u2014 check your editor\'s documentation for how to invoke prompt files.\n\n### Extra Capabilities\n\nBehind-the-scenes capabilities an agent gains from the MCP tools \u2014 mostly invoked automatically by the commands above, rarely requested by name:\n\n- **Ship a PR end-to-end:** commit & push, open a pull request, transition the Jira status, and discover/poll CI checks (powers `commit-ticket`, `create-pr`, `check-ci`, `implement-ticket`).\n- **Architecture plan** for a ticket (design-level guidance, separate from the implementation plan).\n- **Index the codebase** so Bridge\'s agents can reason about it: queue/parse the repo, check parse status, regenerate the directory map.\n- **Read & tune project config/standards:** list/read/update config fields, fetch project standards, and the per-topic `learn-*` commands that populate them.\n- **Ticket lifecycle bookkeeping:** track tickets and backfill workflow-state timestamps (`scan-tickets`), search across tickets, read comments, list attachments.\n- **Pipeline machinery:** list/inspect pipeline recipes, run/resume/list/delete pipeline runs (the engine under the orchestration commands), and resume a full-automation chain that stopped at an approval gate or was interrupted.\n- **Decision page** generation for capturing human review decisions as structured data.\n- **Connectivity & identity checks:** ping Bridge, check your role, resolve the local docs directory.\n- **Retrieve any generated artifact** (`get_*` for plans, critiques, questions, councils, research, architecture) without regenerating it.\n- **Tiered-section execution telemetry** recording (internal measurement).\n\n## Salesforce B2C Commerce (SFCC) Tools\n\nSalesforce\'s official MCP server, `@salesforce/b2c-dx-mcp`, covers developer-experience tasks \u2014 cartridge deploy, step debugging, MRT bundle push, and SCAPI Custom API scaffolding. **As of `@salesforce/b2c-dx-mcp` 1.1.2 (published 2026-05-20)** it has no way to *read* the platform\'s object model, custom objects, or site configuration \u2014 exactly the context an AI coding agent needs to write correct Commerce code and to inspect a sandbox before touching it. That comparison is dated on purpose: its basis is this repository\'s hand-maintained [vendor manual](../docs/mcp/b2c-commerce-developer.md), pinned to the same version, so a new Salesforce toolset ages the claim visibly instead of rotting silently. Bridge\'s SFCC tools install side-by-side with `b2c-dx-mcp` (they don\'t duplicate its surface) and close that gap with **OCAPI Data API introspection** of system objects, custom object definitions, and site preferences \u2014 plus, behind a separate opt-in, a set of sandbox-bounded writes.\n\n**Every SFCC tool is restricted to a developer sandbox, and the restriction is checked at invocation time against the hostname your credentials actually resolve to** \u2014 not against anything the caller passes in. If `dw.json` or `SFCC_HOSTNAME` names a host Bridge does not recognize as a developer sandbox, every SFCC tool refuses with a `403` before contacting it. See [Sandbox enforcement](#sandbox-enforcement).\n\n**Credentials stay local** \u2014 in `dw.json` or `SFCC_*` env vars \u2014 and are never sent to Bridge. The `sfcc` profile registers read-only tools; the nine destructive write tools require the separate `sfcc-write` opt-in (see [Read and write profiles](#read-and-write-profiles)).\n\nFor a step-by-step OCAPI client setup guide (including the Business Manager permissions grant), see [docs/install/sfcc-integration.md](./docs/install/sfcc-integration.md).\n\n<details>\n<summary><strong>Setup</strong></summary>\n\nThe two diagnostic tools (`sfcc_setup_status`, `check_permissions`) are always available. The read tools, the write tools, and `sfcc_log_query` must be enabled with a profile (step 3). Changing `BRIDGE_MCP_PROFILE` requires an MCP client restart.\n\n**Prerequisites:** a running SFCC **sandbox** and its hostname, plus an Account Manager API client (`client-id` + `client-secret`).\n\n**1. Set the repo `version` config field** to your SFCC project type \u2014 one of `sfra | pwakit | sitegenesis | storefrontnext | hybrid`. The call-time gate reads this; a non-SFCC value blocks the OCAPI tools \u2014 the eight reads, the nine writes, and `check_permissions`. It does **not** block `sfcc_setup_status`, and it does not block `sfcc_log_query`: log query runs on its own gate, which reads neither the `version` field nor `dw.json` and instead probes the backend log capability (log access is WebDAV Basic auth, a different boundary from OCAPI\'s OAuth). Set the field via your normal config path, the `config_field` MCP tool (operation `update`, field `version`), or the `/teach-bridge` skill.\n\n**2. Provide SFCC credentials.** Create a `dw.json` in your project root:\n\n```json\n{\n "hostname": "zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com",\n "client-id": "<account-manager-client-id>",\n "client-secret": "<account-manager-client-secret>"\n}\n```\n\nAccepted key spellings: `hostname`/`host`, `client-id`/`clientId`/`client_id`, `client-secret`/`clientSecret`/`client_secret`. `dw.json` is auto-added to git exclude and must never be committed.\n\nCredentials resolve in **three tiers, highest first** \u2014 the environment wins over `dw.json`, not the other way round:\n\n1. An explicit dotted `instance` argument on the call, **plus** `SFCC_CLIENT_ID` and `SFCC_CLIENT_SECRET` in the environment. Secrets are never read from `dw.json` on this tier, so an explicit instance without those two env values is an error.\n2. `SFCC_HOSTNAME` **and** `SFCC_CLIENT_ID` **and** `SFCC_CLIENT_SECRET`, all three set.\n3. `dw.json`.\n\nBecause tier 2 outranks tier 3, a stale `SFCC_HOSTNAME` left in the environment silently wins over the `dw.json` you are looking at. Check both when a tool reports an unexpected host.\n\n**Use a single-config `dw.json`, or set all three `SFCC_*` variables.** A multi-entry `configs[]` array is **rejected outright** \u2014 it is not a working setup that merely requires an explicit `instance` on every call. Two things make that workaround unavailable: an explicit `instance` takes tier 1, which needs the client id and secret in the environment anyway, and most tools cannot accept a hostname at all \u2014 the value must contain a dot, and the site-preference tools constrain `instance` to `staging | development | sandbox | production`, none of which is a hostname.\n\n**3. Enable the tools you want.** Add the groups to `BRIDGE_MCP_PROFILE` in the MCP server `env` block (it is comma-separated), then **restart the MCP client**:\n\n```json\n"env": { "BRIDGE_MCP_PROFILE": "sfcc" }\n```\n\n`sfcc` gives the eight read tools plus `sfcc_log_query`. For the nine destructive write tools as well, use `"sfcc,sfcc-write"`; `full` expands to every group and is therefore write-capable. Without any of these, only the diagnostic tools are registered.\n\n**4. Verify and grant OCAPI access.** Ask your agent to run `sfcc_setup_status` (expect all checks \u2713), then `check_permissions`. If it reports HTTP 401/403, it prints the exact OCAPI Settings JSON to paste in Business Manager (**Administration \u2192 Site Development \u2192 Open Commerce API Settings \u2192 Data API** tab); save it there and re-run. Restart the MCP client after any credential or env change \u2014 a running session does not pick them up.\n\nRead what it prints before pasting it. The output is **two labelled blocks**, and they are not equivalent:\n\n- **READ/SEARCH TOOL GRANTS** \u2014 what the `sfcc` read tools need: `get` on `/system_object_definitions`, and `get` + `post` on `/system_object_definitions/**`, `/site_preferences/**`, and `/custom_object_definitions/**`. The `post` is OCAPI\'s convention for its `*_search` endpoints, not a mutation \u2014 but it is a grant you are pasting, so it is labelled for what it is rather than as "read-only".\n- **MUTATION GRANTS** \u2014 required by the nine `sfcc-write` tools and by nothing else: `put`/`patch` on `/system_object_definitions/**` and `/custom_object_definitions/**`, and `patch` on `/site_preferences/**`. Paste this block only if you intend to enable `sfcc-write`.\n\nNeither block grants `delete`, and neither pastes the global `resource_id: "/**"` that would cover every Data API resource. Each entry names one resource family \u2014 `/system_object_definitions`, `/custom_object_definitions/**`, `/site_preferences/**` \u2014 so the wildcard is scoped to the family, not to the API. Within a family it is still broad, and `write_attributes` is `(**)`, so a throwaway sandbox is the right place for these.\n\n</details>\n\n### Sandbox enforcement\n\nEvery SFCC tool \u2014 all twenty, reads and writes alike, including the diagnostics \u2014\npasses through one check before its own logic runs: **the hostname your\ncredentials actually resolve to must be a recognized developer sandbox.**\n\n- The check reads `credentials.hostname`, the value that goes into the OCAPI\n URL. It does not read the `instance` tool argument. Omitting `instance`, or\n passing `instance: "sandbox"`, has no effect on the decision \u2014 neither one\n selects or proves anything about the target. A dotted `instance` still\n *selects* a host through the documented credential precedence, but the host it\n selects is then validated like any other, so `check_permissions` cannot be\n aimed at a named production instance.\n- It **fails closed.** An unrecognized, malformed, or unparseable hostname is\n refused with HTTP `403`, `error.code: "TARGET_NOT_SANDBOX"`, and\n `error.details.failure_class: "target-not-sandbox"`, before any request leaves\n your machine.\n\nThe accepted hostname forms are:\n\n| Form | Example |\n|---|---|\n| `<realm>-<nnn>.sandbox.<region>.dx.commercecloud.salesforce.com` | `zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com` |\n| `<realm>-<nnn>.sandbox.dx.commercecloud.salesforce.com` | `zzzz-001.sandbox.dx.commercecloud.salesforce.com` |\n| `<realm>-<nnn>.dx.commercecloud.salesforce.com` | `zyis-001.dx.commercecloud.salesforce.com` |\n\nAnything else is refused. In particular, hosts whose leading label names an\nenvironment (`production-\u2026`, `staging-\u2026`, `development-\u2026`) are rejected even\nwhen they otherwise fit a form above, and the legacy `*.demandware.net` domain\nis not accepted at all \u2014 sandbox, staging, and production instances share that\ndomain with no suffix that separates them.\n\n### Read and write profiles\n\n`sfcc` and `sfcc-write` are **independent** groups. Neither implies the other.\n\n| `BRIDGE_MCP_PROFILE` | SFCC tools registered |\n|---|---|\n| unset / `core` | `sfcc_setup_status`, `check_permissions` only |\n| `sfcc` | the above + 8 OCAPI read tools + `sfcc_log_query` |\n| `sfcc-write` | the above diagnostics + the 9 destructive write tools |\n| `sfcc,sfcc-write` | all 20 |\n| `full` | all 20 \u2014 `full` includes `sfcc-write` and is therefore write-capable |\n\n**Migration.** Enabling `sfcc` used to register the nine write tools as well. It\nno longer does. If you were relying on SFCC writes through\n`BRIDGE_MCP_PROFILE=sfcc`, change it to `BRIDGE_MCP_PROFILE=sfcc,sfcc-write`.\nUsers of `BRIDGE_MCP_PROFILE=full` keep write access and need no change.\n\n### Tools\n\nTwenty tools in total: two always-on diagnostics, the `sfcc` profile\'s **read-only** surface (eight OCAPI reads plus `sfcc_log_query`), and the nine destructive writes that only the separate `sfcc-write` profile registers \u2014 see [Read and write profiles](#read-and-write-profiles). Every one of them is bounded to a developer sandbox by the same invocation-time check. All twenty are enumerated below.\n\nAn oversized response is saved in full to `BAPI_DOCS_DIR/sfcc/` and replaced by a parseable JSON descriptor \u2014 `truncated: true`, the `saved_path` it was written to, and the `page` metadata (`returned`, `total` when OCAPI supplied one, `has_more`) \u2014 so the collection metadata survives even though the data itself is on disk. If that save fails, the complete payload is returned inline instead, still as parseable JSON.\n\nAttribute-definition reads and writes can return an attribute\'s `default_value` at `projection: "full"`, and Bridge withholds it \u2014 every key is preserved except that one, whose value becomes `[REDACTED_BY_BRIDGE]` \u2014 from the inline response, the saved file, and a successful write echo alike. Attribute defaults are intentionally unavailable through this MCP surface; Business Manager is the supported path to read one.\n\n**Diagnostics** (always available, no profile needed)\n- `sfcc_setup_status` \u2014 report on every prerequisite: Bridge API key, repo name, `version` config, `dw.json` presence/uniqueness, AM (OCAPI) token acquisition, and the independent **SFCC Log Query (WebDAV)** capability that gates `sfcc_log_query`.\n- `check_permissions` \u2014 probe OCAPI access via `GET /system_object_definitions`; on 401/403, print the exact OCAPI Settings JSON to paste in Business Manager (read/search grants for the `sfcc` tools, mutation grants for the `sfcc-write` tools). An explicit `instance` hostname is still subject to the sandbox check below.\n\n**System object model \u2014 reads** (needs the `sfcc` profile)\n- `system_object_list` \u2014 list system object types (Product, Order, Customer, \u2026).\n- `system_object_get` \u2014 fetch one system object type\'s definition.\n- `system_object_attribute_search` \u2014 search a type\'s attribute definitions; prefer this over a full dump when hunting a specific `c_` custom attribute.\n\n**System object model \u2014 writes** (needs the `sfcc-write` profile; every one is a destructive write, sandbox only)\n- `system_object_attribute_definition_create` \u2014 create an attribute definition via `PUT /system_object_definitions/{type}/attribute_definitions/{id}`.\n- `system_object_attribute_definition_update` \u2014 update one via `PATCH` on the same path.\n- `system_object_attribute_group_create` \u2014 create an attribute group via `PUT /system_object_definitions/{type}/attribute_groups/{id}`.\n- `system_object_attribute_group_update` \u2014 update one via `PATCH` on the same path.\n- `system_object_attribute_assign_to_group` \u2014 assign an existing attribute definition into a group via `PUT \u2026/attribute_groups/{group}/attribute_definitions/{def}`.\n- `custom_preference_definition_create` \u2014 define a custom site or organization preference via `PUT /system_object_definitions/{SitePreferences|OrganizationPreferences}/attribute_definitions/{id}`.\n\n**Custom object definitions** (reads need `sfcc`; the two writes need `sfcc-write`)\n- `custom_object_definition_attributes_get` \u2014 fetch attribute definitions for a known custom object type (`default_value` withheld). OCAPI cannot enumerate custom object type *IDs* directly, so `object_type` must be known \u2014 but it is discoverable: call `system_object_list` at `projection: "full"` for each custom type\'s `display_name` and `attribute_definition_count`, derive a candidate id (e.g. strip spaces from `"Product Quality Result"` \u2192 `ProductQualityResult`), and confirm it by checking that this tool\'s returned attribute count matches that row\'s `attribute_definition_count`.\n- `custom_object_definition_attribute_search` \u2014 search attribute definitions within a known custom object type (`default_value` withheld). Read-only \u2014 creating a custom object *type* isn\'t possible via OCAPI; that\'s a future v2 metadata-import capability. Same discovery path as above applies to `object_type`.\n- `custom_object_definition_attribute_create` \u2014 **write** (sandbox only): create an attribute definition on a known custom object type via `PUT /custom_object_definitions/{type}/attribute_definitions/{id}`. TYPE creation is never attempted (the type must pre-exist). Echoes paste-ready OCAPI grant JSON on 403.\n- `custom_object_definition_attribute_update` \u2014 **write** (sandbox only): update an attribute definition via an ETag-conditional `PATCH \u2026/attribute_definitions/{id}`; surfaces 409/412 conflicts and echoes grant JSON on 403.\n\n**Site preferences** (reads need `sfcc`; the write needs `sfcc-write`; sandbox only)\n- `site_preference_group_list` \u2014 list the preference groups on a site. This is the discovery tool the other two reads depend on: both take a group, and this is how you find one.\n- `site_preference_get` \u2014 list the preference **identifiers** in a group.\n- `site_preference_search` \u2014 search/filter preference identifiers within a group.\n- `site_preference_values_set` \u2014 **write** (sandbox only): set custom preference values via `PATCH /site_preferences/preference_groups/{group}/sandbox` with a flat map of `c_`-prefixed ids to string/number/boolean/string[] values. A bad group returns 404 `CustomPreferenceGroupNotFoundException`; echoes grant JSON on 403.\n\n> **Site preference values are write-only through this surface.** `site_preference_get` and `site_preference_search` return **ids only, never values** \u2014 an unset preference and one set to the empty string are indistinguishable. So you can *set* a value with `site_preference_values_set` and have no way to read it back through an MCP tool. Business Manager is the supported path to read a preference value.\n\n**On-demand log query** (needs the `sfcc` profile)\n- `sfcc_log_query` \u2014 query redacted, filtered SFCC logs on demand. `environment` and `time_range` (`start`/`end`) are **required** \u2014 production, "all environments", and an open-ended period are never inferred. The tool calls a Bridge backend endpoint that runs the pull \u2192 redaction \u2192 filter pipeline server-side and returns scoped, redacted findings; **WebDAV credentials, retrieval, redaction, and filtering all stay server-side and single-sourced.** It holds no credentials of its own.\n - **Guardrails.** Selection is bounded by log-file `prefixes` (max 5), the time range, a scanned-entry cap (`max_entries`, \u2264 2000), a finding cap, and a per-snippet length cap. High-volume prefix classes (`info`, `jobs`, `debug`, `customdebug`) impose a **stricter 6-hour** max range (vs. 24h for the error class) because `info-*` runs ~1 MB/day versus `error-*` at ~13 KB median \u2014 a wide window over a high-volume prefix is **rejected**, never silently narrowed.\n - **Response order.** Resolved scope (`environment`, `time_range`, `applied_prefixes`) and cap `status` first, redacted `findings` second, retrieval/truncation `metadata` last.\n - **Statuses & errors.** `ready`, `no_matching_findings`, `results_truncated`; plus `VALIDATION_ERROR` (bad/oversized scope, caught before any network call), `NOT_CONFIGURED` (503 \u2014 the log capability isn\'t set up; run `sfcc_setup_status`, whose step 6 reports it), and `BAD_GATEWAY`/`SERVICE_UNAVAILABLE` on a retrieval/backend failure.\n - **Auth is separate from OCAPI.** Log retrieval uses **HTTP Basic auth** \u2014 a Business Manager username + a **40-character WebDAV access key** \u2014 *not* the OCAPI Account Manager OAuth token the other SFCC tools use. A valid AM bearer token 401s on `/Logs`. `sfcc_setup_status` step 5 (AM/OCAPI token) and step 6 (WebDAV log access) are independent: one can be green while the other is not.\n - **Local / air-gapped fallback.** The primary path above is the only path this tool takes. For air-gapped development, the documented fallback is Salesforce\'s own **`@salesforce/b2c-cli`**, shelled out to directly: `b2c logs get --since <window> --search <q> --json`. There is no MCP alternative to reach for \u2014 `@salesforce/b2c-dx-mcp` ships **no `logs_*` tool** as of 1.1.2, and every log workflow in the vendor toolkit goes through the CLI anyway (see the [vendor manual](../docs/mcp/b2c-commerce-developer.md)). It is **not** the primary path because its credentials live client-side and its output has **not** passed Bridge\'s redaction/filter. If you use it, its output must be treated as raw: route it back through the same server-side Python `LogSource` composition and `RedactionPort`/T3 filter workflow \u2014 never paste or relay unredacted CLI output to an LLM.\n\n## CLI Subcommands\n\nBeyond `--init` / `--upgrade`, the package ships operational subcommands of the **single `bridge-api-mcp-server` bin** (not separate binaries) \u2014 so they travel with the package to every consumer. See [Usage Documentation \u2192 Start Tickets](#regularly-useful) for *when* to use `start-tickets`; this section is the full CLI reference.\n\n### `start-tickets`\n\nSpawns one Worktrunk worktree + selected-agent session per Jira ticket and backs the `/start-tickets` slash command. The agent defaults to **Claude Code** (`claude`) and is configurable via `--agent`. For existing ticket keys, `/review-and-start` (see [Usage Documentation \u2192 Review and Start](#regularly-useful)) is the recommended enriched front door over this CLI\'s `--workflow review-and-implement` seam; using the CLI directly (below) remains the advanced/lower-level path.\n\n```\nnpx -y @bridge_gpt/mcp-server start-tickets [flags] KEY [KEY ...]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--agent claude\\|cursor-agent` | `claude` | Agent command to launch in each worktree |\n| `--workflow implement\\|review-and-implement` | `implement` | Slash command each spawned worktree runs. `implement` is byte-identical to today\'s `/implement-ticket <KEY> [--auto]`. `review-and-implement` spawns `/review-and-implement <KEY> [--auto] [--rounds=<n>]`, which runs `/review-ticket` then, after a per-ticket halt gate, `/implement-ticket` in the same session. `--auto` applies to the selected workflow as a whole. |\n| `--rounds 1\\|2` | unset | Review round count forwarded to the review phase. Review-only \u2014 valid only with `--workflow review-and-implement`. |\n| `--tier cheap\\|basic\\|premium` | unset (difficulty routing) | Coarse model-routing override. Bypasses **only** the per-ticket difficulty/tier lookup (`GET /jira/tickets/{KEY}/model-tier`) and applies this one tier to every ticket; the tier is still mapped to a model through the centralized agent registry and any configured `difficulty_model_tier_overrides`, then validated. It is **not** a raw `--model` alias and never carries an API key or credential. A malformed value fails open to premium routing. Used by the `/review-and-implement` handoff to reuse the review-time tier. |\n| `--terminal terminal\\|iterm` | auto-detect via `$TERM_PROGRAM` | Override the macOS terminal app (honored on macOS only) |\n| `--dry-run` | off | Print intended actions; create no worktrees, open no tabs (any OS) |\n| `--branch KEY=BRANCH` | `feature/<KEY>` | Use a custom branch for that ticket (repeatable) |\n| `--base-branch <BRANCH>` | `main` | Cut new worktrees from `<BRANCH>` and refresh `origin/<BRANCH>` instead of `main` |\n| `--no-refresh-main` | off (the configured base branch is refreshed) | Skip refresh of the configured base branch (default `main`). Historical flag name preserved for backward compatibility \u2014 despite the name, it now skips refresh of whatever `--base-branch` resolves to. |\n| `--max-parallel N` | `3` | Max worktrees created concurrently |\n| `--conductor` | off | Opt into the Conductor system (per-worker `BAPI_CONDUCTOR_*` env + Claude hook injection, a supervisor peer tab, and the `check_messages` message-relay prompt). **Default off** \u2014 a plain run spawns `cd <worktree> && <agent> \'/implement-ticket <KEY>\'`. |\n| `-h`, `--help` | \u2014 | Show usage |\n\nEach `KEY` must match `[A-Z]+-[0-9]+` (e.g., `BAPI-248`). The CLI creates/switches each worktree up front (throttled by `--max-parallel`), then opens one tab/session per successful worktree running the selected agent\'s `\'/implement-ticket <KEY>\'` \u2014 `claude \'/implement-ticket <KEY>\'` by default, or `cursor-agent \'/implement-ticket <KEY>\'` with `--agent cursor-agent`. The `/implement-ticket <KEY>` prompt is unchanged for both agents. To launch Cursor Agent instead of Claude Code:\n\n```\nnpx -y @bridge_gpt/mcp-server start-tickets --agent cursor-agent BAPI-248\n```\n\n**Difficulty-based model routing.** Before launching each agent, the CLI selects an implementation **model tier** from the ticket\'s `difficulty` (1-2 \u2192 cheap, 3-5 \u2192 basic, 6+ \u2192 premium) and injects it as a `--model` flag at the spawn boundary. The Python backend returns only the coarse tier (`GET /jira/tickets/{KEY}/model-tier`, computing + caching difficulty on demand); this CLI alone maps a tier to the agent-specific alias (`claude`: `haiku`/`sonnet`/`opus`; `cursor-agent`: version-suffixed strings validated against `cursor-agent --list-models`). It is gated per repo by `difficulty_model_routing_enabled` (default **ON**) with an optional `difficulty_model_tier_overrides` JSON map (tier \u2192 alias). Routing is **fail-open**: missing credentials, an evaluation failure/timeout, a backend `fallback`, an invalid/unavailable alias, an unadvertised Cursor model, or an agent without `--model` support all default to the **premium** (Opus) model \u2014 and when even the premium alias cannot be resolved/validated for the agent, `--model` is omitted (the agent uses its default) \u2014 each surfaced as a per-ticket warning rather than failing the spawn. `--dry-run` does **not** create worktrees or open tabs, but it **does** resolve routing read-only to preview the `--model` each tab would use.\n\n**Coarse `--tier` override.** Passing `--tier cheap|basic|premium` bypasses **only** the per-ticket difficulty lookup above and applies that one tier to every ticket; the tier is still resolved to an alias through the same agent registry + `difficulty_model_tier_overrides` and validated the same way (including the live `cursor-agent --list-models` check). It is never treated as a raw `--model` alias, and no API key or credential belongs in the spawned command (the CLI resolves credentials itself via `resolveBapiCredentials`). A malformed/unrecognized `--tier` value is **fail-open**: the CLI logs one concise warning and routes every ticket on the premium (Opus) fallback rather than aborting. `/review-and-implement` uses this flag to hand its review-time tier snapshot to the fresh implementation session.\n\n**Conductor observability (opt-in via `--conductor`, BAPI-394).** Conductor is **off by default**. With `--conductor`, a run mints a conductor `run_id`, emits events into the local ledger (`~/.config/bridge/events.db`), opens a supervisor peer tab, and (for Claude Code) injects a secret-free lifecycle hook into each worktree; it also sets `BRIDGE_MCP_PROFILE=conductor` so workers get the conductor MCP tools. Observability is best-effort \u2014 a conductor failure never blocks a spawn, and `--dry-run` performs no conductor side effects. (Epic-tick dispatch always enables conductor internally.) See **[CONDUCTOR.md](./docs/CONDUCTOR.md)** for the full observability, git-hook, done-gate, and auto-merge reference.\n\n**Cross-platform spawning.** The CLI routes spawning per platform; `--dry-run` previews the platform-correct command form on any OS. An unsupported `process.platform` (not `darwin`/`win32`/`linux`) fails fast with a clear "unsupported platform" message.\n\n- **macOS** \u2014 opens a Terminal.app or iTerm tab via `osascript`.\n- **Windows** \u2014 creates worktrees with **`git-wt`** (Worktrunk\'s winget alias) and opens a tab via **Windows Terminal (`wt.exe new-tab`)**, falling back to **`Start-Process powershell.exe`** when Windows Terminal is absent. Requires **Git for Windows / Git Bash** (Worktrunk runs its `pre-start` / `post-start` hooks via Git Bash). The Worktrunk binary (`git-wt`) and the tab launcher (`wt.exe`) are resolved independently and never conflated.\n- **Linux** \u2014 creates one detached **tmux** session per ticket (pane kept open after the agent exits); attach with `tmux attach -t <session>`. A missing `tmux` produces a clear, actionable error.\n\nPer-OS prerequisites: macOS `wt`, `git`, `osascript`; Windows `git-wt`, Git for Windows / Git Bash, Windows Terminal or PowerShell; Linux `wt`, `git`, `tmux`. Set `BAPI_WORKTRUNK_BIN` to override the Worktrunk executable name/path for nonstandard installs (`doctor` honors it too). The read-only `doctor` subcommand (below) additionally surfaces a missing `uv` \u2014 Worktrunk\'s `pre-start` hook runs `uv`, but live preflight does not check it \u2014 and the selected agent\'s command; run `doctor --agent cursor-agent` to also check `cursor-agent` (it prints `cursor-agent login` as an informational auth reminder).\n\n### `doctor`\n\nThe package also ships a strictly **read-only** `doctor` subcommand \u2014 titled **`bridge doctor \u2014 read-only diagnostics`** \u2014 that diagnoses your whole Bridge install without changing anything:\n\n```\nnpx -y @bridge_gpt/mcp-server doctor [--agent <name>]\n```\n\nThe report always leads with the advisory **`Install status`** section (repo identity, credential resolution, server connectivity, bootstrap-field completeness, integration credentials, indexing state) **before** the `start-tickets` prerequisite diagnostics; the launcher-cache and MCP tool-surface sections follow. `Install status` is read-only GETs only and never affects the exit code.\n\nThe report also includes a **Claude login** advisory: whether the host\'s own\n`~/.claude.json` carries a login marker. This is informational only \u2014 it never\nblocks the doctor run and cannot guarantee the next worker spawn will\nauthenticate. See\n[Claude login for conductor workers](#claude-login-for-conductor-workers).\n\nIt is **read-only**: it never installs anything, modifies your system, adds an npm `postinstall`, spawns a terminal, or starts the MCP server, and there is no `--fix`. For each prerequisite it prints found/missing and, when missing, the exact per-OS install command **as a manual instruction you run yourself**. The checked set is the `start-tickets` preflight prerequisites **plus `uv`** **plus the selected agent\'s command** (`claude` by default, or `cursor-agent` with `--agent cursor-agent`). The Worktrunk binary is probed via the resolved name (honoring `BAPI_WORKTRUNK_BIN`), not a hard-coded one. **Exit code:** `0` when all required prerequisites are present, non-zero when any is missing or the platform is unsupported. A failing `start-tickets` preflight now hints you to run `doctor` for an actionable diagnostics report.\n\n### `drive-epic`\n\nThe one conductor entry point every Bridge surface names. Give it an epic key and\nit reads conductor readiness for your repository and routes to the single path\nyour project can actually run:\n\n```\nnpx -y @bridge_gpt/mcp-server drive-epic <EPIC>\n```\n\nYou are never asked to choose. Bridge currently has two conductors and a standing\nrule that they must never operate on the same epic \u2014 two transition authorities on\none epic wedge it permanently \u2014 so the choice is made structurally rather than by\njudgement. Readiness green routes to the v2 bootstrap below (pass `--plan-file`\nand `drive-epic` runs it for you); readiness not green prints the interactive\npilot instruction instead. If readiness is **unknown** \u2014 unreachable,\nunauthorized, or malformed \u2014 it escalates and prints no conductor invocation at\nall, because an unknown owner is not the same as a not-ready one. No branch,\nincluding every error path, ever offers you two paths.\n\nTwo conductors is a transitional state. When one is eliminated, `drive-epic` is\nthe only thing that changes.\n\n### `setup-epic`\n\nBootstraps an Epic Conductor v2 run in one command \u2014 creates the epic run, stores the plan DAG, and approves it:\n\n```\nnpx -y @bridge_gpt/mcp-server setup-epic --epic-key <KEY> --plan-file <path-to-epic-plan.dag.json>\n```\n\nThe plan sidecar is produced by the `decompose-epic` pipeline step. `setup-epic` validates it locally (unique ticket keys, resolvable dependency references, acyclicity) before sending anything, so a malformed plan fails legibly instead of as a bare HTTP 400. It is **idempotent**: re-running it on an epic that already has a live run reuses that run rather than minting a second one. `--dry-run` validates and previews the calls without mutating anything; `--json` emits a single machine-readable result object.\n\nOnce the plan is approved, the **server-side reconciler** picks the run up within ~30s. To execute claimed jobs on your machine, run `executor`:\n\n```\nnpx -y @bridge_gpt/mcp-server executor --repo <name>\n```\n\n### `conduct-epic`\n\nThe deterministic half of the `/conduct-epic` loop: it owns the epic branch, a\nversioned local checkpoint, a per-epic lock, and the read-only probes the loop\ndecides on. It never creates or mutates an `epic_run` \u2014 that is the server-side\nv2 reconciler\'s job, and `init` refuses to start when one is already active.\n\n```\nnpx -y @bridge_gpt/mcp-server conduct-epic <verb> [flags]\n```\n\n**Verbs**\n\n| Verb | Flags |\n| --- | --- |\n| `init <EPIC>` | `--tickets K1,K2,\u2026` (required), `--base-branch <b>`, `--checkpoint-path <p>`, `--dry-run`, `--json` |\n| `status <EPIC>` | `--json` (required), `--checkpoint-path <p>` |\n| `checkpoint set <EPIC>` | `--ticket <KEY>` (required), `--field <name> <value>` (repeatable), `--journal "<line>"`, `--checkpoint-path <p>` |\n| `finish <EPIC>` | `--checkpoint-path <p>`, `--json` |\n| `spawn <EPIC>` | `--ticket <KEY>` and `--prompt-file <path>` (required), `--agent claude\\|cursor-agent`, `--checkpoint-path <p>`, `--json` |\n\n**Local state.** Everything lives *outside* the repository, under\n`~/.config/bridge/conduct/<repo>/` (honoring `XDG_CONFIG_HOME`), so it resolves\nidentically from the main checkout and from any worktree and can never be\ncommitted by an agent running `git add`:\n\n| Path | Purpose |\n| --- | --- |\n| `<EPIC>.json` | the version-1 checkpoint (file `0600`, directory `0700`) |\n| `<EPIC>.json.prev` | the previous valid checkpoint, retained on every write |\n| `<EPIC>.lock` | the per-epic lock |\n| `<EPIC>/prompts/<KEY>-<kind>-<n>.md` | prompt files the caller writes for `spawn` |\n\n`status` prints the resolved `checkpoint_path`. To unpark a run a human edits the\ncheckpoint (`needs_human` \u2192 `null`, plus the ticket\'s `status`/counters);\n`last_seen_head`, `ci_last_poll`, and `lock` are observational and are never\nhand-edited.\n\n**`init` runs ONE preflight** that reports *every* failure in a single pass and\nwrites nothing unless all of them pass: `gh auth status`; Worktrunk resolves\n(honoring `BAPI_WORKTRUNK_BIN`); Bridge credentials resolve; `auto_merge_enabled`\nis on \u2014 or is turned on by PUTting the *complete* effective config back with just\nthat flag flipped, which prints a line beginning `announced:`; at least one\nrequired CI check exists (an empty required set would make the done gate pass\nvacuously); no active server-side `epic_run` for the key; the lock is free or its\nowner is provably dead; the base branch exists on `origin` after `git fetch`; and\nthe indexed-branch override is either absent or this epic\'s own \u2014 a re-`init`\nafter a crash is accepted and its `original_base_branch` becomes the default base,\nwhile a *foreign* override is refused by name. `resolve-ci-checks` is called\nexactly once either way, because that call is what warms the `poll-ci-checks`\ncache the first `status` depends on. Only then does `init` push\n`epic/<EPIC>` to `origin` at the fetched base tip (no local checkout), repoint the\nindex, write the checkpoint, and take the lock. `--dry-run` prints the validated\nplan and mutates nothing. A second `init` refuses with `already initialized`.\n\n**Failure posture is split on purpose.** In `status`, each probe fails *open*: a\n`gh`, CI, review, or parse failure leaves that sub-object `null`, adds an entry to\n`probe_errors`, and the command still exits `0` with a complete object \u2014 the loop\nmust be able to read its own checkpoint during a GitHub outage. Everything else\nfails *closed*: a corrupt or wrong-version checkpoint makes every verb but `init`\nexit non-zero **without rewriting it**, and `checkpoint set`, `spawn`, and\n`finish` refuse a lock held by another live process. `status` never takes the lock.\n\n**Exit codes.** `0` on success \u2014 including a missing checkpoint\n(`checkpoint_exists: false`) and an idempotent second `finish`. Non-zero on any\nother failure, with a one-line reason on stderr. With `--json`, stdout is exactly\none JSON object carrying `ok`.\n\n**Credentials** resolve only from `BAPI_API_KEY` or the user-scoped\n`bapi:<repo>` credential target, travel only in the `X-API-Key` header, and never\nappear in a command argument, in stdout/stderr, or in a journal line.\n\n### Conductor (epic & multi-agent orchestration)\n\nConductor is an **opt-in, off-by-default** layer for epic supervision, inter-agent messaging, done-gate evaluation, local git-hook event producers, and conditional auto-merge. Its full reference \u2014 the v2 architecture (server-side reconciler + local executor), `setup-epic`, `conductor install-git-hooks`, the supervisor `done_gate_config` / `auto_merge_enabled` settings, and the observability stream \u2014 lives in **[CONDUCTOR.md](./docs/CONDUCTOR.md)**.\n\n> The v1 `conductor epic-tick` command is **frozen** \u2014 it throws on every invocation. There is nothing to schedule locally, and `conductor doctor` flags any epic-tick schedule left over from an earlier release so you can cancel it.\n\n## Custom Pipelines\n\nYou can create your own pipelines by adding JSON files to `.bridge/pipelines/`. Running `--init` scaffolds this directory with a `README.md` and an example pipeline to get you started.\n\nThe easiest way to write a custom pipeline is to describe what you want to automate to your AI coding agent and have it draft the JSON for you. The schema is straightforward, and agents like Claude Code understand it well \u2014 just describe the steps you want, and the agent will produce a working pipeline file.\n\n**What you can build:**\n\n- Any sequence of Bridge MCP tool calls and free-form agent tasks\n- Parameterized workflows using variables (e.g., `{ticket_key}`)\n- Approval gates that pause for user confirmation before sensitive steps\n- Per-step error handling \u2014 halt immediately or warn and continue\n\n**Ideas for custom pipelines:**\n\n- A standup pipeline that fetches your open tickets and summarizes their status\n- A ticket triage pipeline that runs critiques on a batch of new tickets\n- A pre-merge checklist that runs tests, checks linting, and posts a summary comment\n\n**Step types:**\n\n| Type | What it does |\n|---|---|\n| `mcp_call` | Calls an MCP tool with the given params |\n| `agent_task` | Gives the AI a free-form instruction (inline or from a file in `.bridge/instructions/`) |\n\nVariables are declared in the `variables` array and referenced as `{variable_name}` in params and instructions. Each step supports `on_error: "halt"` (default) or `"warn_and_continue"`, and `requires_approval: true` to pause before execution.\n\n**System variables:**\n\nTwo variables are automatically available in every pipeline without declaring them:\n\n| Variable | What it does |\n|---|---|\n| `{provider}` | Routes AI generation to a specific LLM provider (`openai`, `anthropic`, or `gemini`). Pass it through to any `request_*` tool param to control which provider handles that step. Omit it (or leave it empty) to use the project default. |\n| `{second_opinion}` | Runs AI generation through a different provider than the default, acting as a cross-check. Set to `"auto"` to let Bridge pick the second provider automatically. When set, it takes precedence over `{provider}`. Use this when you want two independent AI perspectives on the same task \u2014 for example, running clarifying questions and a critique twice (once with each provider) produces better results than a single pass. |\n\nSee `.bridge/pipelines/README.md` for the full schema reference.\n\nIf a custom pipeline has the same key as a built-in pipeline, the custom version takes precedence (a warning is logged at startup).\n\n## Environment Variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `BAPI_BASE_URL` (server, installer) | No | `https://bridgegpt-api.com` | Bridge API base URL. The MCP server and the `install` CLI both fall back to the production default |\n| `BAPI_BASE_URL` (`executor` subcommand) | **Yes** | _(none)_ | The `executor` deliberately has **no** production fallback \u2014 it refuses to start rather than guess a target |\n| `BAPI_REPO_NAME` | Yes | _(none)_ | Jira project/repository identifier configured in Bridge API |\n| `BAPI_API_KEY` | No | _(none)_ | A Bridge credential **is** required; this environment variable is only the first place the server looks for it. When it is unset the server resolves the credential from the user-scoped store (`~/.config/bridge/credentials.json`, target `bapi:<repo>`), which is why generated MCP registrations are secret-free |\n| `BAPI_PROJECT_ROOT` | No | _(see fallback order)_ | Absolute path to project root. Anchors `BAPI_DOCS_DIR` and `BAPI_PIPELINES_DIR` resolution. Resolved once, in order: `BAPI_PROJECT_ROOT` \u2192 the connected client\'s MCP `roots/list` \u2192 `CLAUDE_PROJECT_DIR` \u2192 `process.cwd()`. Several paths *write* it into a generated registration (`--init`, host-config provisioning, the worktree `mcp-invoke` shim) \u2014 that is provenance, not a runtime default |\n| `SFCC_HOSTNAME` | No | _(none)_ | SFCC sandbox hostname. Part of the environment credential tier \u2014 `SFCC_HOSTNAME`, `SFCC_CLIENT_ID`, and `SFCC_CLIENT_SECRET` must **all three** be set for that tier to apply, and a complete tier takes precedence over `dw.json` |\n| `SFCC_CLIENT_ID` | No | _(none)_ | Account Manager API client id. See `SFCC_HOSTNAME` \u2014 all three are needed together. Also required on its own when a tool is called with an explicit dotted `instance` |\n| `SFCC_CLIENT_SECRET` | No | _(none)_ | Account Manager API client secret. See `SFCC_HOSTNAME` \u2014 all three are needed together. Never sent to Bridge; it goes only to the Account Manager token endpoint |\n| `CLAUDE_CODE_OAUTH_TOKEN` | No | _(none)_ | The supported headless authentication input for conductor workers. Export it into the **executor process\'s own** environment; Bridge forwards it unchanged into the worker and stores it nowhere \u2014 no credential-store entry, no disk, never sent to Bridge. See [Claude login for conductor workers](#claude-login-for-conductor-workers) |\n| `BAPI_INSTALL_DEBUG` | No | _(unset)_ | Set to any non-empty value to unlock raw diagnostics in `install` and the `apply_install_manifest` path \u2014 the underlying error message and stack behind an `unexpected error` summary. The installer\'s own failure text tells you to set it |\n| `BAPI_SIGNUP_EMAIL` | No | _(none)_ | Selects the self-serve signup route without `--email`. Precedence: `--email` first, then this variable, then the visible interactive prompt |\n| `BAPI_INVITE` | No | _(none)_ | Invite code for `install`, for scripting. Like `--invite <code>`, it exposes the code to your shell history and the process list \u2014 prefer bare `--invite` and the hidden prompt |\n| `BAPI_PLANE_PYTHON` | No | `python` | Executable used for the Python members of `plane up`. Point it at a venv interpreter when `python` on `PATH` is not the one you want |\n| `BAPI_PLANE_UVICORN` | No | `uvicorn` | Executable used for the server member of `plane up` |\n| `BAPI_DOCS_DIR` | No | `docs/tmp` | Local directory for saving plans, critiques, and research reports |\n| `BAPI_PIPELINES_DIR` | No | `.bridge/pipelines` | Directory for user-defined custom pipeline JSON files |\n| `BAPI_WORKTRUNK_BIN` | No | `wt` (`git-wt` on Windows) | Override the Worktrunk executable name/path used by `start-tickets` for nonstandard installs |\n| `BAPI_TMUX_SESSION` | No | `bridge-start-tickets` | Override the tmux session-name prefix used by `start-tickets` on Linux |\n| `BAPI_MCP_UPGRADE_ADVICE_ENABLED` | No | _(enabled)_ | MCP-local opt-out for proactively surfacing upgrade advice in pipeline recipe preambles. Set to `false`/`0`/`no`/`off`/`disabled` to suppress. Disabling it does **not** change the `/jira/ping` response or server-side upgrade computation \u2014 it only gates the recipe-preamble convention |\n| `BAPI_MCP_TOOL_SURFACE_GATING_ENABLED` | No | _(enabled)_ | MCP-local kill switch for **dynamic tool-surface capability gating** (see [Dynamic tool-surface gating](#dynamic-tool-surface-gating-capability-availability)). Default-on; set to `false`/`0`/`no`/`off`/`disabled` to skip the startup probe, the recurring poll, and the custom `tools/list` handler entirely, restoring the SDK\'s previous full profile-derived surface. Fail-open: any probe timeout, unreachable backend, non-2xx, malformed payload, incomplete evaluation, or unsupported schema advertises the full profile |\n| `BAPI_MCP_TOOL_SURFACE_POLL_ENABLED` | No | _(disabled)_ | Opt IN to the recurring tool-surface **poll**. Default-**off**: a session gates once via the startup probe and never re-probes. Set to `true`/`1`/`yes`/`on`/`enabled` to restore the jittered 12\u201318 s heartbeat that pushes `notifications/tools/list_changed` on mid-session capability changes. No effect when gating itself is disabled |\n| `BAPI_MCP_UPDATE_CHECK_ENABLED` | No | _(enabled)_ | MCP-local kill switch for the cached update check run at startup. Default-on; set to `false`/`0`/`no`/`off`/`disabled` to skip it entirely \u2014 no npm registry request, no update-cache read/write, no stderr advice, and no `tools/list` advisory decoration. Any other value is treated as enabled. Useful behind a firewall/registry-restricted network, and used by the payload-measurement harness so a stale local cache can never perturb a capture |\n| `CURSOR_API_KEY` | No | _(none)_ | API key used to authenticate `cursor-agent` when launching `start-tickets`/scheduled runs with `--agent cursor-agent`. Not needed for the default Claude Code agent |\n| `BRIDGE_MCP_PROFILE` | No | `core` | Startup-time tool registration profile \u2014 a **comma-separated** list of groups controlling which tool groups are registered when the server starts. `core` is always implicitly included. Valid groups: `core` (default \u2014 normal coding tools only), `conductor` (+ 8 conductor/event/supervisor tools), `pipeline-authoring` (+ 5 pipeline run/admin tools \u2014 `get_pipeline_recipe` is NOT gated; it stays in `core` because the recipe-driven slash commands depend on it), `sfcc` (+ the 8 heavy SFCC read tools and `sfcc_log_query` \u2014 read-only, see [Salesforce B2C Commerce (SFCC) Tools](#salesforce-b2c-commerce-sfcc-tools); the `sfcc_setup_status`/`check_permissions` diagnostics are always registered regardless), `sfcc-write` (+ the 9 destructive SFCC write tools \u2014 independent of `sfcc`, which does not enable them; see [Read and write profiles](#read-and-write-profiles)), and `full` (shortcut that expands to every group, **including `sfcc-write`**). Example: `sfcc,conductor`; use `sfcc,sfcc-write` for reads plus writes. Unknown, blank, or malformed tokens are dropped (falling back to `core`). Dynamic mid-session switching via `tools/list_changed` is unsupported \u2014 groups are resolved once at process startup. Conductor/epic sessions launched via `start-tickets --conductor` **merge** `conductor` into the parent process\'s already-resolved groups at the spawn boundary rather than replacing them \u2014 a project running on `sfcc` spawns workers on `core,sfcc,conductor`. A normal `start-tickets` run stays on `core`. |\n\nEnvironment values are **trimmed**, and only a non-empty result wins. A\nwhitespace-only `BAPI_API_KEY` therefore does not override anything: it falls\nthrough to credential-store resolution exactly as an unset variable would.\n\n## Dynamic tool-surface gating (capability availability)\n\nThe **effective advertised tool surface** is the intersection of three things:\n\n1. **Startup profile registration** \u2014 which tool groups `BRIDGE_MCP_PROFILE`\n registered at process start (see above).\n2. **Current SDK-enabled state** \u2014 a tool the server has disabled for another\n reason (e.g. `poll_ci_checks` when `ci_check_config` is unset) stays hidden.\n3. **Backend capability availability** \u2014 the set of tool IDs the backend would\n currently hard-block for this repo, reported by `GET /jira/mcp/tool-surface`.\n\nOn startup the server issues one bounded probe to that endpoint and installs a\ncustom `tools/list` handler that subtracts the backend-blocked IDs (intersected\nwith the locally advertised surface) from what it advertises. That single startup\nprobe is the default: the surface is gated once per session and the server does\nnot re-probe. Installed integrations change rarely and MCP clients re-list on\nreconnect, so a permanent per-session heartbeat \u2014 multiplied across every\nconcurrent worktree/agent session \u2014 was pure request noise against the backend.\nOpt in with `BAPI_MCP_TOOL_SURFACE_POLL_ENABLED=true` to restore the jittered\n12\u201318 s re-probe that emits `notifications/tools/list_changed` whenever the\neffective visible set actually changes, so a connected client converges to the\ncurrent surface mid-session without a reconnect.\n\n**Fail-open by design.** A probe timeout, unreachable backend, non-2xx response,\nmalformed payload, incomplete evaluation, or unsupported schema version all\nadvertise the **full** existing profile baseline \u2014 gating never removes a tool on\na doubtful signal.\n\n**Hidden \u2260 disabled.** A capability-hidden tool remains **registered and\ncallable**, including through in-process pipelines. Hiding affects `tools/list`\nprojection only; it never calls `.disable()` or mutates the SDK `enabled` flag,\nbecause doing so would also block `tools/call` and the in-process dispatch path \u2014\nthe backend remains the authoritative enforcement boundary, returning its own\nrefusal for a stale call rather than a local "disabled" error.\n\n**Kill switch.** Set `BAPI_MCP_TOOL_SURFACE_GATING_ENABLED` to an accepted false\ntoken (`false`/`0`/`no`/`off`/`disabled`) to skip the probe, the poll, and the\ncustom handler entirely, restoring the SDK\'s previous full profile-derived\nsurface.\n\n**Client convergence and the reconnect escape hatch.** Clients that honor\n`notifications/tools/list_changed` converge automatically. A client that does not\nhonor the notification must **reconnect or start a new MCP server session** to\nobserve the current surface; no project MCP configuration change is required.\n\n**Diagnosing the surface.** Run `doctor` (its advisory "MCP tool surface"\nsection reports the kill-switch state, reachability, decision reason, blocked\ncount, physical tool IDs, and catalog revision via a single read-only GET), or\nread the server\'s stderr gating decision lines (`tool-surface gating: reason=\u2026\nhidden=\u2026 revision=\u2026 hidden_tools=[\u2026]`).\n\n## Worktree credentials and the `mcp-invoke` shim\n\nWhen `start-tickets` creates a git worktree, it provisions a Bridge API MCP\nregistration into that worktree so Claude Code (`.mcp.json`) and Cursor\n(`.cursor/mcp.json`) can reach the server immediately. These registrations are\n**secret-free**: they contain no `env` block and no API key. Instead they point\nat an internal subcommand of the published single CLI bin, `mcp-invoke`:\n\n```bash\nnpx -y @bridge_gpt/mcp-server@<VERSION> mcp-invoke --target bapi --project-root <ABS_WORKTREE_PATH>\n```\n\n`mcp-invoke` is not a separate binary \u2014 it is a positional subcommand of\n`bridge-api-mcp-server`. It resolves the repo identity from the absolute\n`--project-root` (the committed `.bridge/config` manifest, falling back to the\ngit common dir), resolves credentials from the home-directory credential store,\nand then spawns the real MCP server with that environment.\n\n### Credential store\n\nCredentials live outside the repository, keyed by `bapi:<repo_name>`:\n\n```json\n{\n "bapi:<repo_name>": {\n "BAPI_API_KEY": "..."\n }\n}\n```\n\nResolution order:\n\n1. `BAPI_API_KEY` in the parent environment (overrides the file entirely).\n2. `$XDG_CONFIG_HOME/bridge/credentials.json`, else `~/.config/bridge/credentials.json`.\n3. `~/.bridge/credentials.json` (only when the primary path is absent).\n\nOn POSIX systems, lock the file down so only you can read it:\n\n```bash\nchmod 600 ~/.config/bridge/credentials.json\n```\n\n`mcp-invoke` warns (but continues) if the file is group/world-readable, and it\nnever creates or initializes the credential file for you.\n\n### Populating the credential store\n\nThe same store also backs the shell-spawned `start-tickets` CLI (its\ndifficulty\u2192model routing runs in a Bash process that cannot see an `env` block in\n`.mcp.json` / `.cursor/mcp.json`), so the store must hold the key for routing to\nwork. Two supported paths write it for you:\n\n- **Install-time upsert.** `/install-bridge`\'s final stage persists the validated\n routing credential into `~/.config/bridge/credentials.json` (target\n `bapi:<repo>`) via the `persist_routing_credential` MCP tool \u2014 the tool resolves\n the key inside the MCP process and writes the store, so no secret crosses the\n wire.\n- **One-shot migration.** If a key currently lives only in `.mcp.json` /\n `.cursor/mcp.json`, migrate it into the user-scoped store with the consent-gated\n command (a compatibility aid, not a live fallback):\n\n ```bash\n npx -y @bridge_gpt/mcp-server credentials migrate-agent-config [--write-credentials]\n ```\n\n Run it without `--write-credentials` to preview; add the flag to write the store.\n\n### Claude login for conductor workers\n\nConductor workers are **not** isolated into a private Claude configuration\ndirectory \u2014 they run with the executor host\'s own `HOME`, so a worker\nauthenticates the same way any interactive `claude` invocation on that host\ndoes. The prerequisite is simple: run\n\n```bash\nclaude login\n```\n\non the executor host, once, the normal way. Bridge never stores, resolves, mints,\nrotates, validates, or diagnoses this credential \u2014 it is entirely the operator\'s\nown Claude CLI state, exactly as if you were running `claude` at the terminal\nyourself.\n\n**Headless hosts.** If the executor host has no interactive login session\navailable (a service-launched executor, a CI-style runner), export\n`CLAUDE_CODE_OAUTH_TOKEN` into the **executor process\'s own environment**\nyourself before starting it:\n\n```bash\nexport CLAUDE_CODE_OAUTH_TOKEN="$(claude setup-token)" # run once, wherever you can browser-login\n```\n\nBridge forwards that value **unchanged**, byte-for-byte, into the direct worker\nprocess environment \u2014 nothing else. It is never written to disk, never placed in\na generated launchd/systemd service unit, never placed in project configuration\n(`.mcp.json` / `.cursor/mcp.json`), and never sent to Bridge servers. There is no\ncredential store entry for it and no lifecycle tracking: expiry, rotation, and\nvalidity are entirely the operator\'s own responsibility, the same as any other\nvalue you choose to export into a process environment.\n\n**`ANTHROPIC_API_KEY` is never forwarded to a worker**, under any circumstance \u2014\nthere is no fallback path for it.\n\n`mcp-server doctor` reports a single advisory **Claude login** line \u2014 whether\n`~/.claude.json` on the host it runs on carries a login marker. This is\ninformational only: it cannot confirm the next worker spawn will authenticate,\nand it never blocks the doctor run or changes its exit code.\n\n## Reference\n\nThe full surface, for when you need the complete enumeration. Day-to-day, use [Usage Documentation](#usage-documentation) instead \u2014 you don\'t call MCP tools directly; you ask your AI assistant to perform a task, or compose tools into a pipeline.\n\n### MCP tools\n\nThe authoritative tool catalog covers **92 tools** (enumerated below). What\'s actually registered in a session depends on `BRIDGE_MCP_PROFILE`: the default `core` profile loads a trimmed subset, and the conductor/pipeline-authoring/SFCC tools are added only under their respective profiles (see [Environment Variables](#environment-variables)). Async AI tools follow a request/get pattern: call the `request_*` tool to kick off generation, then the matching `get_*` tool to retrieve the result (or pass `wait_for_result: true` to poll automatically).\n\n- **Connectivity & identity** \u2014 `ping`, `get_my_role`, `get_docs_dir`\n- **Team & access** \u2014 `invite_member` (admin-only; mints a scoped access key for a teammate on an already-configured project \u2014 the plaintext key is shown exactly once)\n- **Jira tickets** \u2014 `get_tickets`, `get_ticket`, `create_ticket`, `update_ticket_description`, `add_comment`, `get_comments`\n- **Attachments** \u2014 `attachment` (operations: `upload`, `download`, `list`)\n- **AI generation (request/get)** \u2014 `request_plan_generation`/`get_plan`, `request_architecture`/`get_architecture`, `create_doc`/`get_doc` (design docs by `doc_type`: tdd/fsd/prd), `request_prd`/`get_prd`, `request_clarifying_questions`/`get_clarifying_questions`, `request_ticket_critique`/`get_ticket_critique`, `request_ticket_review`, `request_reimplement_context`/`get_reimplement_context`, `request_council`/`get_council`, `request_deep_research`/`get_deep_research`\n- **Other AI** \u2014 `second_opinion`, `generate_image`, `generate_decision_page`, `visual_diff` (deterministic pixel diff of a rendered URL vs a design comp)\n- **Ticket lifecycle** \u2014 `track_ticket`, `update_ticket_state`, `get_ticket_state`, `get_ticket_state_tree` (live repo-wide lifecycle + dependency tree; read-only, no mutation parameter)\n- **Jira status** \u2014 `get_jira_transitions`, `update_jira_status`, `resolve_target_status`\n- **Repository & CI** \u2014 `parse_repository`, `get_parse_status`, `regenerate_directory_map`, `create_pull_request`, `merge_pull_request`, `resolve_ci_checks`, `poll_ci_checks`\n- **Pipelines & automation** \u2014 `list_pipelines`, `get_pipeline_recipe`, `run_pipeline`, `resume_pipeline`, `list_pipeline_runs`, `delete_pipeline_run`, `run_full_automation`, `resume_full_automation`\n- **Config** \u2014 `get_project_standards`, `config_field` (operations: `get`, `update`, `list`)\n\n### Bundled pipelines\n\nPipelines are declarative, multi-step workflows your AI agent executes step-by-step \u2014 each a JSON recipe chaining MCP tool calls and free-form agent tasks, with variable substitution, per-step error handling, and optional approval gates. You can also write your own (see [Custom Pipelines](#custom-pipelines)).\n\n| Pipeline | Description | Invoke with |\n|---|---|---|\n| `implement-ticket` | Generate a plan, execute the implementation, commit, open a PR, and monitor CI | `/implement-ticket PROJ-123` |\n| `review-ticket` | Full ticket quality review: clarifying questions + critique plus an automatic second-opinion pass, then evaluation and decision capture. The backend owns round orchestration; pass `--rounds=1` for a single-pass review or `--rounds=2` to force the full second-opinion review, or omit `--rounds` to let the backend decide adaptively. | `/review-ticket PROJ-123` |\n| `idea-to-ticket` | Turn an idea into a Jira Task/Spike (or Epic + children) with research, dedup, and critique | `/idea-to-ticket "<idea>"` |\n| `plan-epic` | Decompose an epic into sub-tasks with a structured exploration doc for each | `/plan-epic "<epic>"` |\n| `full-automation` | Chain: idea \u2192 ticket(s) \u2192 review each \u2192 spawn worktrees to implement | `/full-automation "<idea>"` |\n| `pr-ticket` | Commit changes and open a pull request | `/create-pr PROJ-123` |\n| `check-ci-ticket` | Commit, open a PR, then monitor CI checks until they pass or fail | `/check-ci PROJ-123` |\n| `learn-repository` | Analyze codebase architecture, testing, review, and documentation standards, then upload to Bridge | `/learn-repository` |\n\n### Pipeline response envelope\n\n`run_pipeline`, `resume_pipeline`, and `list_pipeline_runs` share a unified envelope keyed on `status`:\n\n- `completed` \u2014 terminal success; `results` holds per-step output.\n- `needs_agent_task` \u2014 the orchestrator paused. Read `instruction`, perform the task, then call `resume_pipeline` with `pipeline_run_id` and a string `agent_result`.\n- `failed` \u2014 terminal failure. `error_code` is one of `VALIDATION`, `NOT_FOUND`, `EXPIRED`, `REPO_MISMATCH`, `TOOL_ERROR`.\n\nPaused runs auto-expire after an idle TTL (default 24 hours; override with `ttl_seconds`). The TTL is reset on every state transition. List output is metadata-only \u2014 it never includes resolved recipes, params, instructions, results, or agent outputs.\n';import{readdir,readFile}from"fs/promises";import path from"path";var EXECUTION_MODE_VARIABLE="execution_mode";function validatePipelineSchema(json){let errors=[];if(typeof json!="object"||json===null||Array.isArray(json))return{valid:!1,errors:["Pipeline must be a JSON object."]};let obj=json;if((typeof obj.name!="string"||obj.name.trim()==="")&&errors.push('Missing or empty required field "name" (string).'),!Array.isArray(obj.steps)||obj.steps.length===0)return errors.push('Missing or empty required field "steps" (non-empty array).'),{valid:!1,errors};obj.description!==void 0&&typeof obj.description!="string"&&errors.push('"description" must be a string if provided.'),obj.variables!==void 0&&(!Array.isArray(obj.variables)||!obj.variables.every(v=>typeof v=="string"))&&errors.push('"variables" must be an array of strings if provided.');let steps=obj.steps;for(let i=0;i<steps.length;i++){let prefix=`steps[${i}]`,step=steps[i];if(typeof step!="object"||step===null||Array.isArray(step)){errors.push(`${prefix}: must be an object.`);continue}let s=step;if((typeof s.description!="string"||s.description.trim()==="")&&errors.push(`${prefix}: missing or empty "description" (string).`),s.on_error!==void 0&&s.on_error!=="halt"&&s.on_error!=="warn_and_continue"&&errors.push(`${prefix}: "on_error" must be "halt" or "warn_and_continue".`),s.requires_approval!==void 0&&typeof s.requires_approval!="boolean"&&errors.push(`${prefix}: "requires_approval" must be a boolean if provided.`),s.id!==void 0&&(typeof s.id!="string"||s.id.trim().length===0)&&errors.push(`${prefix}."id" must be a non-empty string when provided`),s.type==="mcp_call")(typeof s.tool!="string"||s.tool.trim()==="")&&errors.push(`${prefix}: mcp_call step requires "tool" (string).`),(typeof s.params!="object"||s.params===null||Array.isArray(s.params))&&errors.push(`${prefix}: mcp_call step requires "params" (object).`);else if(s.type==="agent_task"){let hasInstruction=typeof s.instruction=="string"&&s.instruction.trim()!=="",hasInstructionFile=typeof s.instruction_file=="string"&&s.instruction_file.trim()!=="";hasInstruction&&hasInstructionFile?errors.push(`${prefix}: agent_task must have exactly one of "instruction" or "instruction_file", not both.`):!hasInstruction&&!hasInstructionFile&&errors.push(`${prefix}: agent_task must have exactly one of "instruction" or "instruction_file".`)}else errors.push(`${prefix}: "type" must be "mcp_call" or "agent_task", got "${String(s.type)}".`)}return{valid:errors.length===0,errors}}function hasTerminalReturnSection(markdown){if(typeof markdown!="string"||markdown.length===0)return!1;let h2Pattern=/^##\s+(.+?)\s*$/gm,match,lastHeading=null;for(;(match=h2Pattern.exec(markdown))!==null;)lastHeading=match[1].trim();return lastHeading===null?!1:/^return\b/i.test(lastHeading)}var variablePattern=()=>/\{([a-zA-Z_][a-zA-Z0-9_]*)}/g;function substituteVariables(template,variables){return template.replace(variablePattern(),(full,name)=>name in variables?variables[name]:full)}function substituteDeep(value,variables){if(typeof value=="string")return substituteVariables(value,variables);if(Array.isArray(value))return value.map(item=>substituteDeep(item,variables));if(typeof value=="object"&&value!==null){let result={};for(let[k,v]of Object.entries(value))result[k]=substituteDeep(v,variables);return result}return value}function substituteInParams(params,variables){return substituteDeep(params,variables)}var UPGRADE_ADVICE_SURFACING_INSTRUCTIONS=" Upgrade advice: when any `ping` MCP call in this run returns a non-empty second text content item, relay that server-provided text to the user verbatim \u2014 do not invent, prefix, suffix, summarize, or paraphrase it. Surface it at most once per pipeline run or session, including any chained sub-pipelines. Stay completely silent when the second content item is absent or empty. A ping failure, a non-OK ping response, missing advice, or empty advice is fail-open: it must never block, pause, or crash the run, and you must never synthesize advice of your own.";function resolveRecipe(pipeline,instructions,variables,skipSteps,autoApprove,options){let declared=pipeline.variables??[],missing=declared.filter(v=>!(v in variables));if(missing.length>0)throw new Error(`Missing required variable(s): ${missing.join(", ")}. Pipeline "${pipeline.name}" declares: [${declared.join(", ")}].`);let skip=new Set(skipSteps??[]),executionMode=options?.executionMode??"inline";variables={...variables,[EXECUTION_MODE_VARIABLE]:executionMode};let resolvedSteps=[],stepIndex=1;for(let step of pipeline.steps){let stepId=typeof step.id=="string"&&step.id.trim().length>0?step.id:void 0,skipKey=stepId??(step.type==="mcp_call"?step.tool:step.description);if(skip.has(skipKey))continue;let declaredApproval=step.requires_approval??!1,effectiveApproval=declaredApproval&&!autoApprove,isPingStep=step.type==="mcp_call"&&step.tool==="ping",base={step:stepIndex++,type:step.type,description:substituteVariables(step.description,variables),on_error:isPingStep?"warn_and_continue":step.on_error??"halt",requires_approval:effectiveApproval};if(declaredApproval!==effectiveApproval&&(base.requires_approval_declared=declaredApproval),stepId!==void 0&&(base.id=stepId),step.type==="mcp_call")base.tool=step.tool,base.params=substituteInParams(step.params,variables);else{let rawInstruction;if(step.instruction_file){let content=instructions[step.instruction_file];if(content===void 0)throw new Error(`Instruction file "${step.instruction_file}" not found in bundled instructions.`);rawInstruction=content,base.instruction_file=step.instruction_file}else rawInstruction=step.instruction;base.instruction=substituteVariables(rawInstruction,variables)}resolvedSteps.push(base)}let baseInstructions=`IMPORTANT: Execute every step below in exact sequential order. For mcp_call steps, call the specified tool with the provided params. For agent_task steps, follow the instruction text using any tools it specifies. If requires_approval is true, pause before executing. For agent_task steps, the instruction file's own approval format is authoritative \u2014 follow it verbatim and do not substitute your own short confirmation prompt. For mcp_call steps with no instruction file, present the resolved params as bullet points and ask for approval. For on_error "halt", stop the pipeline immediately on failure. For on_error "warn_and_continue", log a warning and proceed. Recipes are re-entrant: a recovery run may begin again at step 1, and a step whose tool reports a server-side reuse (e.g. reused: true) has completed successfully \u2014 treat that as the expected fast path, not a failure, and continue. A step can succeed (no on_error handling applies) while its own returned content is a JSON envelope shaped like error: "GATEWAY_TIMEOUT", status: 504, and a recovery_get field \u2014 recognize that shape and read it as "server-side processing may still be running", not as a failure or an invitation to retry. Poll the named retrieval tool (or the recovery_get URL) with the same artifact identifier until a terminal response is reached, and never reissue the original request tool. Only fall back to on_error handling if that retrieval itself terminally fails. Do not skip steps, reorder them, or substitute your own tool calls.`,upgradeAdviceConvention=options?.includeUpgradeAdviceSurfacing!==!1?UPGRADE_ADVICE_SURFACING_INSTRUCTIONS:"",autoApproveSuffix=autoApprove?" Auto-approve mode is ACTIVE: every approval gate has been pre-approved by the user via the auto_approve flag \u2014 proceed without pausing for confirmation, applying the default branching, file-staging, and recommendation choices documented in each instruction file's auto-approve branch.":"";return{pipeline:pipeline.name,description:pipeline.description??"",total_steps:resolvedSteps.length,agent_instructions:baseInstructions+upgradeAdviceConvention+autoApproveSuffix,auto_approve:!!autoApprove,execution_mode:executionMode,steps:resolvedSteps}}async function loadCustomPipelines(pipelinesDir,instructionsDir,bundledInstructions){let mergedInstructions={...bundledInstructions},userPipelines={},userPipelineKeys2=new Set;try{let instrFiles=await readdir(instructionsDir);for(let file of instrFiles)if(file.endsWith(".md"))try{let content=await readFile(path.join(instructionsDir,file),"utf-8");file in bundledInstructions&&console.error(`Warning: custom instruction "${file}" overrides a bundled instruction.`),mergedInstructions[file]=content}catch(readErr){let msg=readErr instanceof Error?readErr.message:String(readErr);console.error(`Warning: skipping instruction "${file}" \u2014 failed to read: ${msg}`)}}catch(err){err.code!=="ENOENT"&&console.error(`Warning: could not read instructions directory "${instructionsDir}": ${err.message}`)}try{let pipelineFiles=await readdir(pipelinesDir);for(let file of pipelineFiles){if(!file.endsWith(".json"))continue;let parsed;try{let raw=await readFile(path.join(pipelinesDir,file),"utf-8");parsed=JSON.parse(raw)}catch(parseErr){let msg=parseErr instanceof Error?parseErr.message:String(parseErr);console.error(`Warning: skipping "${file}" \u2014 failed to parse: ${msg}`);continue}let{valid,errors}=validatePipelineSchema(parsed);if(!valid){console.error(`Warning: skipping "${file}" \u2014 validation errors:
6095
6095
  ${errors.join(`
6096
6096
  `)}`);continue}let pipeline=parsed,key=file.replace(/\.json$/,""),hasInvalidRef=!1;for(let step of pipeline.steps)if(step.type==="agent_task"&&step.instruction_file){let content=mergedInstructions[step.instruction_file];if(content===void 0){console.error(`Warning: skipping "${file}" \u2014 instruction_file "${step.instruction_file}" not found.`),hasInvalidRef=!0;break}if(!hasTerminalReturnSection(content)){console.error(`Warning: skipping "${file}" \u2014 instruction_file "${step.instruction_file}" is missing a terminal "## Return" section (required by BAPI-275 agent_result contract).`),hasInvalidRef=!0;break}}hasInvalidRef||(userPipelines[key]=pipeline,userPipelineKeys2.add(key))}}catch(err){return err.code!=="ENOENT"&&console.error(`Warning: could not read pipelines directory "${pipelinesDir}": ${err.message}`),{pipelines:userPipelines,instructions:mergedInstructions,userPipelineKeys:userPipelineKeys2}}return userPipelineKeys2.size>0&&console.error(`Loaded ${userPipelineKeys2.size} user pipeline(s) from ${pipelinesDir}`),{pipelines:userPipelines,instructions:mergedInstructions,userPipelineKeys:userPipelineKeys2}}var PLAN_PROVENANCE_CLASSES=["implementation","documentation","unit_tests","e2e_tests","rendered_ui_review","test_gap_review","final_plan_review"],PLAN_PHASES=["produce","pre_pr_verification","post_pr_gap_close"],PLAN_STEP_DISPOSITIONS=["executed","adapted","escalated","unrun-advisory"],MECHANICAL_ADAPTATION_KINDS=["locator-correction","repository-command-correction","equivalent-implementation-recognized"],ESCALATION_ONLY_CATEGORIES=["design","schema","public-api","dependencies","security"],PLAN_CLASS_OWNERSHIP=Object.freeze({implementation:"produce",documentation:"produce",unit_tests:"pre_pr_verification",e2e_tests:"pre_pr_verification",rendered_ui_review:"pre_pr_verification",test_gap_review:"pre_pr_verification",final_plan_review:"pre_pr_verification"}),PRE_PR_VERIFICATION_LIMITS=Object.freeze({maxCorrectionTurns:3,maxChangedFiles:40,maxDiffLines:2e3}),RENDERED_UI_MAX_CYCLES=3,PlanLedgerError=class extends Error{constructor(message){super(message),this.name="PlanLedgerError"}};function isPlainObject(value){return typeof value=="object"&&value!==null&&!Array.isArray(value)}function isPositiveInteger(value){return typeof value=="number"&&Number.isInteger(value)&&value>=1}function validatePlanMetadata(value){if(value==null)throw new PlanLedgerError("plan metadata is absent; routing requires provenance and must not be inferred from plan prose");if(!isPlainObject(value))throw new PlanLedgerError(`plan metadata must be an object, received ${Array.isArray(value)?"array":typeof value}`);if(value.version!==1)throw new PlanLedgerError(`unsupported plan metadata version ${String(value.version)}; expected 1`);let rawParts=value.parts;if(!Array.isArray(rawParts)||rawParts.length===0)throw new PlanLedgerError("plan metadata must carry a non-empty parts array");let parts=[],seenIds=new Set,previousEnd=0,previousId="";for(let[index,raw]of rawParts.entries()){if(!isPlainObject(raw))throw new PlanLedgerError(`plan metadata part at index ${index} is not an object`);let partId=raw.part_id;if(typeof partId!="string"||partId.trim()==="")throw new PlanLedgerError(`plan metadata part at index ${index} has an empty part_id`);if(seenIds.has(partId))throw new PlanLedgerError(`duplicate plan metadata part_id '${partId}'`);seenIds.add(partId);let producer=raw.producer;if(typeof producer!="string"||producer.trim()==="")throw new PlanLedgerError(`plan metadata part '${partId}' has an empty producer`);let provenanceClass=raw.provenance_class;if(typeof provenanceClass!="string"||!PLAN_PROVENANCE_CLASSES.includes(provenanceClass))throw new PlanLedgerError(`plan metadata part '${partId}' has unknown provenance class '${String(provenanceClass)}'`);let startStep=raw.start_step,endStep=raw.end_step;if(!isPositiveInteger(startStep))throw new PlanLedgerError(`plan metadata part '${partId}' has a non-integer or non-positive start_step ${String(startStep)}`);if(!isPositiveInteger(endStep))throw new PlanLedgerError(`plan metadata part '${partId}' has a non-integer or non-positive end_step ${String(endStep)}`);if(endStep<startStep)throw new PlanLedgerError(`plan metadata part '${partId}' has a reversed range ${startStep}-${endStep}`);if(startStep<=previousEnd)throw new PlanLedgerError(`plan metadata part '${partId}' range ${startStep}-${endStep} overlaps or precedes '${previousId}' ending at ${previousEnd}; ranges must be disjoint and ascending`);if(startStep>previousEnd+1)throw new PlanLedgerError(`plan metadata part '${partId}' starts at step ${startStep} but '${previousId}' ended at ${previousEnd}; steps ${previousEnd+1}-${startStep-1} are claimed by no part and would be executed by no phase`);previousEnd=endStep,previousId=partId,parts.push({part_id:partId,producer,provenance_class:provenanceClass,start_step:startStep,end_step:endStep,declared_advisory:raw.declared_advisory===!0})}let totalSteps=value.total_steps;if(typeof totalSteps!="number"||!Number.isInteger(totalSteps))throw new PlanLedgerError("plan metadata total_steps must be an integer");if(totalSteps<previousEnd)throw new PlanLedgerError(`plan metadata total_steps ${totalSteps} is below the highest declared step ${previousEnd}`);let declaredClasses=value.provenance_classes,derivedClasses=[];for(let part of parts)derivedClasses.includes(part.provenance_class)||derivedClasses.push(part.provenance_class);if(Array.isArray(declaredClasses)){for(let declared of declaredClasses)if(!derivedClasses.includes(declared))throw new PlanLedgerError(`plan metadata declares provenance class '${String(declared)}' that no part produces`)}return{version:1,parts,provenance_classes:derivedClasses,total_steps:totalSteps}}function resolveOwnedSteps(metadata,phase){if(!PLAN_PHASES.includes(phase))throw new PlanLedgerError(`unknown phase '${phase}'`);let owned=[];for(let part of metadata.parts)if(PLAN_CLASS_OWNERSHIP[part.provenance_class]===phase)for(let step=part.start_step;step<=part.end_step;step+=1)owned.push(step);return owned}function resolveOwnedParts(metadata,phase){return metadata.parts.filter(part=>PLAN_CLASS_OWNERSHIP[part.provenance_class]===phase)}function assertStepClassCoverage(metadata,ownership=PLAN_CLASS_OWNERSHIP){let unowned=[];for(let part of metadata.parts){let owner=ownership[part.provenance_class];if(owner===void 0){if(part.declared_advisory)continue;unowned.push(`class '${part.provenance_class}' (part '${part.part_id}', steps ${part.start_step}-${part.end_step}) has no executing phase and is not declared advisory`);continue}PLAN_PHASES.includes(owner)||unowned.push(`class '${part.provenance_class}' is mapped to unknown phase '${String(owner)}'`)}if(unowned.length>0)throw new PlanLedgerError(`plan step-class coverage failed \u2014 every class a planner can emit must be executed by some phase or declared advisory in the plan:
6097
6097
  ${unowned.join(`
@@ -6277,7 +6277,7 @@ This phase is critical to ticket quality. Spend time here in proportion to the r
6277
6277
 
6278
6278
  ### Consuming a Comp\u2192Codebase Map (optional upstream input)
6279
6279
 
6280
- **Check applicability first, before anything else in this section.** You may be handed a precomputed comp\u2192codebase map (\`comp-analysis.json\`) produced by an **upstream orchestrating vision step** (the recipe's \`comp-analysis.md\` step, or the \`/write-ticket\` Stage 0.5 pre-draft pass). If no \`comp-analysis.json\` path was supplied, or the map has \`applicable: false\` (a backend-only request, a no-comp request, a non-design request, or a degraded/unreadable comp): **skip this entire section \u2014 ignore the artifact entirely**. Do NOT read the map further, do NOT apply the fidelity taxonomy below, and do NOT mention comp analysis, design comps, visual fidelity, map artifacts, or image-derived requirements at all \u2014 unless the user's original request independently requires those materials. A backend-only or no-comp ticket must read exactly as it would with no map present.
6280
+ **Check applicability first, before anything else in this section.** You may be handed a precomputed comp\u2192codebase map (\`comp-analysis.json\`) produced by an **upstream orchestrating vision step** (the recipe's \`comp-analysis.md\` step, or the calling ticket-authoring workflow's own pre-draft comp-analysis pass). If no \`comp-analysis.json\` path was supplied, or the map has \`applicable: false\` (a backend-only request, a no-comp request, a non-design request, or a degraded/unreadable comp): **skip this entire section \u2014 ignore the artifact entirely**. Do NOT read the map further, do NOT apply the fidelity taxonomy below, and do NOT mention comp analysis, design comps, visual fidelity, map artifacts, or image-derived requirements at all \u2014 unless the user's original request independently requires those materials. A backend-only or no-comp ticket must read exactly as it would with no map present.
6281
6281
 
6282
6282
  Only when a map was supplied AND has \`applicable: true\` does the rest of this section apply. That upstream step is a frontier vision model that already opened the design comp, classified it, and mapped its regions to concrete existing code. You remain **text-only**: you **must not open images**, embed images, download attachments, or perform any vision analysis yourself \u2014 you only read the JSON map as focused research input.
6283
6283
 
@@ -6407,7 +6407,7 @@ The markdown file MUST contain exactly these sections:
6407
6407
 
6408
6408
  After the draft (including its \`## Materials & Access\` section) is written, run this pass. It is a non-blocking, **warn-not-halt** completeness check \u2014 it never blocks or fails ticket creation, and it never modifies the Requirements or Acceptance Criteria text directly.
6409
6409
 
6410
- 1. **Check the caller-provided gate first \u2014 you must NOT resolve it yourself.** The invoking caller (the \`/write-ticket\` command's Stage 0, or the \`/idea-to-ticket\` draft-and-critique instruction's setup) resolves the per-repo \`enable_regression_checks\` setting once, before invoking you, and states the result explicitly in this prompt as \`enable_regression_checks: true\` or \`enable_regression_checks: false\`. You must never call the \`config_field\` MCP tool to look this value up yourself \u2014 the caller has already resolved it, and re-checking it here would be a redundant MCP call whose result cannot change the outcome. If the prompt you were given does not state \`enable_regression_checks: true\` exactly \u2014 it is missing, \`false\`, or any other/malformed value \u2014 **skip this entire pass** \u2014 the draft is produced exactly as it would be without this section (byte-for-byte unchanged). The recommended default for this flag is OFF (unset) for safe rollout; only proceed past this step when the caller explicitly passed \`enable_regression_checks: true\`.
6410
+ 1. **Check the caller-provided gate first \u2014 you must NOT resolve it yourself.** The invoking caller (the calling ticket-authoring workflow's own setup stage, or the \`/idea-to-ticket\` draft-and-critique instruction's setup) resolves the per-repo \`enable_regression_checks\` setting once, before invoking you, and states the result explicitly in this prompt as \`enable_regression_checks: true\` or \`enable_regression_checks: false\`. You must never call the \`config_field\` MCP tool to look this value up yourself \u2014 the caller has already resolved it, and re-checking it here would be a redundant MCP call whose result cannot change the outcome. If the prompt you were given does not state \`enable_regression_checks: true\` exactly \u2014 it is missing, \`false\`, or any other/malformed value \u2014 **skip this entire pass** \u2014 the draft is produced exactly as it would be without this section (byte-for-byte unchanged). The recommended default for this flag is OFF (unset) for safe rollout; only proceed past this step when the caller explicitly passed \`enable_regression_checks: true\`.
6411
6411
 
6412
6412
  2. **Derive the touched-symbol set.** From the draft's Requirements and *Relevant code* citations (or, if the ticket references an existing diff/PR, that diff/PR), extract the specific function/class/symbol names the proposed change touches.
6413
6413
 
@@ -7037,6 +7037,41 @@ drafting.
7037
7037
  The rest of this document is the rationale the block is deliberately too short to
7038
7038
  carry.
7039
7039
 
7040
+ ## Decision: Jira ticket authoring ships through the Jira Ticket Writer (BAPI-900)
7041
+
7042
+ Bridge ships two very different kinds of ticket-authoring surface, and customers
7043
+ need to know which one they actually have.
7044
+
7045
+ **Shipped customer surface.** A customer project gets the \`jira-ticket-writer\`
7046
+ agent \u2014 the same writer this posture requires every ticket body to go through \u2014
7047
+ plus the agent-directed capability to revise an existing ticket's description.
7048
+ Ask your agent to draft a ticket with the Jira Ticket Writer, and ask your agent
7049
+ to update an existing ticket's description when it needs revising. Both reach a
7050
+ customer project because they are packaged: the writer through \`AGENTS\`
7051
+ (\`mcp_server/src/agents.generated.ts\`) and the description-update path through
7052
+ the registered \`update_ticket_description\` / \`request_ticket_update\` MCP tools.
7053
+
7054
+ **Repository-local workflows.** \`.claude/commands/write-ticket.md\` and
7055
+ \`.claude/commands/update-ticket.md\` are bridge-api's own repository-maintenance
7056
+ commands. They are **not** scaffolded into a customer project by \`--init\`, have
7057
+ no \`commands/src/\` source, and have no generated Cursor or \`mcp_server/\`
7058
+ mirror \u2014 deliberately, not by omission. A customer asking their agent to run
7059
+ the write-ticket or update-ticket slash command will not find either one,
7060
+ because neither ships.
7061
+
7062
+ **Why (Option B, not a promotion to shipped status).** The Jira Ticket Writer is
7063
+ already the packaged drafting surface this posture mandates, so shipping
7064
+ \`write-ticket.md\` as a second drafting entry point would duplicate it. More
7065
+ importantly, \`write-ticket.md\` is today an autonomous, single-ticket, no-halt
7066
+ pipeline ("No human confirmation gates \u2014 run end-to-end") with no decomposition
7067
+ step and no approval gate \u2014 it cannot honor the "group at three" epic rule or
7068
+ the epic approval gate this posture requires, because it was never built to
7069
+ propose an epic at all. Promoting it to a shipped surface without that redesign
7070
+ would ship a customer-facing command that silently violates this file's own
7071
+ posture. Until that redesign happens, \`write-ticket.md\` and \`update-ticket.md\`
7072
+ stay repository-local, and every packaged surface directs customers to the Jira
7073
+ Ticket Writer and to agent-directed description updates instead.
7074
+
7040
7075
 
7041
7076
  ## The four rules
7042
7077
 
@@ -7868,7 +7903,7 @@ When done, call \`resume_full_automation\` with \`chain_run_id\` "${row.chain_ru
7868
7903
 
7869
7904
  ${command}
7870
7905
 
7871
- When the worktrees have been spawned, call \`resume_full_automation\` with \`chain_run_id\` "${row.chain_run_id}" and \`agent_result\` set to a short summary of what start-tickets reported.`;return buildNeedsAgentTaskEnvelope({chainRunId:updated.chain_run_id,chainStage:START_TICKETS_PIPELINE,chainStep:idx+1,chainTotal:total,preamble:buildPreamble(recipe,idx,updated.stages),instruction})}function numericArg(value){if(typeof value=="number"&&Number.isFinite(value))return value}async function continueChainExecution(deps,persistence,recipe,row,autoApprove){let guard=0,guardMax=1e4;for(;guard++<guardMax;){let idx=row.current_stage_index,total=recipe.stages.length;if(idx>=total){try{row=await persistence.patchRun(row.chain_run_id,{status:"completed"})}catch(err){return persistenceFailEnvelope(err,row,recipe)}return buildCompletedEnvelope(recipe,row)}let stageRecipe=recipe.stages[idx],outcome2=null;if(stageRecipe.pipeline_name===START_TICKETS_PIPELINE)return startStartTicketsStage(persistence,recipe,row);if(stageRecipe.fan_out_input?outcome2=await startOrContinueReviewTicketStage(deps,persistence,recipe,row,autoApprove):outcome2=await startOrContinueIdeaToTicketStage(deps,persistence,recipe,row,autoApprove),outcome2.kind==="pause"||outcome2.kind==="fail")return outcome2.envelope;row=outcome2.row}return failedEnvelope2("TOOL_ERROR","Chain execution exceeded its step guard.",{chain_run_id:row.chain_run_id,chain_total:recipe.stages.length})}async function runFullAutomation(deps,input){try{if(typeof input.idea!="string"||input.idea.trim()==="")return failedEnvelope2("VALIDATION","idea must be a non-empty string.");let agent=input.agent??"claude";if(agent!=="claude")return failedEnvelope2("VALIDATION",`Unsupported agent "${String(input.agent)}". Only "claude" is supported.`);let recipe=deps.chainRecipes[CHAIN_NAME];if(!recipe)return failedEnvelope2("VALIDATION",`Chain recipe "${CHAIN_NAME}" not found.`);let autoApprove=input.auto_approve===void 0?!0:normalizeAutoApprove2(input.auto_approve),args={idea:input.idea,auto_approve:autoApprove,scheduled_at:input.scheduled_at??"",max_children:input.max_children,allow_duplicate:input.allow_duplicate,agent,ttl_seconds:input.ttl_seconds},initialStages=recipe.stages.map(stage=>({pipeline_name:stage.pipeline_name,status:"pending"})),persistence=createChainPersistenceClient({baseUrl:deps.baseUrl,apiKey:deps.apiKey,repoName:deps.repoName}),row;try{row=await persistence.createRun({chain_name:CHAIN_NAME,args,current_stage_index:0,stages:initialStages,status:"running",ttl_seconds:input.ttl_seconds})}catch(err){return err instanceof ChainPersistenceError?failedEnvelope2(err.code,err.message):failedEnvelope2("TOOL_ERROR","An unexpected error occurred while creating the chain run.")}return continueChainExecution(deps,persistence,recipe,row,autoApprove)}catch(err){return console.error("[chain-orchestrator] unexpected error in runFullAutomation:",err),failedEnvelope2("TOOL_ERROR","An unexpected error occurred while executing the full-automation chain.")}}async function resumeFullAutomation(deps,input){try{let recipe=deps.chainRecipes[CHAIN_NAME];if(!recipe)return failedEnvelope2("VALIDATION",`Chain recipe "${CHAIN_NAME}" not found.`,{chain_run_id:input.chain_run_id});let persistence=createChainPersistenceClient({baseUrl:deps.baseUrl,apiKey:deps.apiKey,repoName:deps.repoName}),row;try{row=await persistence.getRun(input.chain_run_id)}catch(err){return err instanceof ChainPersistenceError?failedEnvelope2(err.code,err.message,{chain_run_id:input.chain_run_id}):failedEnvelope2("TOOL_ERROR","An unexpected error occurred while fetching the chain run.",{chain_run_id:input.chain_run_id})}if(row.status==="expired")return failedEnvelope2("EXPIRED","Chain run has expired.",{chain_run_id:row.chain_run_id,chain_total:recipe.stages.length});let autoApprove=normalizeAutoApprove2(row.args.auto_approve),idx=row.current_stage_index,stageRecipe=recipe.stages[idx],total=recipe.stages.length;if(!stageRecipe)return failedEnvelope2("VALIDATION",`Chain run has no active stage at index ${idx}.`,{chain_run_id:row.chain_run_id,chain_total:total});if(stageRecipe.pipeline_name===START_TICKETS_PIPELINE){if(typeof input.agent_result!="string"||input.agent_result.trim()==="")return failedEnvelope2("VALIDATION","agent_result must be a non-empty string to complete the start-tickets stage.",{chain_run_id:row.chain_run_id,chain_stage:START_TICKETS_PIPELINE,chain_step:idx+1,chain_total:total});let startResolution=resolveStartTicketKeys(row,idx,stageRecipe.fan_out_input??"reviewed_ticket_keys"),startedKeys=startResolution.ok?startResolution.keys:[],stages=cloneStages(row.stages);stages[idx].status="completed",stages[idx].pipeline_run_id=null,stages[idx].outputs={started_ticket_keys:startedKeys},stages[idx].summary=summarizeStageCompletion(START_TICKETS_PIPELINE,startedKeys);let updated;try{updated=await persistence.patchRun(row.chain_run_id,{stages,current_stage_index:idx+1,status:"completed",expected_status:"paused",expected_current_stage_index:idx})}catch(err){return persistenceFailEnvelope(err,row,recipe)}return buildCompletedEnvelope(recipe,updated)}let activePipelineRunId=row.stages[idx]?.pipeline_run_id;if(!activePipelineRunId)return failedEnvelope2("VALIDATION",`No active child pipeline to resume for stage ${idx+1}.`,{chain_run_id:row.chain_run_id,chain_stage:stageRecipe.pipeline_name,chain_step:idx+1,chain_total:total});let peek=await peekPipelineRun(deps,activePipelineRunId);if("error_code"in peek)return failedEnvelope2(peek.error_code,peek.error,{chain_run_id:row.chain_run_id,chain_stage:stageRecipe.pipeline_name,chain_step:idx+1,chain_total:total});if(peek.status!=="paused"&&peek.status!=="completed"&&peek.status!=="failed")return{status:"failed",error_code:"VALIDATION",error:`Inner pipeline run is in status "${peek.status}" and cannot be safely resumed or recovered. Inspect pipeline_run_id ${activePipelineRunId}.`,chain_run_id:row.chain_run_id,chain_stage:stageRecipe.pipeline_name,chain_step:idx+1,chain_total:total,pipeline_run_id:activePipelineRunId,resumable:!1};try{row=await persistence.patchRun(row.chain_run_id,{status:"running"})}catch(err){return persistenceFailEnvelope(err,row,recipe)}let childEnv;if(peek.status==="paused")childEnv=await resumePipeline(deps,{pipeline_run_id:activePipelineRunId,agent_result:input.agent_result});else if(peek.status==="completed")childEnv={status:"completed",pipeline_run_id:peek.pipeline_run_id,pipeline:peek.pipeline,total_steps:peek.total_steps,results:peek.results};else{let failedStepError=peek.results.find(r=>!r.ok&&typeof r.error=="string")?.error;childEnv={status:"failed",error_code:"TOOL_ERROR",error:failedStepError?`Inner pipeline run failed before the chain could advance: ${failedStepError}`:"Inner pipeline run failed before the chain could advance.",pipeline_run_id:peek.pipeline_run_id,pipeline:peek.pipeline,results:peek.results}}let fanOut=!!stageRecipe.fan_out_input,childIndex=row.stages[idx]?.current_child_index??0,ticketKey=fanOut?(resolveCrossStageList(row,idx,stageRecipe.fan_out_input)??[])[childIndex]:void 0,outcome2=await handleChildPipelineEnvelope(persistence,recipe,row,childEnv,{fanOut,ticketKey,childIndex});return outcome2.kind==="pause"||outcome2.kind==="fail"?outcome2.envelope:continueChainExecution(deps,persistence,recipe,outcome2.row,autoApprove)}catch(err){return console.error("[chain-orchestrator] unexpected error in resumeFullAutomation:",err),failedEnvelope2("TOOL_ERROR","An unexpected error occurred while resuming the full-automation chain.",{chain_run_id:input.chain_run_id})}}import path51 from"path";import{Worker}from"worker_threads";import{PNG}from"pngjs";import pixelmatch from"pixelmatch";import{isMainThread,parentPort,workerData}from"worker_threads";var PIXELMATCH_COLOR_THRESHOLD=.1,DEFAULT_PASS_MISMATCH_PCT=2,MAX_DIFF_REGIONS=10;function decodePng(buffer,label){try{let png=PNG.sync.read(Buffer.from(buffer));return!Number.isInteger(png.width)||!Number.isInteger(png.height)||png.width<=0||png.height<=0?{ok:!1,error:"IMAGE_DECODE_FAILED",status:422,message:`Decoded ${label} PNG has invalid dimensions.`}:{width:png.width,height:png.height,data:png.data}}catch{return{ok:!1,error:"IMAGE_DECODE_FAILED",status:422,message:`Failed to decode ${label} image as PNG.`}}}function extractOverlap(src,srcW,overlapW,overlapH){let out=new Uint8Array(overlapW*overlapH*4);for(let y=0;y<overlapH;y++){let srcRow=y*srcW*4,dstRow=y*overlapW*4;out.set(src.subarray(srcRow,srcRow+overlapW*4),dstRow)}return out}function buildMaskGrid(boxes,unionW,unionH){let grid=new Uint8Array(unionW*unionH);for(let box of boxes){let x0=Math.max(0,Math.floor(box.x)),y0=Math.max(0,Math.floor(box.y)),x1=Math.min(unionW,Math.floor(box.x+box.width)),y1=Math.min(unionH,Math.floor(box.y+box.height));for(let y=y0;y<y1;y++)for(let x=x0;x<x1;x++)grid[y*unionW+x]=1}return grid}function applyMaskToOverlap(buf,overlapW,overlapH,maskGrid,unionW){for(let y=0;y<overlapH;y++)for(let x=0;x<overlapW;x++)if(maskGrid[y*unionW+x]===1){let off=(y*overlapW+x)*4;buf[off]=0,buf[off+1]=0,buf[off+2]=0,buf[off+3]=255}}function extractDiffRegions(mask,width,height,maxRegions){let visited=new Uint8Array(width*height),regions=[],stack=[];for(let start=0;start<mask.length;start++){if(mask[start]===0||visited[start]===1)continue;let minX=width,minY=height,maxX=-1,maxY=-1,pixels=0;for(stack.length=0,stack.push(start),visited[start]=1;stack.length>0;){let idx=stack.pop(),x=idx%width,y=(idx-x)/width;if(pixels++,x<minX&&(minX=x),y<minY&&(minY=y),x>maxX&&(maxX=x),y>maxY&&(maxY=y),x>0){let n=idx-1;mask[n]===1&&visited[n]===0&&(visited[n]=1,stack.push(n))}if(x<width-1){let n=idx+1;mask[n]===1&&visited[n]===0&&(visited[n]=1,stack.push(n))}if(y>0){let n=idx-width;mask[n]===1&&visited[n]===0&&(visited[n]=1,stack.push(n))}if(y<height-1){let n=idx+width;mask[n]===1&&visited[n]===0&&(visited[n]=1,stack.push(n))}}regions.push({x:minX,y:minY,width:maxX-minX+1,height:maxY-minY+1,pixels})}return regions.sort((a,b)=>b.pixels!==a.pixels?b.pixels-a.pixels:a.y!==b.y?a.y-b.y:a.x-b.x),regions.slice(0,Math.max(0,maxRegions))}function computePngVisualDiff(input){let comp=decodePng(input.compPngBuffer,"comp");if("ok"in comp&&comp.ok===!1)return comp;let render=decodePng(input.renderPngBuffer,"render");if("ok"in render&&render.ok===!1)return render;let compImg=comp,renderImg=render,dimensionMatch=compImg.width===renderImg.width&&compImg.height===renderImg.height,unionW=Math.max(compImg.width,renderImg.width),unionH=Math.max(compImg.height,renderImg.height),overlapW=Math.min(compImg.width,renderImg.width),overlapH=Math.min(compImg.height,renderImg.height),maskGrid=buildMaskGrid(input.maskBoxes??[],unionW,unionH),output=new Uint8Array(unionW*unionH*4),diffMask=new Uint8Array(unionW*unionH),differingPixels=0;if(overlapW>0&&overlapH>0){let compOverlap=extractOverlap(compImg.data,compImg.width,overlapW,overlapH),renderOverlap=extractOverlap(renderImg.data,renderImg.width,overlapW,overlapH);applyMaskToOverlap(compOverlap,overlapW,overlapH,maskGrid,unionW),applyMaskToOverlap(renderOverlap,overlapW,overlapH,maskGrid,unionW);let overlapOut=new Uint8Array(overlapW*overlapH*4);try{differingPixels=pixelmatch(compOverlap,renderOverlap,overlapOut,overlapW,overlapH,{threshold:input.pixelmatchColorThreshold,includeAA:!1,diffColor:[255,0,0],diffColorAlt:[255,0,0],aaColor:[255,255,0]})}catch{return{ok:!1,error:"DIFF_FAILED",status:500,message:"Pixel comparison failed."}}for(let y=0;y<overlapH;y++)for(let x=0;x<overlapW;x++){let so=(y*overlapW+x)*4,uo=(y*unionW+x)*4;output[uo]=overlapOut[so],output[uo+1]=overlapOut[so+1],output[uo+2]=overlapOut[so+2],output[uo+3]=255,overlapOut[so]===255&&overlapOut[so+1]===0&&overlapOut[so+2]===0&&(diffMask[y*unionW+x]=1)}}for(let y=0;y<unionH;y++)for(let x=0;x<unionW;x++){let inComp=x<compImg.width&&y<compImg.height,inRender=x<renderImg.width&&y<renderImg.height;if(inComp===inRender||maskGrid[y*unionW+x]===1)continue;let off=(y*unionW+x)*4;output[off]=255,output[off+1]=0,output[off+2]=0,output[off+3]=255,diffMask[y*unionW+x]=1,differingPixels++}let diffRegions=extractDiffRegions(diffMask,unionW,unionH,input.maxRegions??MAX_DIFF_REGIONS),totalPixels=unionW*unionH,mismatchPct=totalPixels>0?differingPixels/totalPixels*100:0,passed=dimensionMatch&&mismatchPct<=input.passMismatchPct,heatmapBase64;try{let png=new PNG({width:unionW,height:unionH});png.data=Buffer.from(output),heatmapBase64=PNG.sync.write(png).toString("base64")}catch{return{ok:!1,error:"DIFF_FAILED",status:500,message:"Failed to encode diff heatmap."}}return{ok:!0,mismatch_pct:mismatchPct,dimension_match:dimensionMatch,passed,diff_regions:diffRegions,comp_dimensions:{width:compImg.width,height:compImg.height},render_dimensions:{width:renderImg.width,height:renderImg.height},differing_pixels:differingPixels,total_pixels:totalPixels,heatmap_base64:heatmapBase64}}if(!isMainThread&&parentPort)try{let result=computePngVisualDiff(workerData);parentPort.postMessage(result)}catch(err){parentPort.postMessage({ok:!1,error:"DIFF_FAILED",status:500,message:`Diff worker failed: ${err instanceof Error?err.message:"unknown error"}`})}var NAV_TIMEOUT_MS=3e4,NETWORK_IDLE_TIMEOUT_MS=15e3,FONTS_READY_TIMEOUT_MS=5e3,SCREENSHOT_TIMEOUT_MS=2e4,MAX_VIEWPORT_DIMENSION=16384,MAX_VIEWPORT_PIXELS=32e6,MAX_COMP_BYTES=25*1024*1024,DETERMINISTIC_CSS="* { animation: none !important; transition: none !important; caret-color: transparent !important; }";function textJson(value){return{type:"text",text:JSON.stringify(value,null,2)}}function errorContent(error,status,message,extra){return{content:[{type:"text",text:JSON.stringify({error,status,message,...extra??{}})}]}}var PNG_MAGIC=[137,80,78,71,13,10,26,10];function sniffImageFormat(bytes){return bytes.length>=8&&PNG_MAGIC.every((b,i)=>bytes[i]===b)?"png":bytes.length>=3&&bytes[0]===255&&bytes[1]===216&&bytes[2]===255?"jpeg":null}function readPngDimensions(bytes){if(bytes.length<24||sniffImageFormat(bytes)!=="png")return{ok:!1,message:"Not a valid PNG header."};if(bytes[12]!==73||bytes[13]!==72||bytes[14]!==68||bytes[15]!==82)return{ok:!1,message:"PNG IHDR chunk not found."};let width=readUInt32BE(bytes,16),height=readUInt32BE(bytes,20);return width<=0||height<=0?{ok:!1,message:"PNG reports non-positive dimensions."}:{ok:!0,width,height}}function readJpegDimensions(bytes){if(sniffImageFormat(bytes)!=="jpeg")return{ok:!1,message:"Not a valid JPEG header."};let offset=2,len=bytes.length;for(;offset+1<len;){if(bytes[offset]!==255){offset++;continue}let marker=bytes[offset+1];for(;marker===255&&offset+1<len;)offset++,marker=bytes[offset+1];if(offset+=2,marker>=208&&marker<=217||marker===1)continue;if(offset+1>=len)break;let segLen=readUInt16BE(bytes,offset);if(marker>=192&&marker<=207&&marker!==196&&marker!==200&&marker!==204){if(offset+5>=len)break;let height=readUInt16BE(bytes,offset+3),width=readUInt16BE(bytes,offset+5);return width<=0||height<=0?{ok:!1,message:"JPEG SOF reports non-positive dimensions."}:{ok:!0,width,height}}offset+=segLen}return{ok:!1,message:"No supported JPEG SOF marker found."}}function readUInt32BE(b,o){return b[o]*16777216+(b[o+1]<<16)+(b[o+2]<<8)+b[o+3]}function readUInt16BE(b,o){return(b[o]<<8)+b[o+1]}function isPositiveInt(n){return Number.isInteger(n)&&n>0}function resolveViewport(input,compDimensions){let vp=input.viewport??compDimensions;return!isPositiveInt(vp.width)||!isPositiveInt(vp.height)?{ok:!1,error:"INVALID_VIEWPORT",status:400,message:`Viewport must be positive integers, got ${vp.width}x${vp.height}.`}:vp.width>MAX_VIEWPORT_DIMENSION||vp.height>MAX_VIEWPORT_DIMENSION||vp.width*vp.height>MAX_VIEWPORT_PIXELS?{ok:!1,error:"IMAGE_TOO_LARGE",status:413,message:`Requested render area ${vp.width}x${vp.height} exceeds the local pixel guard.`}:{ok:!0,viewport:{width:vp.width,height:vp.height}}}function toUint8(bytes){return bytes instanceof Uint8Array?bytes:Buffer.from(bytes)}async function resolveCompRef(compRef,deps){let candidates=[];if(path51.isAbsolute(compRef))candidates.push(compRef);else{let root=await deps.getProjectRoot();candidates.push(path51.resolve(root,compRef));let cwdCandidate=path51.resolve(process.cwd(),compRef);candidates.includes(cwdCandidate)||candidates.push(cwdCandidate)}for(let candidate of candidates)try{let st=await deps.stat(candidate);if(st&&st.isFile())return{ok:!0,bytes:toUint8(await deps.readFile(candidate)),source:"local",sourcePath:candidate,warnings:[]}}catch{}let trimmed=compRef.trim(),lookup=/^\d+$/.test(trimmed)?{kind:"attachment_id",attachment_id:trimmed}:{kind:"filename",filename:compRef},fetched=await deps.fetchAttachmentBytes(lookup);if(!fetched.ok)return{ok:!1,error:fetched.error,status:fetched.status,message:fetched.message};let bytes=toUint8(fetched.bytes),warnings=[];try{let dir=await deps.getDocsPath("visual-diffs"),rawName=fetched.filename||(lookup.kind==="attachment_id"?`attachment-${lookup.attachment_id}`:lookup.filename),base=path51.basename(rawName),target=path51.resolve(dir,`comp-${deps.safeTimestampForFilename()}-${base}`);target.startsWith(path51.resolve(dir)+path51.sep)?(await deps.mkdir(dir,{recursive:!0}),await deps.writeFile(target,Buffer.from(bytes))):warnings.push("Skipped saving attachment comp copy: resolved path escaped the visual-diffs directory.")}catch(err){warnings.push(`Attachment comp copy could not be saved locally: ${err instanceof Error?err.message:"unknown error"}`)}return{ok:!0,bytes,source:"attachment",warnings}}async function loadPlaywright(){try{let mod=await import("playwright"),chromium=mod?.chromium??mod?.default?.chromium;return!chromium||typeof chromium.launch!="function"?{ok:!1}:{ok:!0,playwright:{chromium}}}catch{return{ok:!1}}}async function launchBrowser(playwright){try{return{ok:!0,browser:await playwright.chromium.launch({headless:!0})}}catch(err){return{ok:!1,error:"BROWSER_UNAVAILABLE",status:503,message:`Chromium could not be launched: ${err instanceof Error?err.message:"unknown error"}. Run "npx playwright install chromium".`}}}function normalizeMaskBoxes(raw,viewport){let boxes=[];for(let r of raw){let x0=Math.max(0,Math.floor(r.x)),y0=Math.max(0,Math.floor(r.y)),x1=Math.min(viewport.width,Math.ceil(r.x+r.width)),y1=Math.min(viewport.height,Math.ceil(r.y+r.height)),width=x1-x0,height=y1-y0;width>0&&height>0&&boxes.push({x:x0,y:y0,width,height})}return boxes.sort((a,b)=>a.y!==b.y?a.y-b.y:a.x!==b.x?a.x-b.x:a.width!==b.width?a.width-b.width:a.height-b.height),boxes}async function collectMaskBoxes(page,selectors,viewport){if(!selectors||selectors.length===0)return[];let raw=await page.evaluate(sels=>{let out=[];for(let sel of sels)document.querySelectorAll(sel).forEach(el=>{let rect=el.getBoundingClientRect();out.push({x:rect.x,y:rect.y,width:rect.width,height:rect.height})});return out},selectors);return normalizeMaskBoxes(Array.isArray(raw)?raw:[],viewport)}async function captureRenderPng(browser,targetUrl,viewport,maskSelectors){let context=await browser.newContext({viewport,deviceScaleFactor:1}),page;try{page=await context.newPage();try{await page.goto(targetUrl,{timeout:NAV_TIMEOUT_MS,waitUntil:"load"}),await page.waitForLoadState("networkidle",{timeout:NETWORK_IDLE_TIMEOUT_MS})}catch(err){return{ok:!1,error:isTimeoutError(err)?"PAGE_TIMEOUT":"PAGE_LOAD_FAILED",status:isTimeoutError(err)?504:502,message:`Failed to load ${targetUrl}: ${err instanceof Error?err.message:"unknown error"}`}}await page.addStyleTag({content:DETERMINISTIC_CSS}),await settleFonts(page);let maskBoxes=await collectMaskBoxes(page,maskSelectors,viewport),png;try{png=await page.screenshot({clip:{x:0,y:0,width:viewport.width,height:viewport.height},timeout:SCREENSHOT_TIMEOUT_MS,animations:"disabled"})}catch(err){return{ok:!1,error:isTimeoutError(err)?"PAGE_TIMEOUT":"PAGE_LOAD_FAILED",status:isTimeoutError(err)?504:502,message:`Screenshot capture failed: ${err instanceof Error?err.message:"unknown error"}`}}return{ok:!0,png:toUint8(png),maskBoxes,dimensions:viewport}}finally{try{page&&await page.close()}catch{}try{await context.close()}catch{}}}async function settleFonts(page){try{await Promise.race([page.evaluate(()=>{let d=document;return d.fonts&&d.fonts.ready?d.fonts.ready.then(()=>!0):!0}),new Promise(resolve2=>setTimeout(resolve2,FONTS_READY_TIMEOUT_MS))])}catch{}}function isTimeoutError(err){let msg=err instanceof Error?err.message:String(err??"");return/timeout|timed out|TimeoutError/i.test(msg)}async function normalizeCompToPng(browser,compBytes,format,dims){if(format==="png")return{ok:!0,png:Buffer.from(compBytes)};let context=await browser.newContext({viewport:dims,deviceScaleFactor:1}),page;try{page=await context.newPage(),page.setContent&&await page.setContent("<!doctype html><html><body></body></html>");let dataUrl=`data:image/jpeg;base64,${Buffer.from(compBytes).toString("base64")}`,base64=(await page.evaluate(async arg=>{let img=new Image;await new Promise((resolve2,reject)=>{img.onload=()=>resolve2(),img.onerror=()=>reject(new Error("image load failed")),img.src=arg.url});let canvas=document.createElement("canvas");canvas.width=arg.w,canvas.height=arg.h;let ctx=canvas.getContext("2d");if(!ctx)throw new Error("no 2d context");return ctx.drawImage(img,0,0,arg.w,arg.h),canvas.toDataURL("image/png")},{url:dataUrl,w:dims.width,h:dims.height})).split(",")[1]??"";return base64?{ok:!0,png:Buffer.from(base64,"base64")}:{ok:!1,error:"IMAGE_DECODE_FAILED",status:422,message:"Browser-side JPEG normalization produced no PNG data."}}catch(err){return{ok:!1,error:"UNSUPPORTED_COMP_IMAGE",status:400,message:`Failed to normalize JPEG comp to PNG: ${err instanceof Error?err.message:"unknown error"}`}}finally{try{page&&await page.close()}catch{}try{await context.close()}catch{}}}async function computeVisualDiffInWorker(args,deps){try{return await runInWorker(args)}catch(err){deps?.logger?.(`visual_diff: worker unavailable, computing inline (${err instanceof Error?err.message:"unknown error"}).`);try{return computePngVisualDiff(args)}catch(inlineErr){return{ok:!1,error:"DIFF_FAILED",status:500,message:`Diff computation failed: ${inlineErr instanceof Error?inlineErr.message:"unknown error"}`}}}}function runInWorker(args){return new Promise((resolve2,reject)=>{let workerRelative="./visual-diff-worker.js",workerUrl=new URL(workerRelative,import.meta.url),settled=!1,worker;try{worker=new Worker(workerUrl,{workerData:args})}catch(err){reject(err);return}worker.once("message",msg=>{settled=!0,resolve2(msg),worker.terminate()}),worker.once("error",err=>{settled||reject(err)}),worker.once("exit",code=>{!settled&&code!==0&&reject(new Error(`diff worker exited with code ${code}`))})})}async function saveHeatmap(heatmapBase64,deps){try{let dir=await deps.getDocsPath("visual-diffs"),target=path51.resolve(dir,`visual-diff-${deps.safeTimestampForFilename()}.png`);return target.startsWith(path51.resolve(dir)+path51.sep)?(await deps.mkdir(dir,{recursive:!0}),await deps.writeFile(target,Buffer.from(heatmapBase64,"base64")),{ok:!0,path:target}):{ok:!1,warning:"Heatmap not saved: resolved path escaped the visual-diffs directory."}}catch(err){return{ok:!1,warning:`Heatmap could not be saved locally: ${err instanceof Error?err.message:"unknown error"}`}}}async function runVisualDiff(input,deps){try{let warnings=[],resolved=await resolveCompRef(input.comp_ref,deps);if(!resolved.ok)return errorContent(resolved.error,resolved.status,resolved.message);warnings.push(...resolved.warnings);let compBytes=resolved.bytes;if(compBytes.length>MAX_COMP_BYTES)return errorContent("IMAGE_TOO_LARGE",413,`Comp image is ${compBytes.length} bytes, exceeding the ${MAX_COMP_BYTES}-byte guard.`);let format=sniffImageFormat(compBytes);if(!format)return errorContent("UNSUPPORTED_COMP_IMAGE",400,"comp_ref resolved but is not a decodable PNG or JPEG image.");let dims=format==="png"?readPngDimensions(compBytes):readJpegDimensions(compBytes);if(!dims.ok)return errorContent("UNSUPPORTED_COMP_IMAGE",400,dims.message);let compDimensions={width:dims.width,height:dims.height},vp=resolveViewport(input,compDimensions);if(!vp.ok)return errorContent(vp.error,vp.status,vp.message);let loaded=await(deps.loadPlaywright??loadPlaywright)();if(!loaded.ok)return errorContent("BROWSER_UNAVAILABLE",503,'The optional Playwright browser runtime is not available. Install it with "npm i playwright && npx playwright install chromium".');let launch=await launchBrowser(loaded.playwright);if(!launch.ok)return errorContent(launch.error,launch.status,launch.message);let browser=launch.browser,capture,normalized;try{if(capture=await captureRenderPng(browser,input.target_url,vp.viewport,input.mask_selectors),!capture.ok)return errorContent(capture.error,capture.status,capture.message);if(normalized=await normalizeCompToPng(browser,compBytes,format,compDimensions),!normalized.ok)return errorContent(normalized.error,normalized.status,normalized.message)}finally{try{await browser.close()}catch{}}let passMismatchPct=typeof input.threshold=="number"?input.threshold:DEFAULT_PASS_MISMATCH_PCT,diff=await computeVisualDiffInWorker({compPngBuffer:normalized.png,renderPngBuffer:capture.png,maskBoxes:capture.maskBoxes,passMismatchPct,pixelmatchColorThreshold:PIXELMATCH_COLOR_THRESHOLD},deps);if(!diff.ok)return errorContent(diff.error,diff.status,diff.message);let saved=await saveHeatmap(diff.heatmap_base64,deps),heatmapPath=saved.ok?saved.path:null;saved.ok||warnings.push(saved.warning);let result={mismatch_pct:diff.mismatch_pct,dimension_match:diff.dimension_match,diff_regions:diff.diff_regions,heatmap_path:heatmapPath,comp_dimensions:diff.comp_dimensions,render_dimensions:diff.render_dimensions,threshold_used:passMismatchPct,passed:diff.passed};return diff.dimension_match||(result.message=`Render dimensions ${diff.render_dimensions.width}x${diff.render_dimensions.height} differ from comp dimensions ${diff.comp_dimensions.width}x${diff.comp_dimensions.height}. Images were NOT rescaled, so this result is automatically not passed; the diff covers the union region.`),warnings.length>0&&(result.warnings=warnings),{content:[textJson(result),{type:"image",data:diff.heatmap_base64,mimeType:"image/png"}]}}catch(err){return errorContent("VISUAL_DIFF_FAILED",500,`visual_diff failed: ${err instanceof Error?err.message:"unknown error"}`)}}function validateEstimateEpicInput(input){let hasEpic=typeof input.epic_key=="string"&&input.epic_key.trim().length>0,hasKeys=Array.isArray(input.ticket_keys);return hasEpic&&hasKeys?"epic_key and ticket_keys are mutually exclusive; supply exactly one, never both.":!hasEpic&&!hasKeys?"Exactly one of epic_key or ticket_keys is required.":hasKeys&&input.ticket_keys.length===0?"ticket_keys was supplied but contained no keys; provide at least one, non-empty ticket_keys.":null}function buildEstimateEpicErrorEnvelope(code,message,extras){return JSON.stringify({error:code,message,...extras??{}},null,2)}async function runEstimateEpic(input,deps){let validationError2=validateEstimateEpicInput(input);if(validationError2)return{content:[{type:"text",text:buildEstimateEpicErrorEnvelope("VALIDATION_ERROR",validationError2)}]};let payload={repo_name:deps.repoName};typeof input.epic_key=="string"&&(payload.epic_key=input.epic_key),Array.isArray(input.ticket_keys)&&(payload.ticket_keys=input.ticket_keys),typeof input.allow_partial=="boolean"&&(payload.allow_partial=input.allow_partial);let fetchImpl=deps.fetchImpl??fetch,resp;try{resp=await fetchImpl(deps.buildUrl("/estimate-epic"),{method:"POST",headers:await deps.getPostHeaders(),body:JSON.stringify(payload)})}catch{return{content:[{type:"text",text:buildEstimateEpicErrorEnvelope("NETWORK_ERROR","Failed to reach the Bridge API estimate-epic endpoint.")}]}}return{content:[{type:"text",text:await deps.handleResponse(resp)}]}}function text2(value){return{content:[{type:"text",text:value}]}}async function postDirectInvocation(deps,path53,body,ticketNumber){let resp;try{resp=await(deps.fetchImpl??fetch)(deps.buildUrl(path53),{method:"POST",headers:await deps.getPostHeaders(),body:JSON.stringify({...body,repo_name:deps.repoName})})}catch{return text2(`The request could not be delivered, so it is unknown whether it was accepted. Call get_ticket_state for ${ticketNumber} to check before retrying.`)}return resp.ok?text2(await resp.text()):text2(await deps.handleResponse(resp))}async function runRequestTicketUpdate(args,deps){return postDirectInvocation(deps,`/ticket/${encodeURIComponent(args.ticket_number)}/generate-ticket-update`,{},args.ticket_number)}async function runRequestEstimate(args,deps){return postDirectInvocation(deps,`/ticket/${encodeURIComponent(args.ticket_number)}/generate-estimate`,{recreate:args.recreate===!0},args.ticket_number)}async function runGetTicketUpdateReview(args,deps){let url=deps.buildGetUrl(`/ticket/${encodeURIComponent(args.ticket_number)}/ticket-update-review`,{repo_name:deps.repoName}),resp;try{resp=await(deps.fetchImpl??fetch)(url,{headers:await deps.getHeaders()})}catch{return text2(`The held-for-review proposal for ${args.ticket_number} could not be fetched. Retry shortly.`)}return resp.ok?text2(await resp.text()):text2(await deps.handleResponse(resp))}init_git_ci_types();init_git_ci_types();init_done_gate();init_merge_identity();init_local_merge();var DRY_RUN_HINT="set auto_merge_enabled=true on the project default via PUT /jira/epic-runs/supervisor-config/defaults/",UNKNOWN_HINT="the request was sent but its outcome was not observed; repeat this call with identical arguments \u2014 the server's action key makes it idempotent",REQUIRED_CHECKS_EMPTY="required_checks_empty",CI_NOT_GREEN_REASON="ci_not_green",REVIEW_NOT_APPROVED_REASON="review_not_approved";function text3(envelope2){return{content:[{type:"text",text:JSON.stringify(envelope2)}]}}function envelope(merged,outcome2,reason,retryHint,evaluatedHeadSha,prNumber,diagnostics={}){let result={merged,outcome:outcome2,reason,retry_hint:retryHint,evaluated_head_sha:evaluatedHeadSha,pr_number:prNumber};return diagnostics.actual_head_sha!==void 0&&(result.actual_head_sha=diagnostics.actual_head_sha),diagnostics.ci_summary!==void 0&&(result.ci_summary=diagnostics.ci_summary),diagnostics.paths!==void 0&&(result.paths=diagnostics.paths),diagnostics.hint!==void 0&&(result.hint=diagnostics.hint),diagnostics.http_status!==void 0&&(result.http_status=diagnostics.http_status),diagnostics.completion!==void 0&&(result.completion=diagnostics.completion),diagnostics.review_waiver!==void 0&&(result.review_waiver=diagnostics.review_waiver),result}var SHA_RE2=/^[0-9a-fA-F]{40}$/;function isPlainObject12(value){return typeof value=="object"&&value!==null&&!Array.isArray(value)}function validateInputs(prNumber,expectedHeadSha){return typeof prNumber!="number"||!Number.isSafeInteger(prNumber)||prNumber<=0?"invalid_pr_number":typeof expectedHeadSha!="string"||!SHA_RE2.test(expectedHeadSha)?"invalid_expected_head_sha":null}async function readJson2(resp){try{let body=await resp.text();return body.trim().length===0?void 0:JSON.parse(body)}catch{return}}function normalizeCheckNames(raw){let out=[],seen=new Set;for(let candidate of raw){let name=normalizeCheckName(candidate);name===null||seen.has(name)||(seen.add(name),out.push(name))}return out}async function resolveRequiredChecks(deps,expectedHeadSha){let fetchImpl=deps.fetchImpl??fetch,defaultsUrl=deps.buildGetUrl("/epic-runs/supervisor-setup/defaults/",{repo_name:deps.repoName}),defaultsHeaders=await deps.getHeaders(),defaultsResp;try{defaultsResp=await fetchImpl(defaultsUrl,{headers:defaultsHeaders})}catch{return null}if(!defaultsResp.ok)return await deps.handleResponse(defaultsResp).catch(()=>""),null;let defaults=await readJson2(defaultsResp);if(!isPlainObject12(defaults))return null;let rawGateConfig=defaults.done_gate_config;if(rawGateConfig!=null){let parsed=parseDoneGateConfig(rawGateConfig);if(!parsed.enabled||!parsed.valid)return{checks:[],configHash:null,reviewCondition:null};let condition=parsed.conditions.find(c=>c.type===REQUIRED_CI_CHECKS_GREEN);if(condition===void 0||condition.type!==REQUIRED_CI_CHECKS_GREEN)return{checks:[],configHash:null,reviewCondition:null};let review=parsed.conditions.find(c=>c.type===REVIEW_STATE),reviewCondition=review!==void 0&&review.type===REVIEW_STATE?review:null;return{checks:[...condition.required_checks],configHash:parsed.config_hash,reviewCondition}}let resolverUrl=deps.buildUrl("/resolve-ci-checks"),resolverHeaders=await deps.getPostHeaders(),resolverResp;try{resolverResp=await fetchImpl(resolverUrl,{method:"POST",headers:resolverHeaders,body:JSON.stringify({repo_name:deps.repoName,commit_ref:expectedHeadSha})})}catch{return null}if(!resolverResp.ok)return await deps.handleResponse(resolverResp).catch(()=>""),null;let resolved=await readJson2(resolverResp);if(!isPlainObject12(resolved))return null;let detail=resolved.detail;if(!isPlainObject12(detail))return{checks:[],configHash:null,reviewCondition:null};let rawChecks=detail.checks;if(!Array.isArray(rawChecks))return{checks:[],configHash:null,reviewCondition:null};let requiredNames=rawChecks.filter(entry=>isPlainObject12(entry)&&entry.required===!0).map(entry=>entry.name);return{checks:normalizeCheckNames(requiredNames),configHash:null,reviewCondition:null}}var SUPPORTED_REVIEW_SOURCES=new Set(["verdict_protocol","native_review_decision"]),REVIEW_UNAVAILABLE_REASON="review_unavailable",REVIEW_SOURCE_UNSUPPORTED_REASON="review_source_unsupported",HEAD_SHA_DRIFT_REASON="head_sha_drift";function refuse(env){return{kind:"refused",envelope:env}}function withReviewWaiver(env,waiver){return waiver===void 0?env:{...env,review_waiver:waiver}}function verdictIsGenuinelyAbsent(condition,snapshot,rawBody){if(condition.source!=="verdict_protocol")return!0;if(snapshot.sticky_verdict!==null)return!1;let detail=isPlainObject12(rawBody)&&isPlainObject12(rawBody.detail)?rawBody.detail:null;if(detail===null)return!1;let raw=detail.sticky_verdict;return raw==null}function effectiveDisposition(condition){return condition.verdictless_disposition??VERDICTLESS_DISPOSITION_PARK}async function precheckReviewCondition(deps,condition,prNumber,expectedHeadSha){if(!SUPPORTED_REVIEW_SOURCES.has(condition.source))return refuse(envelope(!1,"review_source_unsupported",REVIEW_SOURCE_UNSUPPORTED_REASON,"needs_human",expectedHeadSha,prNumber));let unavailable=()=>refuse(envelope(!1,"review_unavailable",REVIEW_UNAVAILABLE_REASON,"needs_human",expectedHeadSha,prNumber)),reviewUrl=deps.buildApiUrl(`/vcs/pull-requests/${prNumber}/reviews/status`)+`?repo_name=${encodeURIComponent(deps.repoName)}`,reviewHeaders=await deps.getHeaders(),reviewResp;try{reviewResp=await(deps.fetchImpl??fetch)(reviewUrl,{headers:reviewHeaders})}catch{return unavailable()}if(!reviewResp.ok)return await deps.handleResponse(reviewResp).catch(()=>""),unavailable();let reviewBody=await readJson2(reviewResp),snapshot=normalizeReviewSnapshot(reviewBody);if(snapshot===null)return unavailable();if(snapshot.head_sha!==expectedHeadSha){let diagnostics={};return typeof snapshot.head_sha=="string"&&snapshot.head_sha.length>0&&(diagnostics.actual_head_sha=snapshot.head_sha),refuse(envelope(!1,"refused",HEAD_SHA_DRIFT_REASON,"needs_human",expectedHeadSha,prNumber,diagnostics))}let evaluation=evaluateReviewCondition(condition,snapshot);return evaluation.passed?{kind:"proceed"}:effectiveDisposition(condition)===VERDICTLESS_DISPOSITION_FAIL_OPEN&&evaluation.changesRequested===!1&&verdictIsGenuinelyAbsent(condition,snapshot,reviewBody)?{kind:"waived",waiver:MERGE_REVIEW_WAIVED_BY_DEGRADATION_REASON}:refuse(envelope(!1,"review_not_approved",evaluation.reason,"retry_later",expectedHeadSha,prNumber))}function extractDiagnostics(body){let diagnostics={},events=body.ledger_events;if(!Array.isArray(events))return diagnostics;for(let event of events){if(!isPlainObject12(event)||event.status!=="failed")continue;let details=event.details;if(!isPlainObject12(details))continue;let guard=isPlainObject12(details.guard_outcomes)?details.guard_outcomes:{};if(diagnostics.actual_head_sha===void 0&&typeof guard.actual_head_sha=="string"&&(diagnostics.actual_head_sha=guard.actual_head_sha),diagnostics.ci_summary===void 0){let summary=isPlainObject12(guard.ci_summary)?guard.ci_summary:isPlainObject12(details.ci_summary)?details.ci_summary:void 0;summary!==void 0&&(diagnostics.ci_summary=summary)}diagnostics.paths===void 0&&Array.isArray(guard.paths)&&(diagnostics.paths=guard.paths.filter(p=>typeof p=="string"))}return diagnostics}function hasIncompleteRequiredCheck(ciSummary){if(!isPlainObject12(ciSummary))return!1;let checks=ciSummary.checks;return Array.isArray(checks)?checks.some(check=>isPlainObject12(check)&&check.complete!==!0&&check.present!==!1):!1}function retryHintForFailure(reason,ciSummary){return reason===CI_NOT_GREEN_REASON?hasIncompleteRequiredCheck(ciSummary)?"retry_later":"needs_human":reason===REVIEW_NOT_APPROVED_REASON?"retry_later":"needs_human"}function interpretMergeResponse(body,expectedHeadSha,prNumber){let malformed=()=>envelope(!1,"error","malformed_merge_response","needs_human",expectedHeadSha,prNumber);if(!isPlainObject12(body))return malformed();let status=body.status,reason=typeof body.reason=="string"?body.reason:null;if(typeof status!="string")return malformed();if(status==="succeeded")return body.terminal!==!0?malformed():reason==="already_merged"?envelope(!0,"already_merged",reason,null,expectedHeadSha,prNumber):reason==="merged"?envelope(!0,"merged",reason,null,expectedHeadSha,prNumber):malformed();if(status==="dry_run")return envelope(!1,"dry_run",reason,"needs_human",expectedHeadSha,prNumber,{hint:DRY_RUN_HINT});if(status==="pending_approval")return envelope(!1,"pending_approval",reason,"needs_human",expectedHeadSha,prNumber);if(status==="lease_held")return envelope(!1,"lease_held",reason,"retry_later",expectedHeadSha,prNumber);if(status==="failed"){if(reason===null)return malformed();let diagnostics=extractDiagnostics(body);return envelope(!1,"refused",reason,retryHintForFailure(reason,diagnostics.ci_summary),expectedHeadSha,prNumber,diagnostics)}return malformed()}var LOCAL_APPROVAL_STATUS="approved_for_local_execution",DEFAULT_MERGE_EXECUTION="local",LOCAL_GH_HINTS={local_gh_unavailable:"Install the GitHub CLI (`gh`) on the machine running this MCP server \u2014 local merges execute there.",local_gh_unauthenticated:"Run `gh auth login` in the shell that hosts this MCP server \u2014 local merges use its GitHub session."};async function resolveMergeExecutionMode(deps){try{let resp=await(deps.fetchImpl??fetch)(deps.buildGetUrl("/epic-runs/supervisor-config/defaults/",{repo_name:deps.repoName}),{headers:await deps.getHeaders()});if(!resp.ok)return DEFAULT_MERGE_EXECUTION;let body=await readJson2(resp);return isPlainObject12(body)&&body.merge_execution==="server"?"server":DEFAULT_MERGE_EXECUTION}catch{return DEFAULT_MERGE_EXECUTION}}function localApprovalMismatch(body,prNumber,expectedHeadSha,actionKey){return body.pr_number!==prNumber||typeof body.expected_head_sha!="string"||body.expected_head_sha.toLowerCase()!==expectedHeadSha.toLowerCase()||body.action_key!==actionKey}function mergeShaFromLedger(response){let events=Array.isArray(response.ledger_events)?response.ledger_events:[];for(let event of events){if(!isPlainObject12(event)||event.type!=="merge.succeeded")continue;let sha=(isPlainObject12(event.details)?event.details:{}).merge_commit_sha;if(typeof sha=="string"&&SHA_RE2.test(sha))return sha}}async function reportLocalCompletion(deps,prNumber,body){try{let resp=await(deps.fetchImpl??fetch)(deps.buildApiUrl(`/vcs/pull-requests/${prNumber}/merge/complete`),{method:"POST",headers:await deps.getPostHeaders(),body:JSON.stringify(body)});return resp.ok?await readJson2(resp):(await deps.handleResponse(resp).catch(()=>""),null)}catch{return null}}async function executeApprovedLocalMerge(deps,approval,prNumber,expectedHeadSha,actionKey){if(localApprovalMismatch(approval,prNumber,expectedHeadSha,actionKey))return envelope(!1,"refused","local_approval_mismatch","needs_human",expectedHeadSha,prNumber);let method=resolveLocalMergeMethod(approval.merge_method),request={repo_name:deps.repoName,pr_number:prNumber,expected_head_sha:expectedHeadSha,gate:{name:DEFAULT_GATE_NAME},action_key:actionKey},local;try{local=await(deps.runLocalMerge??runApprovedLocalMerge)(request,{method},{env:process.env})}catch{return await reportLocalCompletion(deps,prNumber,{repo_name:deps.repoName,action_key:actionKey,expected_head_sha:expectedHeadSha,result:"failed",reason:"gh_merge_failed"}),envelope(!1,"refused","gh_merge_failed","needs_human",expectedHeadSha,prNumber)}let localReason=typeof local.reason=="string"?local.reason:null;if(local.status==="succeeded"){let result=localReason==="already_merged"?"already_merged":"merged",mergeSha=mergeShaFromLedger(local);return await reportLocalCompletion(deps,prNumber,{repo_name:deps.repoName,action_key:actionKey,expected_head_sha:expectedHeadSha,result,...mergeSha?{merge_sha:mergeSha}:{}})===null?envelope(!0,result==="already_merged"?"already_merged":"merged",result,null,expectedHeadSha,prNumber,{completion:"unreported"}):envelope(!0,result==="already_merged"?"already_merged":"merged",result,null,expectedHeadSha,prNumber)}let reason=localReason??"gh_merge_failed";await reportLocalCompletion(deps,prNumber,{repo_name:deps.repoName,action_key:actionKey,expected_head_sha:expectedHeadSha,result:"failed",reason});let hint=LOCAL_GH_HINTS[reason];return envelope(!1,"refused",reason,"needs_human",expectedHeadSha,prNumber,{...hint?{hint}:{}})}async function mergePullRequestHandler(deps,args){let rawPr=args?.pr_number,rawSha=args?.expected_head_sha,echoedSha=typeof rawSha=="string"?rawSha:null,echoedPr=typeof rawPr=="number"?rawPr:null;try{let invalid=validateInputs(rawPr,rawSha);if(invalid!==null)return text3(envelope(!1,"error",invalid,"needs_human",echoedSha,echoedPr));let prNumber=rawPr,expectedHeadSha=rawSha,resolution=await resolveRequiredChecks(deps,expectedHeadSha);if(resolution===null||resolution.checks.length===0)return text3(envelope(!1,"gate_unresolved",REQUIRED_CHECKS_EMPTY,"needs_human",expectedHeadSha,prNumber));let reviewWaiver;if(resolution.reviewCondition!==null){let decision=await precheckReviewCondition(deps,resolution.reviewCondition,prNumber,expectedHeadSha);if(decision.kind==="refused")return text3(decision.envelope);decision.kind==="waived"&&(reviewWaiver=decision.waiver)}let gateIdentity=buildGateIdentity(DEFAULT_GATE_NAME,resolution.configHash),actionKey=makeMergeActionKey(deps.repoName,prNumber,expectedHeadSha,gateIdentity),executionMode=await resolveMergeExecutionMode(deps),mergeBody={repo_name:deps.repoName,expected_head_sha:expectedHeadSha,gate:{name:DEFAULT_GATE_NAME,config_hash:resolution.configHash,required_checks:resolution.checks},action_key:actionKey,execution:executionMode},mergeResp;try{mergeResp=await(deps.fetchImpl??fetch)(deps.buildApiUrl(`/vcs/pull-requests/${prNumber}/merge`),{method:"POST",headers:await deps.getPostHeaders(),body:JSON.stringify(mergeBody)})}catch{return text3(envelope(!1,"unknown","merge_request_not_observed","retry_later",expectedHeadSha,prNumber,{hint:UNKNOWN_HINT,review_waiver:reviewWaiver}))}if(!mergeResp.ok)return await deps.handleResponse(mergeResp).catch(()=>""),mergeResp.status===409?text3(envelope(!1,"action_key_mismatch","action_key_mismatch","needs_human",expectedHeadSha,prNumber,{review_waiver:reviewWaiver})):text3(envelope(!1,"error","merge_request_failed","needs_human",expectedHeadSha,prNumber,{http_status:mergeResp.status,review_waiver:reviewWaiver}));let mergeJson=await readJson2(mergeResp);return executionMode==="local"&&isPlainObject12(mergeJson)&&mergeJson.status===LOCAL_APPROVAL_STATUS?text3(withReviewWaiver(await executeApprovedLocalMerge(deps,mergeJson,prNumber,expectedHeadSha,actionKey),reviewWaiver)):text3(withReviewWaiver(interpretMergeResponse(mergeJson,expectedHeadSha,prNumber),reviewWaiver))}catch{return text3(envelope(!1,"error","handler_error","needs_human",echoedSha,echoedPr))}}import{ListToolsRequestSchema}from"@modelcontextprotocol/sdk/types.js";init_index_scope_contract();var NOT_STALE={stale:!1};function createUpdateStatusManager(options={}){let check=options.check??checkForUpdate,warn=options.warn??(message=>console.error(message)),started=!1,settled=null,settledPromise=null,warned=!1,listServed=!1,lateNotified=!1;function conclude(result){if(!result||typeof result!="object"||result.updateAvailable!==!0)return NOT_STALE;let{currentVersion,latestVersion}=result;return typeof currentVersion!="string"||currentVersion.length===0||typeof latestVersion!="string"||latestVersion.length===0?NOT_STALE:{stale:!0,currentVersion,latestVersion}}function start(){started||(started=!0,settledPromise=(async()=>{let status;try{status=conclude(await check())}catch{status=NOT_STALE}if(settled=status,status.stale&&!warned&&(warned=!0,warn(formatUpdateAdvice(status.currentVersion,status.latestVersion)),listServed&&!lateNotified)){lateNotified=!0;try{options.onLateStale?.()}catch{}}return status})())}return{start,getStatus:()=>settled??NOT_STALE,whenSettled:async()=>(started||start(),await settledPromise??NOT_STALE),markListServed:()=>{listServed=!0}}}function updateAdvisoryFor(status){return!status.stale||!status.currentVersion||!status.latestVersion?null:formatToolSurfaceUpdateAdvisory(status.currentVersion,status.latestVersion)}var PIPELINES2={...PIPELINES},INSTRUCTIONS2={...INSTRUCTIONS},userPipelineKeys=new Set,BASE_URL=process.env.BAPI_BASE_URL??"https://bridgegpt-api.com",REPO_NAME=process.env.BAPI_REPO_NAME??"",INDEX_SCOPE,UPGRADE_ADVICE_SURFACING_ENABLED=parseDefaultOnEnvFlag(process.env.BAPI_MCP_UPGRADE_ADVICE_ENABLED),TOOL_SURFACE_GATING_ENABLED=parseDefaultOnEnvFlag(process.env.BAPI_MCP_TOOL_SURFACE_GATING_ENABLED),TOOL_SURFACE_POLL_ENABLED=parseDefaultOffEnvFlag(process.env.BAPI_MCP_TOOL_SURFACE_POLL_ENABLED),ACTIVE_GROUPS=resolveProfiles(process.env.BRIDGE_MCP_PROFILE),resolvedApiKeyPromise;async function getResolvedApiKey(){return resolvedApiKeyPromise||(resolvedApiKeyPromise=(async()=>{try{let result=await resolveBapiCredentials(REPO_NAME,{env:process.env,homedir:os23.homedir,platform:process.platform,readFile:p=>readFile18(p,"utf-8"),stat:p=>stat11(p)});return result.ok?result.credentials.apiKey:""}catch{return""}})()),resolvedApiKeyPromise}async function getResolvedApiKeyForRepo(repoName){try{let result=await resolveBapiCredentials(repoName,{env:process.env,homedir:os23.homedir,platform:process.platform,readFile:p=>readFile18(p,"utf-8"),stat:p=>stat11(p)});return result.ok?result.credentials.apiKey:""}catch{return""}}function buildCredentialStoreWriteDeps(){return{env:process.env,homedir:os23.homedir,platform:process.platform,readFile:p=>readFile18(p,"utf-8"),mkdir:(p,options)=>mkdir15(p,options),writeFile:(p,data,options)=>writeFile14(p,data,options),rename:(oldPath,newPath)=>rename5(oldPath,newPath),chmod:(p,mode)=>chmod4(p,mode),unlink:p=>unlink4(p),open:async(p,flags,mode)=>{let handle=await open6(p,flags,mode);return{writeFile:data=>handle.writeFile(data,{encoding:"utf-8"}),sync:()=>handle.sync(),close:()=>handle.close()}}}}function withIndexScopeHeader(headers,options){return INDEX_SCOPE&&!options.scopeAddressed&&(headers[INDEX_SCOPE_HEADER]=INDEX_SCOPE),headers}async function getGetHeaders(options={}){return withIndexScopeHeader({"X-API-Key":await getResolvedApiKey(),"X-Bridge-MCP-Version":VERSION,"X-Bridge-MCP-Commit":BUILD_COMMIT},options)}async function getPostHeaders(options={}){return withIndexScopeHeader({"X-API-Key":await getResolvedApiKey(),"Content-Type":"application/json","X-Bridge-MCP-Version":VERSION,"X-Bridge-MCP-Commit":BUILD_COMMIT},options)}var serverConnected=!1;async function resolveProjectRootFromRootsList(){if(!serverConnected)return null;try{let result=await server.server.listRoots(),roots=Array.isArray(result?.roots)?result.roots:[];for(let root of roots){let uri=root?.uri;if(typeof uri=="string"&&uri.startsWith("file://"))try{return fileURLToPath4(uri)}catch{}}return null}catch{return null}}var projectRootPromise;async function getProjectRoot(){return projectRootPromise||(projectRootPromise=(async()=>{let explicit=(process.env.BAPI_PROJECT_ROOT??"").trim();if(explicit.length>0)return explicit;let fromRoots=await resolveProjectRootFromRootsList();if(fromRoots&&fromRoots.length>0)return fromRoots;let claudeDir=(process.env.CLAUDE_PROJECT_DIR??"").trim();return claudeDir.length>0?claudeDir:process.cwd()})()),projectRootPromise}var docsDirPromise;async function getDocsDir(){return docsDirPromise||(docsDirPromise=(async()=>path52.resolve(await getProjectRoot(),process.env.BAPI_DOCS_DIR??"docs/tmp"))()),docsDirPromise}var pipelinesDirPromise;async function getPipelinesDir(){return pipelinesDirPromise||(pipelinesDirPromise=(async()=>path52.resolve(await getProjectRoot(),process.env.BAPI_PIPELINES_DIR??".bridge/pipelines"))()),pipelinesDirPromise}var{buildUrl,buildApiUrl,buildGetUrl}=createBridgeApiUrls(BASE_URL);async function getDocsPath(subdir){return path52.join(await getDocsDir(),subdir)}var customPipelinesPromise;async function ensureCustomPipelinesLoaded(){return customPipelinesPromise||(customPipelinesPromise=(async()=>{let pipelinesDir=await getPipelinesDir(),instructionsDir=path52.join(path52.dirname(pipelinesDir),"instructions"),customResult=await loadCustomPipelines(pipelinesDir,instructionsDir,INSTRUCTIONS);for(let[key,pipeline]of Object.entries(customResult.pipelines))key in PIPELINES&&console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`),PIPELINES2[key]=pipeline;Object.assign(INSTRUCTIONS2,customResult.instructions),userPipelineKeys=customResult.userPipelineKeys})()),customPipelinesPromise}var ERROR_CODES={400:"BAD_REQUEST",401:"UNAUTHORIZED",403:"FORBIDDEN",404:"NOT_FOUND",409:"CONFLICT",422:"VALIDATION_ERROR",429:"RATE_LIMITED",500:"INTERNAL_ERROR",502:"BAD_GATEWAY",503:"SERVICE_UNAVAILABLE",504:"GATEWAY_TIMEOUT"};async function handleResponse(resp){if(resp.ok){if((resp.headers.get("content-type")??"").includes("application/json")){let body=await resp.json();return formatSuccessWithTicketBackend(body,resp.headers.get(TICKET_BACKEND_HEADER))}return await resp.text()}let rawText=await resp.text(),errorCode4=ERROR_CODES[resp.status]??"UNKNOWN_ERROR",message=rawText;try{let parsed=JSON.parse(rawText);if(parsed.detail!==null&&typeof parsed.detail=="object"&&!Array.isArray(parsed.detail)){let detail=parsed.detail;return typeof detail.message=="string"?message=detail.message:message=JSON.stringify(detail),detail.error===UNSUPPORTED_IN_LOCAL_MODE_ERROR&&resp.status===409?JSON.stringify({...detail,error:UNSUPPORTED_IN_LOCAL_MODE_ERROR,status:resp.status,message}):JSON.stringify({...detail,error:errorCode4,status:resp.status,message})}parsed.detail&&(message=typeof parsed.detail=="string"?parsed.detail:JSON.stringify(parsed.detail))}catch{}return JSON.stringify({error:errorCode4,status:resp.status,message})}async function createTicketRequest(params){let payload={repo_name:REPO_NAME,summary:params.summary,description:params.description,issue_type:params.issue_type};params.priority&&(payload.priority=params.priority),params.labels&&(payload.labels=params.labels),params.assignee&&(payload.assignee=params.assignee),params.parent_key&&(payload.parent_key=params.parent_key);let resp=await fetch(buildUrl("/create-ticket"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(payload)});return handleResponse(resp)}async function saveLocally(dir,filename,content){let filePath=path52.join(dir,filename);try{return await mkdir15(dir,{recursive:!0}),await writeFile14(filePath,content,"utf-8"),`
7906
+ When the worktrees have been spawned, call \`resume_full_automation\` with \`chain_run_id\` "${row.chain_run_id}" and \`agent_result\` set to a short summary of what start-tickets reported.`;return buildNeedsAgentTaskEnvelope({chainRunId:updated.chain_run_id,chainStage:START_TICKETS_PIPELINE,chainStep:idx+1,chainTotal:total,preamble:buildPreamble(recipe,idx,updated.stages),instruction})}function numericArg(value){if(typeof value=="number"&&Number.isFinite(value))return value}async function continueChainExecution(deps,persistence,recipe,row,autoApprove){let guard=0,guardMax=1e4;for(;guard++<guardMax;){let idx=row.current_stage_index,total=recipe.stages.length;if(idx>=total){try{row=await persistence.patchRun(row.chain_run_id,{status:"completed"})}catch(err){return persistenceFailEnvelope(err,row,recipe)}return buildCompletedEnvelope(recipe,row)}let stageRecipe=recipe.stages[idx],outcome2=null;if(stageRecipe.pipeline_name===START_TICKETS_PIPELINE)return startStartTicketsStage(persistence,recipe,row);if(stageRecipe.fan_out_input?outcome2=await startOrContinueReviewTicketStage(deps,persistence,recipe,row,autoApprove):outcome2=await startOrContinueIdeaToTicketStage(deps,persistence,recipe,row,autoApprove),outcome2.kind==="pause"||outcome2.kind==="fail")return outcome2.envelope;row=outcome2.row}return failedEnvelope2("TOOL_ERROR","Chain execution exceeded its step guard.",{chain_run_id:row.chain_run_id,chain_total:recipe.stages.length})}async function runFullAutomation(deps,input){try{if(typeof input.idea!="string"||input.idea.trim()==="")return failedEnvelope2("VALIDATION","idea must be a non-empty string.");let agent=input.agent??"claude";if(agent!=="claude")return failedEnvelope2("VALIDATION",`Unsupported agent "${String(input.agent)}". Only "claude" is supported.`);let recipe=deps.chainRecipes[CHAIN_NAME];if(!recipe)return failedEnvelope2("VALIDATION",`Chain recipe "${CHAIN_NAME}" not found.`);let autoApprove=input.auto_approve===void 0?!0:normalizeAutoApprove2(input.auto_approve),args={idea:input.idea,auto_approve:autoApprove,scheduled_at:input.scheduled_at??"",max_children:input.max_children,allow_duplicate:input.allow_duplicate,agent,ttl_seconds:input.ttl_seconds},initialStages=recipe.stages.map(stage=>({pipeline_name:stage.pipeline_name,status:"pending"})),persistence=createChainPersistenceClient({baseUrl:deps.baseUrl,apiKey:deps.apiKey,repoName:deps.repoName}),row;try{row=await persistence.createRun({chain_name:CHAIN_NAME,args,current_stage_index:0,stages:initialStages,status:"running",ttl_seconds:input.ttl_seconds})}catch(err){return err instanceof ChainPersistenceError?failedEnvelope2(err.code,err.message):failedEnvelope2("TOOL_ERROR","An unexpected error occurred while creating the chain run.")}return continueChainExecution(deps,persistence,recipe,row,autoApprove)}catch(err){return console.error("[chain-orchestrator] unexpected error in runFullAutomation:",err),failedEnvelope2("TOOL_ERROR","An unexpected error occurred while executing the full-automation chain.")}}async function resumeFullAutomation(deps,input){try{let recipe=deps.chainRecipes[CHAIN_NAME];if(!recipe)return failedEnvelope2("VALIDATION",`Chain recipe "${CHAIN_NAME}" not found.`,{chain_run_id:input.chain_run_id});let persistence=createChainPersistenceClient({baseUrl:deps.baseUrl,apiKey:deps.apiKey,repoName:deps.repoName}),row;try{row=await persistence.getRun(input.chain_run_id)}catch(err){return err instanceof ChainPersistenceError?failedEnvelope2(err.code,err.message,{chain_run_id:input.chain_run_id}):failedEnvelope2("TOOL_ERROR","An unexpected error occurred while fetching the chain run.",{chain_run_id:input.chain_run_id})}if(row.status==="expired")return failedEnvelope2("EXPIRED","Chain run has expired.",{chain_run_id:row.chain_run_id,chain_total:recipe.stages.length});let autoApprove=normalizeAutoApprove2(row.args.auto_approve),idx=row.current_stage_index,stageRecipe=recipe.stages[idx],total=recipe.stages.length;if(!stageRecipe)return failedEnvelope2("VALIDATION",`Chain run has no active stage at index ${idx}.`,{chain_run_id:row.chain_run_id,chain_total:total});if(stageRecipe.pipeline_name===START_TICKETS_PIPELINE){if(typeof input.agent_result!="string"||input.agent_result.trim()==="")return failedEnvelope2("VALIDATION","agent_result must be a non-empty string to complete the start-tickets stage.",{chain_run_id:row.chain_run_id,chain_stage:START_TICKETS_PIPELINE,chain_step:idx+1,chain_total:total});let startResolution=resolveStartTicketKeys(row,idx,stageRecipe.fan_out_input??"reviewed_ticket_keys"),startedKeys=startResolution.ok?startResolution.keys:[],stages=cloneStages(row.stages);stages[idx].status="completed",stages[idx].pipeline_run_id=null,stages[idx].outputs={started_ticket_keys:startedKeys},stages[idx].summary=summarizeStageCompletion(START_TICKETS_PIPELINE,startedKeys);let updated;try{updated=await persistence.patchRun(row.chain_run_id,{stages,current_stage_index:idx+1,status:"completed",expected_status:"paused",expected_current_stage_index:idx})}catch(err){return persistenceFailEnvelope(err,row,recipe)}return buildCompletedEnvelope(recipe,updated)}let activePipelineRunId=row.stages[idx]?.pipeline_run_id;if(!activePipelineRunId)return failedEnvelope2("VALIDATION",`No active child pipeline to resume for stage ${idx+1}.`,{chain_run_id:row.chain_run_id,chain_stage:stageRecipe.pipeline_name,chain_step:idx+1,chain_total:total});let peek=await peekPipelineRun(deps,activePipelineRunId);if("error_code"in peek)return failedEnvelope2(peek.error_code,peek.error,{chain_run_id:row.chain_run_id,chain_stage:stageRecipe.pipeline_name,chain_step:idx+1,chain_total:total});if(peek.status!=="paused"&&peek.status!=="completed"&&peek.status!=="failed")return{status:"failed",error_code:"VALIDATION",error:`Inner pipeline run is in status "${peek.status}" and cannot be safely resumed or recovered. Inspect pipeline_run_id ${activePipelineRunId}.`,chain_run_id:row.chain_run_id,chain_stage:stageRecipe.pipeline_name,chain_step:idx+1,chain_total:total,pipeline_run_id:activePipelineRunId,resumable:!1};try{row=await persistence.patchRun(row.chain_run_id,{status:"running"})}catch(err){return persistenceFailEnvelope(err,row,recipe)}let childEnv;if(peek.status==="paused")childEnv=await resumePipeline(deps,{pipeline_run_id:activePipelineRunId,agent_result:input.agent_result});else if(peek.status==="completed")childEnv={status:"completed",pipeline_run_id:peek.pipeline_run_id,pipeline:peek.pipeline,total_steps:peek.total_steps,results:peek.results};else{let failedStepError=peek.results.find(r=>!r.ok&&typeof r.error=="string")?.error;childEnv={status:"failed",error_code:"TOOL_ERROR",error:failedStepError?`Inner pipeline run failed before the chain could advance: ${failedStepError}`:"Inner pipeline run failed before the chain could advance.",pipeline_run_id:peek.pipeline_run_id,pipeline:peek.pipeline,results:peek.results}}let fanOut=!!stageRecipe.fan_out_input,childIndex=row.stages[idx]?.current_child_index??0,ticketKey=fanOut?(resolveCrossStageList(row,idx,stageRecipe.fan_out_input)??[])[childIndex]:void 0,outcome2=await handleChildPipelineEnvelope(persistence,recipe,row,childEnv,{fanOut,ticketKey,childIndex});return outcome2.kind==="pause"||outcome2.kind==="fail"?outcome2.envelope:continueChainExecution(deps,persistence,recipe,outcome2.row,autoApprove)}catch(err){return console.error("[chain-orchestrator] unexpected error in resumeFullAutomation:",err),failedEnvelope2("TOOL_ERROR","An unexpected error occurred while resuming the full-automation chain.",{chain_run_id:input.chain_run_id})}}import path51 from"path";import{Worker}from"worker_threads";import{PNG}from"pngjs";import pixelmatch from"pixelmatch";import{isMainThread,parentPort,workerData}from"worker_threads";var PIXELMATCH_COLOR_THRESHOLD=.1,DEFAULT_PASS_MISMATCH_PCT=2,MAX_DIFF_REGIONS=10;function decodePng(buffer,label){try{let png=PNG.sync.read(Buffer.from(buffer));return!Number.isInteger(png.width)||!Number.isInteger(png.height)||png.width<=0||png.height<=0?{ok:!1,error:"IMAGE_DECODE_FAILED",status:422,message:`Decoded ${label} PNG has invalid dimensions.`}:{width:png.width,height:png.height,data:png.data}}catch{return{ok:!1,error:"IMAGE_DECODE_FAILED",status:422,message:`Failed to decode ${label} image as PNG.`}}}function extractOverlap(src,srcW,overlapW,overlapH){let out=new Uint8Array(overlapW*overlapH*4);for(let y=0;y<overlapH;y++){let srcRow=y*srcW*4,dstRow=y*overlapW*4;out.set(src.subarray(srcRow,srcRow+overlapW*4),dstRow)}return out}function buildMaskGrid(boxes,unionW,unionH){let grid=new Uint8Array(unionW*unionH);for(let box of boxes){let x0=Math.max(0,Math.floor(box.x)),y0=Math.max(0,Math.floor(box.y)),x1=Math.min(unionW,Math.floor(box.x+box.width)),y1=Math.min(unionH,Math.floor(box.y+box.height));for(let y=y0;y<y1;y++)for(let x=x0;x<x1;x++)grid[y*unionW+x]=1}return grid}function applyMaskToOverlap(buf,overlapW,overlapH,maskGrid,unionW){for(let y=0;y<overlapH;y++)for(let x=0;x<overlapW;x++)if(maskGrid[y*unionW+x]===1){let off=(y*overlapW+x)*4;buf[off]=0,buf[off+1]=0,buf[off+2]=0,buf[off+3]=255}}function extractDiffRegions(mask,width,height,maxRegions){let visited=new Uint8Array(width*height),regions=[],stack=[];for(let start=0;start<mask.length;start++){if(mask[start]===0||visited[start]===1)continue;let minX=width,minY=height,maxX=-1,maxY=-1,pixels=0;for(stack.length=0,stack.push(start),visited[start]=1;stack.length>0;){let idx=stack.pop(),x=idx%width,y=(idx-x)/width;if(pixels++,x<minX&&(minX=x),y<minY&&(minY=y),x>maxX&&(maxX=x),y>maxY&&(maxY=y),x>0){let n=idx-1;mask[n]===1&&visited[n]===0&&(visited[n]=1,stack.push(n))}if(x<width-1){let n=idx+1;mask[n]===1&&visited[n]===0&&(visited[n]=1,stack.push(n))}if(y>0){let n=idx-width;mask[n]===1&&visited[n]===0&&(visited[n]=1,stack.push(n))}if(y<height-1){let n=idx+width;mask[n]===1&&visited[n]===0&&(visited[n]=1,stack.push(n))}}regions.push({x:minX,y:minY,width:maxX-minX+1,height:maxY-minY+1,pixels})}return regions.sort((a,b)=>b.pixels!==a.pixels?b.pixels-a.pixels:a.y!==b.y?a.y-b.y:a.x-b.x),regions.slice(0,Math.max(0,maxRegions))}function computePngVisualDiff(input){let comp=decodePng(input.compPngBuffer,"comp");if("ok"in comp&&comp.ok===!1)return comp;let render=decodePng(input.renderPngBuffer,"render");if("ok"in render&&render.ok===!1)return render;let compImg=comp,renderImg=render,dimensionMatch=compImg.width===renderImg.width&&compImg.height===renderImg.height,unionW=Math.max(compImg.width,renderImg.width),unionH=Math.max(compImg.height,renderImg.height),overlapW=Math.min(compImg.width,renderImg.width),overlapH=Math.min(compImg.height,renderImg.height),maskGrid=buildMaskGrid(input.maskBoxes??[],unionW,unionH),output=new Uint8Array(unionW*unionH*4),diffMask=new Uint8Array(unionW*unionH),differingPixels=0;if(overlapW>0&&overlapH>0){let compOverlap=extractOverlap(compImg.data,compImg.width,overlapW,overlapH),renderOverlap=extractOverlap(renderImg.data,renderImg.width,overlapW,overlapH);applyMaskToOverlap(compOverlap,overlapW,overlapH,maskGrid,unionW),applyMaskToOverlap(renderOverlap,overlapW,overlapH,maskGrid,unionW);let overlapOut=new Uint8Array(overlapW*overlapH*4);try{differingPixels=pixelmatch(compOverlap,renderOverlap,overlapOut,overlapW,overlapH,{threshold:input.pixelmatchColorThreshold,includeAA:!1,diffColor:[255,0,0],diffColorAlt:[255,0,0],aaColor:[255,255,0]})}catch{return{ok:!1,error:"DIFF_FAILED",status:500,message:"Pixel comparison failed."}}for(let y=0;y<overlapH;y++)for(let x=0;x<overlapW;x++){let so=(y*overlapW+x)*4,uo=(y*unionW+x)*4;output[uo]=overlapOut[so],output[uo+1]=overlapOut[so+1],output[uo+2]=overlapOut[so+2],output[uo+3]=255,overlapOut[so]===255&&overlapOut[so+1]===0&&overlapOut[so+2]===0&&(diffMask[y*unionW+x]=1)}}for(let y=0;y<unionH;y++)for(let x=0;x<unionW;x++){let inComp=x<compImg.width&&y<compImg.height,inRender=x<renderImg.width&&y<renderImg.height;if(inComp===inRender||maskGrid[y*unionW+x]===1)continue;let off=(y*unionW+x)*4;output[off]=255,output[off+1]=0,output[off+2]=0,output[off+3]=255,diffMask[y*unionW+x]=1,differingPixels++}let diffRegions=extractDiffRegions(diffMask,unionW,unionH,input.maxRegions??MAX_DIFF_REGIONS),totalPixels=unionW*unionH,mismatchPct=totalPixels>0?differingPixels/totalPixels*100:0,passed=dimensionMatch&&mismatchPct<=input.passMismatchPct,heatmapBase64;try{let png=new PNG({width:unionW,height:unionH});png.data=Buffer.from(output),heatmapBase64=PNG.sync.write(png).toString("base64")}catch{return{ok:!1,error:"DIFF_FAILED",status:500,message:"Failed to encode diff heatmap."}}return{ok:!0,mismatch_pct:mismatchPct,dimension_match:dimensionMatch,passed,diff_regions:diffRegions,comp_dimensions:{width:compImg.width,height:compImg.height},render_dimensions:{width:renderImg.width,height:renderImg.height},differing_pixels:differingPixels,total_pixels:totalPixels,heatmap_base64:heatmapBase64}}if(!isMainThread&&parentPort)try{let result=computePngVisualDiff(workerData);parentPort.postMessage(result)}catch(err){parentPort.postMessage({ok:!1,error:"DIFF_FAILED",status:500,message:`Diff worker failed: ${err instanceof Error?err.message:"unknown error"}`})}var NAV_TIMEOUT_MS=3e4,NETWORK_IDLE_TIMEOUT_MS=15e3,FONTS_READY_TIMEOUT_MS=5e3,SCREENSHOT_TIMEOUT_MS=2e4,MAX_VIEWPORT_DIMENSION=16384,MAX_VIEWPORT_PIXELS=32e6,MAX_COMP_BYTES=25*1024*1024,DETERMINISTIC_CSS="* { animation: none !important; transition: none !important; caret-color: transparent !important; }";function textJson(value){return{type:"text",text:JSON.stringify(value,null,2)}}function errorContent(error,status,message,extra){return{content:[{type:"text",text:JSON.stringify({error,status,message,...extra??{}})}]}}var PNG_MAGIC=[137,80,78,71,13,10,26,10];function sniffImageFormat(bytes){return bytes.length>=8&&PNG_MAGIC.every((b,i)=>bytes[i]===b)?"png":bytes.length>=3&&bytes[0]===255&&bytes[1]===216&&bytes[2]===255?"jpeg":null}function readPngDimensions(bytes){if(bytes.length<24||sniffImageFormat(bytes)!=="png")return{ok:!1,message:"Not a valid PNG header."};if(bytes[12]!==73||bytes[13]!==72||bytes[14]!==68||bytes[15]!==82)return{ok:!1,message:"PNG IHDR chunk not found."};let width=readUInt32BE(bytes,16),height=readUInt32BE(bytes,20);return width<=0||height<=0?{ok:!1,message:"PNG reports non-positive dimensions."}:{ok:!0,width,height}}function readJpegDimensions(bytes){if(sniffImageFormat(bytes)!=="jpeg")return{ok:!1,message:"Not a valid JPEG header."};let offset=2,len=bytes.length;for(;offset+1<len;){if(bytes[offset]!==255){offset++;continue}let marker=bytes[offset+1];for(;marker===255&&offset+1<len;)offset++,marker=bytes[offset+1];if(offset+=2,marker>=208&&marker<=217||marker===1)continue;if(offset+1>=len)break;let segLen=readUInt16BE(bytes,offset);if(marker>=192&&marker<=207&&marker!==196&&marker!==200&&marker!==204){if(offset+5>=len)break;let height=readUInt16BE(bytes,offset+3),width=readUInt16BE(bytes,offset+5);return width<=0||height<=0?{ok:!1,message:"JPEG SOF reports non-positive dimensions."}:{ok:!0,width,height}}offset+=segLen}return{ok:!1,message:"No supported JPEG SOF marker found."}}function readUInt32BE(b,o){return b[o]*16777216+(b[o+1]<<16)+(b[o+2]<<8)+b[o+3]}function readUInt16BE(b,o){return(b[o]<<8)+b[o+1]}function isPositiveInt(n){return Number.isInteger(n)&&n>0}function resolveViewport(input,compDimensions){let vp=input.viewport??compDimensions;return!isPositiveInt(vp.width)||!isPositiveInt(vp.height)?{ok:!1,error:"INVALID_VIEWPORT",status:400,message:`Viewport must be positive integers, got ${vp.width}x${vp.height}.`}:vp.width>MAX_VIEWPORT_DIMENSION||vp.height>MAX_VIEWPORT_DIMENSION||vp.width*vp.height>MAX_VIEWPORT_PIXELS?{ok:!1,error:"IMAGE_TOO_LARGE",status:413,message:`Requested render area ${vp.width}x${vp.height} exceeds the local pixel guard.`}:{ok:!0,viewport:{width:vp.width,height:vp.height}}}function toUint8(bytes){return bytes instanceof Uint8Array?bytes:Buffer.from(bytes)}async function resolveCompRef(compRef,deps){let candidates=[];if(path51.isAbsolute(compRef))candidates.push(compRef);else{let root=await deps.getProjectRoot();candidates.push(path51.resolve(root,compRef));let cwdCandidate=path51.resolve(process.cwd(),compRef);candidates.includes(cwdCandidate)||candidates.push(cwdCandidate)}for(let candidate of candidates)try{let st=await deps.stat(candidate);if(st&&st.isFile())return{ok:!0,bytes:toUint8(await deps.readFile(candidate)),source:"local",sourcePath:candidate,warnings:[]}}catch{}let trimmed=compRef.trim(),lookup=/^\d+$/.test(trimmed)?{kind:"attachment_id",attachment_id:trimmed}:{kind:"filename",filename:compRef},fetched=await deps.fetchAttachmentBytes(lookup);if(!fetched.ok)return{ok:!1,error:fetched.error,status:fetched.status,message:fetched.message};let bytes=toUint8(fetched.bytes),warnings=[];try{let dir=await deps.getDocsPath("visual-diffs"),rawName=fetched.filename||(lookup.kind==="attachment_id"?`attachment-${lookup.attachment_id}`:lookup.filename),base=path51.basename(rawName),target=path51.resolve(dir,`comp-${deps.safeTimestampForFilename()}-${base}`);target.startsWith(path51.resolve(dir)+path51.sep)?(await deps.mkdir(dir,{recursive:!0}),await deps.writeFile(target,Buffer.from(bytes))):warnings.push("Skipped saving attachment comp copy: resolved path escaped the visual-diffs directory.")}catch(err){warnings.push(`Attachment comp copy could not be saved locally: ${err instanceof Error?err.message:"unknown error"}`)}return{ok:!0,bytes,source:"attachment",warnings}}async function loadPlaywright(){try{let mod=await import("playwright"),chromium=mod?.chromium??mod?.default?.chromium;return!chromium||typeof chromium.launch!="function"?{ok:!1}:{ok:!0,playwright:{chromium}}}catch{return{ok:!1}}}async function launchBrowser(playwright){try{return{ok:!0,browser:await playwright.chromium.launch({headless:!0})}}catch(err){return{ok:!1,error:"BROWSER_UNAVAILABLE",status:503,message:`Chromium could not be launched: ${err instanceof Error?err.message:"unknown error"}. Run "npx playwright install chromium".`}}}function normalizeMaskBoxes(raw,viewport){let boxes=[];for(let r of raw){let x0=Math.max(0,Math.floor(r.x)),y0=Math.max(0,Math.floor(r.y)),x1=Math.min(viewport.width,Math.ceil(r.x+r.width)),y1=Math.min(viewport.height,Math.ceil(r.y+r.height)),width=x1-x0,height=y1-y0;width>0&&height>0&&boxes.push({x:x0,y:y0,width,height})}return boxes.sort((a,b)=>a.y!==b.y?a.y-b.y:a.x!==b.x?a.x-b.x:a.width!==b.width?a.width-b.width:a.height-b.height),boxes}async function collectMaskBoxes(page,selectors,viewport){if(!selectors||selectors.length===0)return[];let raw=await page.evaluate(sels=>{let out=[];for(let sel of sels)document.querySelectorAll(sel).forEach(el=>{let rect=el.getBoundingClientRect();out.push({x:rect.x,y:rect.y,width:rect.width,height:rect.height})});return out},selectors);return normalizeMaskBoxes(Array.isArray(raw)?raw:[],viewport)}async function captureRenderPng(browser,targetUrl,viewport,maskSelectors){let context=await browser.newContext({viewport,deviceScaleFactor:1}),page;try{page=await context.newPage();try{await page.goto(targetUrl,{timeout:NAV_TIMEOUT_MS,waitUntil:"load"}),await page.waitForLoadState("networkidle",{timeout:NETWORK_IDLE_TIMEOUT_MS})}catch(err){return{ok:!1,error:isTimeoutError(err)?"PAGE_TIMEOUT":"PAGE_LOAD_FAILED",status:isTimeoutError(err)?504:502,message:`Failed to load ${targetUrl}: ${err instanceof Error?err.message:"unknown error"}`}}await page.addStyleTag({content:DETERMINISTIC_CSS}),await settleFonts(page);let maskBoxes=await collectMaskBoxes(page,maskSelectors,viewport),png;try{png=await page.screenshot({clip:{x:0,y:0,width:viewport.width,height:viewport.height},timeout:SCREENSHOT_TIMEOUT_MS,animations:"disabled"})}catch(err){return{ok:!1,error:isTimeoutError(err)?"PAGE_TIMEOUT":"PAGE_LOAD_FAILED",status:isTimeoutError(err)?504:502,message:`Screenshot capture failed: ${err instanceof Error?err.message:"unknown error"}`}}return{ok:!0,png:toUint8(png),maskBoxes,dimensions:viewport}}finally{try{page&&await page.close()}catch{}try{await context.close()}catch{}}}async function settleFonts(page){try{await Promise.race([page.evaluate(()=>{let d=document;return d.fonts&&d.fonts.ready?d.fonts.ready.then(()=>!0):!0}),new Promise(resolve2=>setTimeout(resolve2,FONTS_READY_TIMEOUT_MS))])}catch{}}function isTimeoutError(err){let msg=err instanceof Error?err.message:String(err??"");return/timeout|timed out|TimeoutError/i.test(msg)}async function normalizeCompToPng(browser,compBytes,format,dims){if(format==="png")return{ok:!0,png:Buffer.from(compBytes)};let context=await browser.newContext({viewport:dims,deviceScaleFactor:1}),page;try{page=await context.newPage(),page.setContent&&await page.setContent("<!doctype html><html><body></body></html>");let dataUrl=`data:image/jpeg;base64,${Buffer.from(compBytes).toString("base64")}`,base64=(await page.evaluate(async arg=>{let img=new Image;await new Promise((resolve2,reject)=>{img.onload=()=>resolve2(),img.onerror=()=>reject(new Error("image load failed")),img.src=arg.url});let canvas=document.createElement("canvas");canvas.width=arg.w,canvas.height=arg.h;let ctx=canvas.getContext("2d");if(!ctx)throw new Error("no 2d context");return ctx.drawImage(img,0,0,arg.w,arg.h),canvas.toDataURL("image/png")},{url:dataUrl,w:dims.width,h:dims.height})).split(",")[1]??"";return base64?{ok:!0,png:Buffer.from(base64,"base64")}:{ok:!1,error:"IMAGE_DECODE_FAILED",status:422,message:"Browser-side JPEG normalization produced no PNG data."}}catch(err){return{ok:!1,error:"UNSUPPORTED_COMP_IMAGE",status:400,message:`Failed to normalize JPEG comp to PNG: ${err instanceof Error?err.message:"unknown error"}`}}finally{try{page&&await page.close()}catch{}try{await context.close()}catch{}}}async function computeVisualDiffInWorker(args,deps){try{return await runInWorker(args)}catch(err){deps?.logger?.(`visual_diff: worker unavailable, computing inline (${err instanceof Error?err.message:"unknown error"}).`);try{return computePngVisualDiff(args)}catch(inlineErr){return{ok:!1,error:"DIFF_FAILED",status:500,message:`Diff computation failed: ${inlineErr instanceof Error?inlineErr.message:"unknown error"}`}}}}function runInWorker(args){return new Promise((resolve2,reject)=>{let workerRelative="./visual-diff-worker.js",workerUrl=new URL(workerRelative,import.meta.url),settled=!1,worker;try{worker=new Worker(workerUrl,{workerData:args})}catch(err){reject(err);return}worker.once("message",msg=>{settled=!0,resolve2(msg),worker.terminate()}),worker.once("error",err=>{settled||reject(err)}),worker.once("exit",code=>{!settled&&code!==0&&reject(new Error(`diff worker exited with code ${code}`))})})}async function saveHeatmap(heatmapBase64,deps){try{let dir=await deps.getDocsPath("visual-diffs"),target=path51.resolve(dir,`visual-diff-${deps.safeTimestampForFilename()}.png`);return target.startsWith(path51.resolve(dir)+path51.sep)?(await deps.mkdir(dir,{recursive:!0}),await deps.writeFile(target,Buffer.from(heatmapBase64,"base64")),{ok:!0,path:target}):{ok:!1,warning:"Heatmap not saved: resolved path escaped the visual-diffs directory."}}catch(err){return{ok:!1,warning:`Heatmap could not be saved locally: ${err instanceof Error?err.message:"unknown error"}`}}}async function runVisualDiff(input,deps){try{let warnings=[],resolved=await resolveCompRef(input.comp_ref,deps);if(!resolved.ok)return errorContent(resolved.error,resolved.status,resolved.message);warnings.push(...resolved.warnings);let compBytes=resolved.bytes;if(compBytes.length>MAX_COMP_BYTES)return errorContent("IMAGE_TOO_LARGE",413,`Comp image is ${compBytes.length} bytes, exceeding the ${MAX_COMP_BYTES}-byte guard.`);let format=sniffImageFormat(compBytes);if(!format)return errorContent("UNSUPPORTED_COMP_IMAGE",400,"comp_ref resolved but is not a decodable PNG or JPEG image.");let dims=format==="png"?readPngDimensions(compBytes):readJpegDimensions(compBytes);if(!dims.ok)return errorContent("UNSUPPORTED_COMP_IMAGE",400,dims.message);let compDimensions={width:dims.width,height:dims.height},vp=resolveViewport(input,compDimensions);if(!vp.ok)return errorContent(vp.error,vp.status,vp.message);let loaded=await(deps.loadPlaywright??loadPlaywright)();if(!loaded.ok)return errorContent("BROWSER_UNAVAILABLE",503,'The optional Playwright browser runtime is not available. Install it with "npm i playwright && npx playwright install chromium".');let launch=await launchBrowser(loaded.playwright);if(!launch.ok)return errorContent(launch.error,launch.status,launch.message);let browser=launch.browser,capture,normalized;try{if(capture=await captureRenderPng(browser,input.target_url,vp.viewport,input.mask_selectors),!capture.ok)return errorContent(capture.error,capture.status,capture.message);if(normalized=await normalizeCompToPng(browser,compBytes,format,compDimensions),!normalized.ok)return errorContent(normalized.error,normalized.status,normalized.message)}finally{try{await browser.close()}catch{}}let passMismatchPct=typeof input.threshold=="number"?input.threshold:DEFAULT_PASS_MISMATCH_PCT,diff=await computeVisualDiffInWorker({compPngBuffer:normalized.png,renderPngBuffer:capture.png,maskBoxes:capture.maskBoxes,passMismatchPct,pixelmatchColorThreshold:PIXELMATCH_COLOR_THRESHOLD},deps);if(!diff.ok)return errorContent(diff.error,diff.status,diff.message);let saved=await saveHeatmap(diff.heatmap_base64,deps),heatmapPath=saved.ok?saved.path:null;saved.ok||warnings.push(saved.warning);let result={mismatch_pct:diff.mismatch_pct,dimension_match:diff.dimension_match,diff_regions:diff.diff_regions,heatmap_path:heatmapPath,comp_dimensions:diff.comp_dimensions,render_dimensions:diff.render_dimensions,threshold_used:passMismatchPct,passed:diff.passed};return diff.dimension_match||(result.message=`Render dimensions ${diff.render_dimensions.width}x${diff.render_dimensions.height} differ from comp dimensions ${diff.comp_dimensions.width}x${diff.comp_dimensions.height}. Images were NOT rescaled, so this result is automatically not passed; the diff covers the union region.`),warnings.length>0&&(result.warnings=warnings),{content:[textJson(result),{type:"image",data:diff.heatmap_base64,mimeType:"image/png"}]}}catch(err){return errorContent("VISUAL_DIFF_FAILED",500,`visual_diff failed: ${err instanceof Error?err.message:"unknown error"}`)}}function validateEstimateEpicInput(input){let hasEpic=typeof input.epic_key=="string"&&input.epic_key.trim().length>0,hasKeys=Array.isArray(input.ticket_keys);return hasEpic&&hasKeys?"epic_key and ticket_keys are mutually exclusive; supply exactly one, never both.":!hasEpic&&!hasKeys?"Exactly one of epic_key or ticket_keys is required.":hasKeys&&input.ticket_keys.length===0?"ticket_keys was supplied but contained no keys; provide at least one, non-empty ticket_keys.":null}function buildEstimateEpicErrorEnvelope(code,message,extras){return JSON.stringify({error:code,message,...extras??{}},null,2)}async function runEstimateEpic(input,deps){let validationError2=validateEstimateEpicInput(input);if(validationError2)return{content:[{type:"text",text:buildEstimateEpicErrorEnvelope("VALIDATION_ERROR",validationError2)}]};let payload={repo_name:deps.repoName};typeof input.epic_key=="string"&&(payload.epic_key=input.epic_key),Array.isArray(input.ticket_keys)&&(payload.ticket_keys=input.ticket_keys),typeof input.allow_partial=="boolean"&&(payload.allow_partial=input.allow_partial);let fetchImpl=deps.fetchImpl??fetch,resp;try{resp=await fetchImpl(deps.buildUrl("/estimate-epic"),{method:"POST",headers:await deps.getPostHeaders(),body:JSON.stringify(payload)})}catch{return{content:[{type:"text",text:buildEstimateEpicErrorEnvelope("NETWORK_ERROR","Failed to reach the Bridge API estimate-epic endpoint.")}]}}return{content:[{type:"text",text:await deps.handleResponse(resp)}]}}function text2(value){return{content:[{type:"text",text:value}]}}async function postDirectInvocation(deps,path53,body,ticketNumber){let resp;try{resp=await(deps.fetchImpl??fetch)(deps.buildUrl(path53),{method:"POST",headers:await deps.getPostHeaders(),body:JSON.stringify({...body,repo_name:deps.repoName})})}catch{return text2(`The request could not be delivered, so it is unknown whether it was accepted. Call get_ticket_state for ${ticketNumber} to check before retrying.`)}return resp.ok?text2(await resp.text()):text2(await deps.handleResponse(resp))}async function runRequestTicketUpdate(args,deps){return postDirectInvocation(deps,`/ticket/${encodeURIComponent(args.ticket_number)}/generate-ticket-update`,{},args.ticket_number)}async function runRequestEstimate(args,deps){return postDirectInvocation(deps,`/ticket/${encodeURIComponent(args.ticket_number)}/generate-estimate`,{recreate:args.recreate===!0},args.ticket_number)}async function runGetTicketUpdateReview(args,deps){let url=deps.buildGetUrl(`/ticket/${encodeURIComponent(args.ticket_number)}/ticket-update-review`,{repo_name:deps.repoName}),resp;try{resp=await(deps.fetchImpl??fetch)(url,{headers:await deps.getHeaders()})}catch{return text2(`The held-for-review proposal for ${args.ticket_number} could not be fetched. Retry shortly.`)}return resp.ok?text2(await resp.text()):text2(await deps.handleResponse(resp))}init_git_ci_types();init_git_ci_types();init_done_gate();init_merge_identity();init_local_merge();var DRY_RUN_HINT="set auto_merge_enabled=true on the project default via PUT /jira/epic-runs/supervisor-config/defaults/",UNKNOWN_HINT="the request was sent but its outcome was not observed; repeat this call with identical arguments \u2014 the server's action key makes it idempotent",REQUIRED_CHECKS_EMPTY="required_checks_empty",CI_NOT_GREEN_REASON="ci_not_green",REVIEW_NOT_APPROVED_REASON="review_not_approved";function text3(envelope2){return{content:[{type:"text",text:JSON.stringify(envelope2)}]}}function envelope(merged,outcome2,reason,retryHint,evaluatedHeadSha,prNumber,diagnostics={}){let result={merged,outcome:outcome2,reason,retry_hint:retryHint,evaluated_head_sha:evaluatedHeadSha,pr_number:prNumber};return diagnostics.actual_head_sha!==void 0&&(result.actual_head_sha=diagnostics.actual_head_sha),diagnostics.ci_summary!==void 0&&(result.ci_summary=diagnostics.ci_summary),diagnostics.paths!==void 0&&(result.paths=diagnostics.paths),diagnostics.hint!==void 0&&(result.hint=diagnostics.hint),diagnostics.http_status!==void 0&&(result.http_status=diagnostics.http_status),diagnostics.completion!==void 0&&(result.completion=diagnostics.completion),diagnostics.review_waiver!==void 0&&(result.review_waiver=diagnostics.review_waiver),result}var SHA_RE2=/^[0-9a-fA-F]{40}$/;function isPlainObject12(value){return typeof value=="object"&&value!==null&&!Array.isArray(value)}function validateInputs(prNumber,expectedHeadSha){return typeof prNumber!="number"||!Number.isSafeInteger(prNumber)||prNumber<=0?"invalid_pr_number":typeof expectedHeadSha!="string"||!SHA_RE2.test(expectedHeadSha)?"invalid_expected_head_sha":null}async function readJson2(resp){try{let body=await resp.text();return body.trim().length===0?void 0:JSON.parse(body)}catch{return}}function normalizeCheckNames(raw){let out=[],seen=new Set;for(let candidate of raw){let name=normalizeCheckName(candidate);name===null||seen.has(name)||(seen.add(name),out.push(name))}return out}async function resolveRequiredChecks(deps,expectedHeadSha){let fetchImpl=deps.fetchImpl??fetch,defaultsUrl=deps.buildGetUrl("/epic-runs/supervisor-setup/defaults/",{repo_name:deps.repoName}),defaultsHeaders=await deps.getHeaders(),defaultsResp;try{defaultsResp=await fetchImpl(defaultsUrl,{headers:defaultsHeaders})}catch{return null}if(!defaultsResp.ok)return await deps.handleResponse(defaultsResp).catch(()=>""),null;let defaults=await readJson2(defaultsResp);if(!isPlainObject12(defaults))return null;let rawGateConfig=defaults.done_gate_config;if(rawGateConfig!=null){let parsed=parseDoneGateConfig(rawGateConfig);if(!parsed.enabled||!parsed.valid)return{checks:[],configHash:null,reviewCondition:null};let condition=parsed.conditions.find(c=>c.type===REQUIRED_CI_CHECKS_GREEN);if(condition===void 0||condition.type!==REQUIRED_CI_CHECKS_GREEN)return{checks:[],configHash:null,reviewCondition:null};let review=parsed.conditions.find(c=>c.type===REVIEW_STATE),reviewCondition=review!==void 0&&review.type===REVIEW_STATE?review:null;return{checks:[...condition.required_checks],configHash:parsed.config_hash,reviewCondition}}let resolverUrl=deps.buildUrl("/resolve-ci-checks"),resolverHeaders=await deps.getPostHeaders(),resolverResp;try{resolverResp=await fetchImpl(resolverUrl,{method:"POST",headers:resolverHeaders,body:JSON.stringify({repo_name:deps.repoName,commit_ref:expectedHeadSha})})}catch{return null}if(!resolverResp.ok)return await deps.handleResponse(resolverResp).catch(()=>""),null;let resolved=await readJson2(resolverResp);if(!isPlainObject12(resolved))return null;let detail=resolved.detail;if(!isPlainObject12(detail))return{checks:[],configHash:null,reviewCondition:null};let rawChecks=detail.checks;if(!Array.isArray(rawChecks))return{checks:[],configHash:null,reviewCondition:null};let requiredNames=rawChecks.filter(entry=>isPlainObject12(entry)&&entry.required===!0).map(entry=>entry.name);return{checks:normalizeCheckNames(requiredNames),configHash:null,reviewCondition:null}}var SUPPORTED_REVIEW_SOURCES=new Set(["verdict_protocol","native_review_decision"]),REVIEW_UNAVAILABLE_REASON="review_unavailable",REVIEW_SOURCE_UNSUPPORTED_REASON="review_source_unsupported",HEAD_SHA_DRIFT_REASON="head_sha_drift";function refuse(env){return{kind:"refused",envelope:env}}function withReviewWaiver(env,waiver){return waiver===void 0?env:{...env,review_waiver:waiver}}function verdictIsGenuinelyAbsent(condition,snapshot,rawBody){if(condition.source!=="verdict_protocol")return!0;if(snapshot.sticky_verdict!==null)return!1;let detail=isPlainObject12(rawBody)&&isPlainObject12(rawBody.detail)?rawBody.detail:null;if(detail===null)return!1;let raw=detail.sticky_verdict;return raw==null}function effectiveDisposition(condition){return condition.verdictless_disposition??VERDICTLESS_DISPOSITION_PARK}async function precheckReviewCondition(deps,condition,prNumber,expectedHeadSha){if(!SUPPORTED_REVIEW_SOURCES.has(condition.source))return refuse(envelope(!1,"review_source_unsupported",REVIEW_SOURCE_UNSUPPORTED_REASON,"needs_human",expectedHeadSha,prNumber));let unavailable=()=>refuse(envelope(!1,"review_unavailable",REVIEW_UNAVAILABLE_REASON,"needs_human",expectedHeadSha,prNumber)),reviewUrl=deps.buildApiUrl(`/vcs/pull-requests/${prNumber}/reviews/status`)+`?repo_name=${encodeURIComponent(deps.repoName)}`,reviewHeaders=await deps.getHeaders(),reviewResp;try{reviewResp=await(deps.fetchImpl??fetch)(reviewUrl,{headers:reviewHeaders})}catch{return unavailable()}if(!reviewResp.ok)return await deps.handleResponse(reviewResp).catch(()=>""),unavailable();let reviewBody=await readJson2(reviewResp),snapshot=normalizeReviewSnapshot(reviewBody);if(snapshot===null)return unavailable();if(snapshot.head_sha!==expectedHeadSha){let diagnostics={};return typeof snapshot.head_sha=="string"&&snapshot.head_sha.length>0&&(diagnostics.actual_head_sha=snapshot.head_sha),refuse(envelope(!1,"refused",HEAD_SHA_DRIFT_REASON,"needs_human",expectedHeadSha,prNumber,diagnostics))}let evaluation=evaluateReviewCondition(condition,snapshot);return evaluation.passed?{kind:"proceed"}:effectiveDisposition(condition)===VERDICTLESS_DISPOSITION_FAIL_OPEN&&evaluation.changesRequested===!1&&verdictIsGenuinelyAbsent(condition,snapshot,reviewBody)?{kind:"waived",waiver:MERGE_REVIEW_WAIVED_BY_DEGRADATION_REASON}:refuse(envelope(!1,"review_not_approved",evaluation.reason,"retry_later",expectedHeadSha,prNumber))}function extractDiagnostics(body){let diagnostics={},events=body.ledger_events;if(!Array.isArray(events))return diagnostics;for(let event of events){if(!isPlainObject12(event)||event.status!=="failed")continue;let details=event.details;if(!isPlainObject12(details))continue;let guard=isPlainObject12(details.guard_outcomes)?details.guard_outcomes:{};if(diagnostics.actual_head_sha===void 0&&typeof guard.actual_head_sha=="string"&&(diagnostics.actual_head_sha=guard.actual_head_sha),diagnostics.ci_summary===void 0){let summary=isPlainObject12(guard.ci_summary)?guard.ci_summary:isPlainObject12(details.ci_summary)?details.ci_summary:void 0;summary!==void 0&&(diagnostics.ci_summary=summary)}diagnostics.paths===void 0&&Array.isArray(guard.paths)&&(diagnostics.paths=guard.paths.filter(p=>typeof p=="string"))}return diagnostics}function hasIncompleteRequiredCheck(ciSummary){if(!isPlainObject12(ciSummary))return!1;let checks=ciSummary.checks;return Array.isArray(checks)?checks.some(check=>isPlainObject12(check)&&check.complete!==!0&&check.present!==!1):!1}function retryHintForFailure(reason,ciSummary){return reason===CI_NOT_GREEN_REASON?hasIncompleteRequiredCheck(ciSummary)?"retry_later":"needs_human":reason===REVIEW_NOT_APPROVED_REASON?"retry_later":"needs_human"}function interpretMergeResponse(body,expectedHeadSha,prNumber){let malformed=()=>envelope(!1,"error","malformed_merge_response","needs_human",expectedHeadSha,prNumber);if(!isPlainObject12(body))return malformed();let status=body.status,reason=typeof body.reason=="string"?body.reason:null;if(typeof status!="string")return malformed();if(status==="succeeded")return body.terminal!==!0?malformed():reason==="already_merged"?envelope(!0,"already_merged",reason,null,expectedHeadSha,prNumber):reason==="merged"?envelope(!0,"merged",reason,null,expectedHeadSha,prNumber):malformed();if(status==="dry_run")return envelope(!1,"dry_run",reason,"needs_human",expectedHeadSha,prNumber,{hint:DRY_RUN_HINT});if(status==="pending_approval")return envelope(!1,"pending_approval",reason,"needs_human",expectedHeadSha,prNumber);if(status==="lease_held")return envelope(!1,"lease_held",reason,"retry_later",expectedHeadSha,prNumber);if(status==="failed"){if(reason===null)return malformed();let diagnostics=extractDiagnostics(body);return envelope(!1,"refused",reason,retryHintForFailure(reason,diagnostics.ci_summary),expectedHeadSha,prNumber,diagnostics)}return malformed()}var LOCAL_APPROVAL_STATUS="approved_for_local_execution",DEFAULT_MERGE_EXECUTION="local",LOCAL_GH_HINTS={local_gh_unavailable:"Install the GitHub CLI (`gh`) on the machine running this MCP server \u2014 local merges execute there.",local_gh_unauthenticated:"Run `gh auth login` in the shell that hosts this MCP server \u2014 local merges use its GitHub session."};async function resolveMergeExecutionMode(deps){try{let resp=await(deps.fetchImpl??fetch)(deps.buildGetUrl("/epic-runs/supervisor-config/defaults/",{repo_name:deps.repoName}),{headers:await deps.getHeaders()});if(!resp.ok)return DEFAULT_MERGE_EXECUTION;let body=await readJson2(resp);return isPlainObject12(body)&&body.merge_execution==="server"?"server":DEFAULT_MERGE_EXECUTION}catch{return DEFAULT_MERGE_EXECUTION}}function localApprovalMismatch(body,prNumber,expectedHeadSha,actionKey){return body.pr_number!==prNumber||typeof body.expected_head_sha!="string"||body.expected_head_sha.toLowerCase()!==expectedHeadSha.toLowerCase()||body.action_key!==actionKey}function mergeShaFromLedger(response){let events=Array.isArray(response.ledger_events)?response.ledger_events:[];for(let event of events){if(!isPlainObject12(event)||event.type!=="merge.succeeded")continue;let sha=(isPlainObject12(event.details)?event.details:{}).merge_commit_sha;if(typeof sha=="string"&&SHA_RE2.test(sha))return sha}}async function reportLocalCompletion(deps,prNumber,body){try{let resp=await(deps.fetchImpl??fetch)(deps.buildApiUrl(`/vcs/pull-requests/${prNumber}/merge/complete`),{method:"POST",headers:await deps.getPostHeaders(),body:JSON.stringify(body)});return resp.ok?await readJson2(resp):(await deps.handleResponse(resp).catch(()=>""),null)}catch{return null}}async function executeApprovedLocalMerge(deps,approval,prNumber,expectedHeadSha,actionKey){if(localApprovalMismatch(approval,prNumber,expectedHeadSha,actionKey))return envelope(!1,"refused","local_approval_mismatch","needs_human",expectedHeadSha,prNumber);let method=resolveLocalMergeMethod(approval.merge_method),request={repo_name:deps.repoName,pr_number:prNumber,expected_head_sha:expectedHeadSha,gate:{name:DEFAULT_GATE_NAME},action_key:actionKey},local;try{local=await(deps.runLocalMerge??runApprovedLocalMerge)(request,{method},{env:process.env})}catch{return await reportLocalCompletion(deps,prNumber,{repo_name:deps.repoName,action_key:actionKey,expected_head_sha:expectedHeadSha,result:"failed",reason:"gh_merge_failed"}),envelope(!1,"refused","gh_merge_failed","needs_human",expectedHeadSha,prNumber)}let localReason=typeof local.reason=="string"?local.reason:null;if(local.status==="succeeded"){let result=localReason==="already_merged"?"already_merged":"merged",mergeSha=mergeShaFromLedger(local);return await reportLocalCompletion(deps,prNumber,{repo_name:deps.repoName,action_key:actionKey,expected_head_sha:expectedHeadSha,result,...mergeSha?{merge_sha:mergeSha}:{}})===null?envelope(!0,result==="already_merged"?"already_merged":"merged",result,null,expectedHeadSha,prNumber,{completion:"unreported"}):envelope(!0,result==="already_merged"?"already_merged":"merged",result,null,expectedHeadSha,prNumber)}let reason=localReason??"gh_merge_failed";await reportLocalCompletion(deps,prNumber,{repo_name:deps.repoName,action_key:actionKey,expected_head_sha:expectedHeadSha,result:"failed",reason});let hint=LOCAL_GH_HINTS[reason];return envelope(!1,"refused",reason,"needs_human",expectedHeadSha,prNumber,{...hint?{hint}:{}})}async function mergePullRequestHandler(deps,args){let rawPr=args?.pr_number,rawSha=args?.expected_head_sha,echoedSha=typeof rawSha=="string"?rawSha:null,echoedPr=typeof rawPr=="number"?rawPr:null;try{let invalid=validateInputs(rawPr,rawSha);if(invalid!==null)return text3(envelope(!1,"error",invalid,"needs_human",echoedSha,echoedPr));let prNumber=rawPr,expectedHeadSha=rawSha,resolution=await resolveRequiredChecks(deps,expectedHeadSha);if(resolution===null||resolution.checks.length===0)return text3(envelope(!1,"gate_unresolved",REQUIRED_CHECKS_EMPTY,"needs_human",expectedHeadSha,prNumber));let reviewWaiver;if(resolution.reviewCondition!==null){let decision=await precheckReviewCondition(deps,resolution.reviewCondition,prNumber,expectedHeadSha);if(decision.kind==="refused")return text3(decision.envelope);decision.kind==="waived"&&(reviewWaiver=decision.waiver)}let gateIdentity=buildGateIdentity(DEFAULT_GATE_NAME,resolution.configHash),actionKey=makeMergeActionKey(deps.repoName,prNumber,expectedHeadSha,gateIdentity),executionMode=await resolveMergeExecutionMode(deps),mergeBody={repo_name:deps.repoName,expected_head_sha:expectedHeadSha,gate:{name:DEFAULT_GATE_NAME,config_hash:resolution.configHash,required_checks:resolution.checks},action_key:actionKey,execution:executionMode},mergeResp;try{mergeResp=await(deps.fetchImpl??fetch)(deps.buildApiUrl(`/vcs/pull-requests/${prNumber}/merge`),{method:"POST",headers:await deps.getPostHeaders(),body:JSON.stringify(mergeBody)})}catch{return text3(envelope(!1,"unknown","merge_request_not_observed","retry_later",expectedHeadSha,prNumber,{hint:UNKNOWN_HINT,review_waiver:reviewWaiver}))}if(!mergeResp.ok)return await deps.handleResponse(mergeResp).catch(()=>""),mergeResp.status===409?text3(envelope(!1,"action_key_mismatch","action_key_mismatch","needs_human",expectedHeadSha,prNumber,{review_waiver:reviewWaiver})):text3(envelope(!1,"error","merge_request_failed","needs_human",expectedHeadSha,prNumber,{http_status:mergeResp.status,review_waiver:reviewWaiver}));let mergeJson=await readJson2(mergeResp);return executionMode==="local"&&isPlainObject12(mergeJson)&&mergeJson.status===LOCAL_APPROVAL_STATUS?text3(withReviewWaiver(await executeApprovedLocalMerge(deps,mergeJson,prNumber,expectedHeadSha,actionKey),reviewWaiver)):text3(withReviewWaiver(interpretMergeResponse(mergeJson,expectedHeadSha,prNumber),reviewWaiver))}catch{return text3(envelope(!1,"error","handler_error","needs_human",echoedSha,echoedPr))}}import{ListToolsRequestSchema}from"@modelcontextprotocol/sdk/types.js";init_index_scope_contract();var NOT_STALE={stale:!1};function createUpdateStatusManager(options={}){let check=options.check??checkForUpdate,warn=options.warn??(message=>console.error(message)),enabled=options.enabled!==!1,started=!1,settled=null,settledPromise=null,warned=!1,listServed=!1,lateNotified=!1;function conclude(result){if(!result||typeof result!="object"||result.updateAvailable!==!0)return NOT_STALE;let{currentVersion,latestVersion}=result;return typeof currentVersion!="string"||currentVersion.length===0||typeof latestVersion!="string"||latestVersion.length===0?NOT_STALE:{stale:!0,currentVersion,latestVersion}}function start(){if(!started){if(started=!0,!enabled){settled=NOT_STALE,settledPromise=Promise.resolve(NOT_STALE);return}settledPromise=(async()=>{let status;try{status=conclude(await check())}catch{status=NOT_STALE}if(settled=status,status.stale&&!warned&&(warned=!0,warn(formatUpdateAdvice(status.currentVersion,status.latestVersion)),listServed&&!lateNotified)){lateNotified=!0;try{options.onLateStale?.()}catch{}}return status})()}}return{start,getStatus:()=>settled??NOT_STALE,whenSettled:async()=>(started||start(),await settledPromise??NOT_STALE),markListServed:()=>{listServed=!0}}}function updateAdvisoryFor(status){return!status.stale||!status.currentVersion||!status.latestVersion?null:formatToolSurfaceUpdateAdvisory(status.currentVersion,status.latestVersion)}var PIPELINES2={...PIPELINES},INSTRUCTIONS2={...INSTRUCTIONS},userPipelineKeys=new Set,BASE_URL=process.env.BAPI_BASE_URL??"https://bridgegpt-api.com",REPO_NAME=process.env.BAPI_REPO_NAME??"",INDEX_SCOPE,UPGRADE_ADVICE_SURFACING_ENABLED=parseDefaultOnEnvFlag(process.env.BAPI_MCP_UPGRADE_ADVICE_ENABLED),TOOL_SURFACE_GATING_ENABLED=parseDefaultOnEnvFlag(process.env.BAPI_MCP_TOOL_SURFACE_GATING_ENABLED),TOOL_SURFACE_POLL_ENABLED=parseDefaultOffEnvFlag(process.env.BAPI_MCP_TOOL_SURFACE_POLL_ENABLED),UPDATE_CHECK_ENABLED=parseDefaultOnEnvFlag(process.env.BAPI_MCP_UPDATE_CHECK_ENABLED),ACTIVE_GROUPS=resolveProfiles(process.env.BRIDGE_MCP_PROFILE),resolvedApiKeyPromise;async function getResolvedApiKey(){return resolvedApiKeyPromise||(resolvedApiKeyPromise=(async()=>{try{let result=await resolveBapiCredentials(REPO_NAME,{env:process.env,homedir:os23.homedir,platform:process.platform,readFile:p=>readFile18(p,"utf-8"),stat:p=>stat11(p)});return result.ok?result.credentials.apiKey:""}catch{return""}})()),resolvedApiKeyPromise}async function getResolvedApiKeyForRepo(repoName){try{let result=await resolveBapiCredentials(repoName,{env:process.env,homedir:os23.homedir,platform:process.platform,readFile:p=>readFile18(p,"utf-8"),stat:p=>stat11(p)});return result.ok?result.credentials.apiKey:""}catch{return""}}function buildCredentialStoreWriteDeps(){return{env:process.env,homedir:os23.homedir,platform:process.platform,readFile:p=>readFile18(p,"utf-8"),mkdir:(p,options)=>mkdir15(p,options),writeFile:(p,data,options)=>writeFile14(p,data,options),rename:(oldPath,newPath)=>rename5(oldPath,newPath),chmod:(p,mode)=>chmod4(p,mode),unlink:p=>unlink4(p),open:async(p,flags,mode)=>{let handle=await open6(p,flags,mode);return{writeFile:data=>handle.writeFile(data,{encoding:"utf-8"}),sync:()=>handle.sync(),close:()=>handle.close()}}}}function withIndexScopeHeader(headers,options){return INDEX_SCOPE&&!options.scopeAddressed&&(headers[INDEX_SCOPE_HEADER]=INDEX_SCOPE),headers}async function getGetHeaders(options={}){return withIndexScopeHeader({"X-API-Key":await getResolvedApiKey(),"X-Bridge-MCP-Version":VERSION,"X-Bridge-MCP-Commit":BUILD_COMMIT},options)}async function getPostHeaders(options={}){return withIndexScopeHeader({"X-API-Key":await getResolvedApiKey(),"Content-Type":"application/json","X-Bridge-MCP-Version":VERSION,"X-Bridge-MCP-Commit":BUILD_COMMIT},options)}var serverConnected=!1;async function resolveProjectRootFromRootsList(){if(!serverConnected)return null;try{let result=await server.server.listRoots(),roots=Array.isArray(result?.roots)?result.roots:[];for(let root of roots){let uri=root?.uri;if(typeof uri=="string"&&uri.startsWith("file://"))try{return fileURLToPath4(uri)}catch{}}return null}catch{return null}}var projectRootPromise;async function getProjectRoot(){return projectRootPromise||(projectRootPromise=(async()=>{let explicit=(process.env.BAPI_PROJECT_ROOT??"").trim();if(explicit.length>0)return explicit;let fromRoots=await resolveProjectRootFromRootsList();if(fromRoots&&fromRoots.length>0)return fromRoots;let claudeDir=(process.env.CLAUDE_PROJECT_DIR??"").trim();return claudeDir.length>0?claudeDir:process.cwd()})()),projectRootPromise}var docsDirPromise;async function getDocsDir(){return docsDirPromise||(docsDirPromise=(async()=>path52.resolve(await getProjectRoot(),process.env.BAPI_DOCS_DIR??"docs/tmp"))()),docsDirPromise}var pipelinesDirPromise;async function getPipelinesDir(){return pipelinesDirPromise||(pipelinesDirPromise=(async()=>path52.resolve(await getProjectRoot(),process.env.BAPI_PIPELINES_DIR??".bridge/pipelines"))()),pipelinesDirPromise}var{buildUrl,buildApiUrl,buildGetUrl}=createBridgeApiUrls(BASE_URL);async function getDocsPath(subdir){return path52.join(await getDocsDir(),subdir)}var customPipelinesPromise;async function ensureCustomPipelinesLoaded(){return customPipelinesPromise||(customPipelinesPromise=(async()=>{let pipelinesDir=await getPipelinesDir(),instructionsDir=path52.join(path52.dirname(pipelinesDir),"instructions"),customResult=await loadCustomPipelines(pipelinesDir,instructionsDir,INSTRUCTIONS);for(let[key,pipeline]of Object.entries(customResult.pipelines))key in PIPELINES&&console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`),PIPELINES2[key]=pipeline;Object.assign(INSTRUCTIONS2,customResult.instructions),userPipelineKeys=customResult.userPipelineKeys})()),customPipelinesPromise}var ERROR_CODES={400:"BAD_REQUEST",401:"UNAUTHORIZED",403:"FORBIDDEN",404:"NOT_FOUND",409:"CONFLICT",422:"VALIDATION_ERROR",429:"RATE_LIMITED",500:"INTERNAL_ERROR",502:"BAD_GATEWAY",503:"SERVICE_UNAVAILABLE",504:"GATEWAY_TIMEOUT"};async function handleResponse(resp){if(resp.ok){if((resp.headers.get("content-type")??"").includes("application/json")){let body=await resp.json();return formatSuccessWithTicketBackend(body,resp.headers.get(TICKET_BACKEND_HEADER))}return await resp.text()}let rawText=await resp.text(),errorCode4=ERROR_CODES[resp.status]??"UNKNOWN_ERROR",message=rawText;try{let parsed=JSON.parse(rawText);if(parsed.detail!==null&&typeof parsed.detail=="object"&&!Array.isArray(parsed.detail)){let detail=parsed.detail;return typeof detail.message=="string"?message=detail.message:message=JSON.stringify(detail),detail.error===UNSUPPORTED_IN_LOCAL_MODE_ERROR&&resp.status===409?JSON.stringify({...detail,error:UNSUPPORTED_IN_LOCAL_MODE_ERROR,status:resp.status,message}):JSON.stringify({...detail,error:errorCode4,status:resp.status,message})}parsed.detail&&(message=typeof parsed.detail=="string"?parsed.detail:JSON.stringify(parsed.detail))}catch{}return JSON.stringify({error:errorCode4,status:resp.status,message})}async function createTicketRequest(params){let payload={repo_name:REPO_NAME,summary:params.summary,description:params.description,issue_type:params.issue_type};params.priority&&(payload.priority=params.priority),params.labels&&(payload.labels=params.labels),params.assignee&&(payload.assignee=params.assignee),params.parent_key&&(payload.parent_key=params.parent_key);let resp=await fetch(buildUrl("/create-ticket"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(payload)});return handleResponse(resp)}async function saveLocally(dir,filename,content){let filePath=path52.join(dir,filename);try{return await mkdir15(dir,{recursive:!0}),await writeFile14(filePath,content,"utf-8"),`
7872
7907
 
7873
7908
  ---
7874
7909
  Saved to ${filePath}`}catch(writeErr){return`
@@ -7910,4 +7945,4 @@ ${content}`),{content:[{type:"text",text:resultText}]}}case"list":{let{ticket_nu
7910
7945
  \u26A0\uFE0F SFCC profile updated: BRIDGE_MCP_PROFILE is now set to \`${newProfile}\` in your local MCP config file(s). This activates on the next MCP server launch \u2014 restart your MCP client to gain access to the SFCC read tools.`)}catch(profileError){profileError instanceof DuplicateRegistrationError&&(text4+=`
7911
7946
 
7912
7947
  \u26A0\uFE0F SFCC profile NOT updated: ${profileError.relPaths.join(", ")} carries two Bridge MCP registrations, so it was left untouched. Remove the unintended entry, then re-run this apply to activate the SFCC tools.`)}}catch{}return{content:[{type:"text",text:text4}]}});registerTool("persist_routing_credential",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"Persist the ALREADY-VALIDATED Bridge API key for this repo into the user-scoped credential store (`~/.config/bridge/credentials.json`) under the target `bapi:<repo_name>`, so that Bash-spawned CLI features such as `start-tickets` (a different runtime surface than the MCP server) can resolve it for difficulty\u2192model routing. This is the final stage of `/install-bridge`. The key is resolved INSIDE the MCP server process (env-first, then the existing store) using the provided `repo_name` as the store identity \u2014 it is NEVER passed as a tool argument. Existing credentials are preserved; only `BAPI_API_KEY` for this repo is upserted. The response is secret-free (it reports ok/action/target/path only) and never echoes the key value.",inputSchema:{repo_name:z18.string().describe("The repository name to store the routing credential under (target `bapi:<repo_name>`). This is the ONLY input \u2014 do not pass the API key, a secret, or a token; the key is resolved inside the MCP server process.")}},async({repo_name})=>{let repoName=typeof repo_name=="string"?repo_name.trim():"",deps=buildCredentialStoreWriteDeps(),storePath=getPrimaryCredentialStorePath(deps);if(repoName.length===0)return{content:[{type:"text",text:JSON.stringify({ok:!1,message:"Cannot persist routing credential: repo_name is required. Pass the repo name this install is configuring.",path:storePath})}]};let target=`bapi:${repoName}`,apiKey=await getResolvedApiKeyForRepo(repoName);if(apiKey.length===0)return{content:[{type:"text",text:JSON.stringify({ok:!1,target,path:storePath,message:`No BAPI_API_KEY could be resolved for ${target}. Set BAPI_API_KEY in the environment (or add it under ${target} in ${storePath}) and rerun /install-bridge.`})}]};let result=await upsertBapiCredential(repoName,apiKey,deps);return result.ok?{content:[{type:"text",text:JSON.stringify({ok:!0,action:result.action,target:result.target,path:result.path,migratedFallback:result.migratedFallback,message:`Stored routing credential for ${result.target} at ${result.path}.`})}]}:{content:[{type:"text",text:JSON.stringify({ok:!1,target:result.target,path:result.path,kind:result.kind,message:`Failed to persist routing credential for ${result.target}: ${result.error} You can rerun /install-bridge or migrate manually.`})}]}});function formatDeepResearchProviderReason(meta){if(!meta)return"";let parts=[],reason=meta.incomplete_details?.reason;reason&&parts.push(`provider reason: ${reason}`);let errMsg=meta.error?.message,errCode=meta.error?.code;return(errMsg||errCode)&&(errCode&&errMsg?parts.push(`provider error: ${errCode}: ${errMsg}`):errMsg?parts.push(`provider error: ${errMsg}`):errCode&&parts.push(`provider error: ${errCode}`)),parts.length?` (${parts.join("; ")})`:""}function _safeIsoMs(value){if(!value)return null;let ms=new Date(value).getTime();return Number.isNaN(ms)?null:ms}function formatDeepResearchElapsed(createdAt,lastPollAt){let createdMs=_safeIsoMs(createdAt);if(createdMs===null)return"";let now=Date.now(),startedMs=Math.max(0,now-createdMs),startedMin=Math.floor(startedMs/6e4),lastPollMs=_safeIsoMs(lastPollAt),pollSuffix="";return lastPollMs!==null&&(pollSuffix=`, last poll ${Math.max(0,Math.floor((now-lastPollMs)/1e3))}s ago`),` (running ${startedMin}m${pollSuffix})`}function formatDeepResearchFailure(body){let kind=body.error_kind||body.error_message||"Unknown error",reason=formatDeepResearchProviderReason(body.provider_status_meta);return`Deep research failed: ${kind}${reason}. Consider using standard web searches to gather the information incrementally.`}function formatDeepResearchStatus(body,taskId){let elapsed=formatDeepResearchElapsed(body.created_at,body.last_poll_at),reason=formatDeepResearchProviderReason(body.provider_status_meta);return`Status: ${body.status}${elapsed}${reason} (task_id: ${taskId}). Try again in a minute.`}registerTool("request_deep_research",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Use to start async deep research on a technical topic using AI-powered web search. Returns a task_id immediately (or the full report if wait_for_result is true). Use get_deep_research to retrieve. Generates and persists a retrievable artifact.",inputSchema:{query:z18.string().describe("The research query. Be specific and detailed about what you need to learn. Good: 'What are the tradeoffs between Redis, Memcached, and DynamoDB DAX for caching in a Python FastAPI application serving 10k RPM, including connection pooling, serialization overhead, and failure modes?' Bad: 'caching options' (too vague \u2014 use a web search instead)"),context:z18.string().optional().describe("Optional context to focus the research scope. Describe your current task, tech stack, and constraints. Example: 'I am building a FastAPI application that uses PostgreSQL and needs to implement real-time notifications. Focus on Python-specific solutions compatible with async frameworks.'"),ticket_number:commonFields.ticket_number.optional(),wait_for_result:commonFields.wait_for_result,save_locally:commonFields.save_locally}},async({query,context,ticket_number,wait_for_result,save_locally})=>{let submitPayload={repo_name:REPO_NAME,query};context&&(submitPayload.context=context),ticket_number&&(submitPayload.ticket_number=ticket_number);let submitResp=await fetch(buildUrl("/deep-research"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(submitPayload)});if(!submitResp.ok)return{content:[{type:"text",text:await handleResponse(submitResp)}]};let taskId=(await submitResp.json()).task_id;if(!wait_for_result)return{content:[{type:"text",text:`Deep research submitted (task_id: ${taskId}). Processing typically takes 2-10 minutes. Use get_deep_research with task_id ${taskId} to retrieve the result once processing completes.`}]};let startTime=Date.now(),MAX_TIMEOUT_MS=900*1e3,pollIntervalMs=15e3,lastStatus="queued",latestStatusBody=null;for(;Date.now()-startTime<MAX_TIMEOUT_MS;){await new Promise(resolve2=>setTimeout(resolve2,pollIntervalMs));let elapsed=Math.round((Date.now()-startTime)/1e3);console.error(`Deep research in progress... (elapsed: ${elapsed}s, status: ${lastStatus})`);let statusUrl=buildGetUrl(`/deep-research/${taskId}/status`,{repo_name:REPO_NAME}),statusResp=await fetch(statusUrl,{headers:await getGetHeaders()});if(!statusResp.ok){let errorText=await handleResponse(statusResp);return{content:[{type:"text",text:JSON.stringify({error:"INTERNAL_ERROR",status:500,message:`Error polling deep research status: ${errorText}`})}]}}let statusBody=await statusResp.json();if(lastStatus=statusBody.status,latestStatusBody=statusBody,lastStatus==="completed")break;if(lastStatus==="failed")return{content:[{type:"text",text:formatDeepResearchFailure(statusBody)}]};Date.now()-startTime>6e4&&(pollIntervalMs=3e4)}if(lastStatus!=="completed"){let statusSuffix=latestStatusBody?` ${formatDeepResearchStatus(latestStatusBody,taskId)}`:"";return{content:[{type:"text",text:`Deep research timed out after 15 minutes (task_id: ${taskId}).${statusSuffix} The task may still be processing on the server. Use get_deep_research with this task_id to check later, or use standard web searches to gather the information incrementally.`}]}}let resultUrl=buildGetUrl(`/deep-research/${taskId}/result`,{repo_name:REPO_NAME}),resultResp=await fetch(resultUrl,{headers:await getGetHeaders()});if(!resultResp.ok){let errorText=await handleResponse(resultResp);return{content:[{type:"text",text:JSON.stringify({error:"INTERNAL_ERROR",status:500,message:`Error retrieving deep research result: ${errorText}`})}]}}let resultText=await resultResp.text();if(save_locally){let slug=slugify(query),note=await saveLocally(await getDocsPath("deep-research"),`${slug}-${taskId}.md`,resultText);resultText+=note}return{content:[{type:"text",text:resultText}]}});registerTool("get_deep_research",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"RETRIEVE the result of a previously submitted deep research request. This tool only fetches an existing/in-progress result \u2014 it does NOT start or trigger new research. If you have not submitted a research request yet (or you need a new one), call `request_deep_research` first; it starts the async research and this `get_deep_research` tool retrieves the result. Returns the full markdown research report if the task is completed, or a structured status response (still processing / failed / not-found) if the report is not ready yet \u2014 that means research has not finished, not that this tool failed. Use this after calling request_deep_research with wait_for_result=false.",inputSchema:{task_id:z18.number().describe("The task ID returned by request_deep_research."),query_slug:z18.string().optional().describe("Optional slug derived from the original query, used for the saved filename. If omitted, the file is saved as 'research-{task_id}.md'."),save_locally:commonFields.save_locally}},async({task_id,query_slug,save_locally})=>{let statusUrl=buildGetUrl(`/deep-research/${task_id}/status`,{repo_name:REPO_NAME}),statusResp=await fetch(statusUrl,{headers:await getGetHeaders()});if(!statusResp.ok)return{content:[{type:"text",text:await handleResponse(statusResp)}]};let statusBody=await statusResp.json();if(statusBody.status==="failed")return{content:[{type:"text",text:formatDeepResearchFailure(statusBody)}]};if(statusBody.status!=="completed")return{content:[{type:"text",text:formatDeepResearchStatus(statusBody,task_id)}]};let resultUrl=buildGetUrl(`/deep-research/${task_id}/result`,{repo_name:REPO_NAME}),resultResp=await fetch(resultUrl,{headers:await getGetHeaders()});if(!resultResp.ok)return{content:[{type:"text",text:await handleResponse(resultResp)}]};let resultText=await resultResp.text();if(save_locally){let slug=query_slug||"research",note=await saveLocally(await getDocsPath("deep-research"),`${slug}-${task_id}.md`,resultText);resultText+=note}return{content:[{type:"text",text:resultText}]}});var BRAINSTORM_TERMINAL_STATUSES=new Set(["completed","failed","skipped"]);function isBrainstormTerminalStatus(status){return BRAINSTORM_TERMINAL_STATUSES.has(status)}async function pollBrainstormUntilTerminal(brainstormId,repoName){let startTime=Date.now(),MAX_TIMEOUT_MS=900*1e3,pollIntervalMs=15e3,latest=null,consecutiveFetchFailures=0,recovery={handleName:"brainstorm_id",handleValue:brainstormId,recoveryGetUrl:buildGetUrl(`/brainstorms/${brainstormId}/result`,{repo_name:repoName}),retrievalToolName:"get_council"};for(;Date.now()-startTime<MAX_TIMEOUT_MS;){await new Promise(resolve2=>setTimeout(resolve2,pollIntervalMs));let elapsed=Math.round((Date.now()-startTime)/1e3),statusUrl=buildGetUrl(`/brainstorms/${brainstormId}/status`,{repo_name:repoName}),statusResp;try{statusResp=await fetch(statusUrl,{headers:await getGetHeaders()})}catch{if(consecutiveFetchFailures+=1,console.error(`Council ${brainstormId} status poll connection failure ${consecutiveFetchFailures}/${MAX_CONSECUTIVE_POLL_FAILURES} (elapsed: ${elapsed}s)`),consecutiveFetchFailures>=MAX_CONSECUTIVE_POLL_FAILURES){let situation2=`Council ${brainstormId} stopped polling after ${MAX_CONSECUTIVE_POLL_FAILURES} consecutive connection failures.`;return{kind:"giveup",text:formatRecoverablePollGiveUp(situation2,recovery)}}Date.now()-startTime>6e4&&(pollIntervalMs=3e4);continue}if(consecutiveFetchFailures=0,!statusResp.ok)return{kind:"status",envelope:latest};if(latest=await statusResp.json(),latest.rows.every(row=>isBrainstormTerminalStatus(row.status)))return{kind:"status",envelope:latest};Date.now()-startTime>6e4&&(pollIntervalMs=3e4)}let situation=`Council ${brainstormId} timed out after ${Math.round(MAX_TIMEOUT_MS/1e3)} seconds. The task may still be processing on the server.`;return{kind:"giveup",text:formatRecoverablePollGiveUp(situation,recovery)}}async function saveBrainstormResultsLocally(envelope2,subject){let dir=await getDocsPath("brainstorm");return saveBrainstormResultsToDir(envelope2,dir,subject)}function formatBrainstormToolResponse(envelope2,savedPaths){let lines=[];lines.push(`# Council ${envelope2.brainstorm_id}`),lines.push(`Repo: ${envelope2.repo_name}`),lines.push("");for(let row of envelope2.results)lines.push(`## ${row.provider} \u2014 status: ${row.status}`),lines.push(`error_kind: ${row.error_kind??"null"}`),row.error_message&&lines.push(`error_message: ${row.error_message}`),row.markdown&&(lines.push(""),lines.push(row.markdown)),lines.push("");if(savedPaths.length>0){lines.push("---"),lines.push("Saved files:");for(let p of savedPaths)lines.push(`- ${p}`)}return lines.join(`
7913
- `)}registerTool("request_council",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Use to start an async council that fans out a task to multiple opinion-provider LLMs. Returns a brainstorm_id immediately (or the full result envelope if wait_for_result is true). Use get_council to retrieve. Generates and persists a retrievable artifact.",inputSchema:{task_description:z18.string().describe("Free-form description of the task for the council to weigh in on. Sent verbatim \u2014 this tool does NOT read task_description from a file."),repo_name:commonFields.repo_name,ticket_number:commonFields.ticket_number.optional(),providers:z18.array(z18.string()).optional().describe("Opinion-provider LLMs. Defaults to ['openai', 'gemini']. A single-provider request runs one opinion provider and returns that provider's markdown directly."),concerns:z18.string().optional().describe("Optional caller-supplied concerns to surface to the council agents."),wait_for_result:commonFields.wait_for_result,save_locally:commonFields.save_locally,prior_brainstorm_id:z18.string().optional().describe("Optional brainstorm_id (the id field returned by an earlier council) to refine. When provided, the prior council's completed opinion-provider markdowns are concatenated and supplied as prior context."),mode:z18.enum(["technical","design","discovery","general"]).optional().describe("Preferred council-mode selector for new callers. 'technical' (default) is the implementation/architecture council; 'design' is web-page/UI visual-direction ideation; 'discovery' generates grouped discovery questions for early/vague tasks. 'general' convenes the council from the supplied brief alone, with no indexed repository context required, unlike 'technical'/'discovery'. Takes precedence over the legacy boolean design field."),design:z18.boolean().optional().describe('Legacy compatibility flag: set to true for web-page/UI design ideation focused on visual appeal and conversion. New callers should use mode: "design" instead. Omit this field when not requesting design mode; absent is treated as false.'),lenses:z18.array(z18.string()).optional().describe("Optional reasoning lenses (e.g. 'simplicity', 'robustness', 'blast-radius') assigned one per provider, applies to technical/design modes only. Omitting this defaults to an automatic Simplicity + Extensibility pair."),debate:z18.boolean().optional().describe("Opt-in second cross-examination round (default off; ignored in discovery mode). Each provider critiques the others' round-1 output, appended under '## Cross-examination'. Costs an extra round and measured no better than the default \u2014 prefer omitting it.")}},async({task_description,repo_name,ticket_number,providers,concerns,wait_for_result,save_locally,prior_brainstorm_id,mode,design,lenses,debate})=>{let effectiveRepo=repo_name&&repo_name.length>0?repo_name:REPO_NAME,effectiveProviders=providers!==void 0?providers:["openai","gemini"],shouldWait=wait_for_result===!0,shouldSave=save_locally!==!1,submitPayload={repo_name:effectiveRepo,task_description,providers:effectiveProviders};ticket_number&&(submitPayload.ticket_number=ticket_number),concerns&&(submitPayload.concerns=concerns),prior_brainstorm_id&&(submitPayload.prior_brainstorm_request_id=prior_brainstorm_id),mode&&(submitPayload.mode=mode),design&&(submitPayload.design=!0),lenses&&(submitPayload.lenses=lenses),debate&&(submitPayload.debate=!0);let submitResp;try{submitResp=await fetch(buildUrl("/brainstorms"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(submitPayload)})}catch{return{content:[{type:"text",text:formatTriggerConnectionFailure("the request_council tool")}]}}if(!submitResp.ok)return{content:[{type:"text",text:await handleResponse(submitResp)}]};let submitBody=await submitResp.json();if(!shouldWait)return{content:[{type:"text",text:`Council submitted (brainstorm_id: ${submitBody.brainstorm_id}). Providers: ${submitBody.providers.join(", ")}. Synthesis step: removed; provider opinions will be returned directly. Use get_council with brainstorm_id ${submitBody.brainstorm_id} to retrieve results.`}]};let pollOutcome=await pollBrainstormUntilTerminal(submitBody.brainstorm_id,effectiveRepo);if(pollOutcome.kind==="giveup")return{content:[{type:"text",text:pollOutcome.text}]};if(!pollOutcome.envelope)return{content:[{type:"text",text:`Council could not confirm terminal status (brainstorm_id: ${submitBody.brainstorm_id}). Use get_council later.`}]};let resultUrl=buildGetUrl(`/brainstorms/${submitBody.brainstorm_id}/result`,{repo_name:effectiveRepo}),resultResp=await fetch(resultUrl,{headers:await getGetHeaders()});if(!resultResp.ok)return{content:[{type:"text",text:await handleResponse(resultResp)}]};let envelope2=await resultResp.json(),savedPaths=[];return shouldSave&&(savedPaths=await saveBrainstormResultsLocally(envelope2,task_description)),{content:[{type:"text",text:formatBrainstormToolResponse(envelope2,savedPaths)}]}});registerTool("get_council",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Use to retrieve the result envelope for a previously submitted council by brainstorm_id. Returns opinion-provider rows only (including error_kind for each row). Does NOT start a new council \u2014 use request_council first if none exists. Returns not-found when still processing.",inputSchema:{brainstorm_id:z18.string().describe("The brainstorm_id (UUID) returned by request_council."),repo_name:commonFields.repo_name,save_locally:commonFields.save_locally}},async({brainstorm_id,repo_name,save_locally})=>{let effectiveRepo=repo_name&&repo_name.length>0?repo_name:REPO_NAME,shouldSave=save_locally!==!1,resultUrl=buildGetUrl(`/brainstorms/${brainstorm_id}/result`,{repo_name:effectiveRepo}),resultResp=await fetch(resultUrl,{headers:await getGetHeaders()});if(!resultResp.ok)return{content:[{type:"text",text:await handleResponse(resultResp)}]};let envelope2=await resultResp.json(),savedPaths=[];return shouldSave&&(savedPaths=await saveBrainstormResultsLocally(envelope2)),{content:[{type:"text",text:formatBrainstormToolResponse(envelope2,savedPaths)}]}});registerTool("create_pull_request",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Create a pull request on the configured VCS provider (GitHub or Bitbucket). Returns a structured response with {available, reason, action, detail}. If a PR already exists for the head branch, returns it with created=false. Capability issues (missing VCS config, API errors) return available=false, not errors. The repo_name is automatically injected from the configured environment.",inputSchema:{head_branch:z18.string().describe("The source branch name for the pull request"),base_branch:z18.string().describe("The target/destination branch name for the pull request"),title:z18.string().describe("The title of the pull request"),body:z18.string().optional().describe("The description/body of the pull request")}},async({head_branch,base_branch,title,body})=>{let payload={repo_name:REPO_NAME,head_branch,base_branch,title};body!==void 0&&(payload.body=body);let resp=await fetch(buildUrl("/vcs/pull-requests"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(payload)});return{content:[{type:"text",text:await handleResponse(resp)}]}});registerTool("merge_pull_request",{annotations:{readOnlyHint:!1,destructiveHint:!0,idempotentHint:!0,openWorldHint:!0},description:"Ask the server to merge a pull request; the SERVER decides the outcome. Only merged:true is success \u2014 dry_run, pending_approval, and lease_held are non-progress outcomes where nothing merged. Returns one JSON envelope: merged, outcome, reason, retry_hint, evaluated_head_sha, pr_number. repo_name is injected from the environment.",inputSchema:{pr_number:z18.number().int().positive().describe("Pull request number to merge."),expected_head_sha:z18.string().regex(/^[0-9a-fA-F]{40}$/).describe("Full 40-character head commit SHA the merge is bound to.")}},async args=>mergePullRequestHandler(mergePullRequestDeps,args));var resolveCiChecksTool=registerTool("resolve_ci_checks",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Discover and classify CI checks for the configured repository. Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), then uses Branch Protection API or LLM to determine which checks are required for merging. Results are cached per-project \u2014 subsequent calls return cached config unless force_rerun is true. Returns {available, reason, action, detail} envelope. The repo_name is automatically injected from the configured environment.",inputSchema:{commit_ref:z18.string().describe("Git commit SHA to discover checks for"),force_rerun:z18.boolean().optional().describe("Set to true to bypass cache and re-resolve CI checks")}},async({commit_ref,force_rerun})=>{let payload={repo_name:REPO_NAME,commit_ref};force_rerun!==void 0&&(payload.force_rerun=force_rerun);let resp=await fetch(buildUrl("/resolve-ci-checks"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(payload)}),text4=await handleResponse(resp);try{JSON.parse(text4).available===!0&&pollCiChecksTool.enable()}catch{}return{content:[{type:"text",text:text4}]}}),pollCiChecksTool=registerTool("poll_ci_checks",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Poll the current status of CI checks for a specific commit. Requires that resolve_ci_checks has been called first to populate the check configuration. Returns per-check status, all_complete, all_passed, and unknown_checks fields. For failed checks with detail_level 'full', includes annotations and/or log tails. Returns {available, reason, action, detail} envelope. The repo_name is automatically injected from the configured environment.",inputSchema:{commit_ref:z18.string().describe("Git commit SHA to poll CI checks for")}},async({commit_ref})=>{let url=buildGetUrl("/poll-ci-checks",{repo_name:REPO_NAME,commit_ref}),resp=await fetch(url,{headers:await getGetHeaders()}),text4=await handleResponse(resp);try{let parsed=JSON.parse(text4);parsed!==null&&typeof parsed=="object"&&!("error"in parsed)&&(Array.isArray(parsed.checks)||typeof parsed.all_complete=="boolean")&&observePrCiFromPollResponse(commit_ref,parsed,{resolveRunId:resolveDispatchRunIdForBinding}).catch(()=>{})}catch{}return{content:[{type:"text",text:text4}]}});async function checkCiConfigAndDisablePoll(){try{let url=buildGetUrl("/config-field/ci_check_config",{repo_name:REPO_NAME}),resp=await fetch(url,{headers:await getGetHeaders()});if(resp.ok){let value=(await resp.json()).value;value==null&&(pollCiChecksTool.disable(),console.error("poll_ci_checks disabled: ci_check_config is null"))}else pollCiChecksTool.disable(),console.error("poll_ci_checks disabled: could not read ci_check_config")}catch(err){pollCiChecksTool.disable(),console.error(`poll_ci_checks disabled: ${err}`)}}await checkCiConfigAndDisablePoll();registerTool("get_docs_dir",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, which requires shell access and may be blocked on some AI coding platforms.",inputSchema:{}},async()=>({content:[{type:"text",text:await getDocsDir()}]}));async function buildPipelineOrchestratorDeps(){return await ensureCustomPipelinesLoaded(),{baseUrl:BASE_URL,apiKey:await getResolvedApiKey(),repoName:REPO_NAME,docsDir:await getDocsDir(),pipelines:PIPELINES2,instructions:INSTRUCTIONS2,toolHandlers:TOOL_HANDLERS,includeUpgradeAdviceSurfacing:UPGRADE_ADVICE_SURFACING_ENABLED}}async function buildChainOrchestratorDeps(){return await ensureCustomPipelinesLoaded(),{baseUrl:BASE_URL,apiKey:await getResolvedApiKey(),repoName:REPO_NAME,docsDir:await getDocsDir(),pipelines:PIPELINES2,chainRecipes:CHAIN_RECIPES,instructions:INSTRUCTIONS2,toolHandlers:TOOL_HANDLERS,includeUpgradeAdviceSurfacing:UPGRADE_ADVICE_SURFACING_ENABLED}}registerTool("get_pipeline_recipe",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction file references to inline content, and returns an ordered array of executable steps. Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). You execute the returned steps yourself (execution_mode: inline); no server-side orchestrator runs them. Use list_pipelines to discover available pipeline names first. Note: the 'docs_dir' variable is automatically set from BAPI_DOCS_DIR \u2014 callers should omit it.",inputSchema:{pipeline:z18.string().describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),variables:z18.record(z18.string(),z18.string()).optional().describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' })"),skip_steps:z18.array(z18.string()).optional().describe("Step tool names or descriptions to omit from the recipe"),auto_approve:z18.boolean().optional().describe("When true, auto-approve all approval-gated steps (skips the commit/push pause for implement-ticket; skips the HTML decision page for review-ticket, picking each item's recommended option). Pass via this top-level parameter."),rounds:z18.union([z18.literal(1),z18.literal(2)]).optional().describe("Round count (1|2); wins over adaptive routing. Omit for backend auto-routing.")}},async({pipeline:pipelineName,variables,skip_steps,auto_approve,rounds})=>{await ensureCustomPipelinesLoaded();let pipelineDef=PIPELINES2[pipelineName];if(!pipelineDef){let available=Object.keys(PIPELINES2).join(", ");return{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[404]??"NOT_FOUND",status:404,message:`Pipeline "${pipelineName}" not found. Available pipelines: ${available||"(none)"}`})}]}}if(variables&&"auto_approve"in variables)return{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[400]??"BAD_REQUEST",status:400,message:"Pass auto_approve via the top-level parameter, not via the variables map."})}]};try{let mergedVariables={docs_dir:await getDocsDir(),provider:"",rounds:"",second_opinion:"",auto_approve:auto_approve?"true":"",base_branch:"",base_sha:"",no_refresh_base:"",...variables??{}};"idea"in mergedVariables&&(mergedVariables.idea_hash=deriveIdeaHash(mergedVariables.idea)),(rounds===1||rounds===2)&&(mergedVariables.rounds=String(rounds));let effectiveSkipSteps=skip_steps?[...skip_steps]:[],recipe=resolveRecipe(pipelineDef,INSTRUCTIONS2,mergedVariables,effectiveSkipSteps,!!auto_approve,{includeUpgradeAdviceSurfacing:UPGRADE_ADVICE_SURFACING_ENABLED,executionMode:"inline"});return{content:[{type:"text",text:JSON.stringify(recipe,null,2)}]}}catch(err){let message=err instanceof Error?err.message:String(err),isServerError=message.includes("not found in bundled instructions"),status=isServerError?500:400,code=isServerError?"PIPELINE_DATA_ERROR":ERROR_CODES[400]??"BAD_REQUEST";return{content:[{type:"text",text:JSON.stringify({error:code,status,message})}]}}});async function fetchPlanMetadata(ticketKey){let resp=await fetch(buildGetUrl(`/ticket/${encodeURIComponent(ticketKey)}/plan`,{repo_name:REPO_NAME,include_metadata:"true"}),{headers:await getGetHeaders()});if(resp.status===202)throw new Error(`Plan generation for ${ticketKey} is still running; routing metadata is not available yet.`);if(!resp.ok)throw new Error(`Could not read plan routing metadata for ${ticketKey} (HTTP ${resp.status}).`);let body;try{body=await resp.json()}catch{throw new Error(`Plan routing metadata for ${ticketKey} was not valid JSON.`)}let envelope2=body??{},status=envelope2.plan_metadata_status;if(envelope2.plan_metadata===null||envelope2.plan_metadata===void 0)throw new Error(`Plan for ${ticketKey} carries no routing metadata (plan_metadata_status: ${String(status??"absent")}). Ownership must not be inferred from plan prose \u2014 regenerate the plan or execute every step.`);return envelope2.plan_metadata}function safeArtifactMessage(err){let raw=err instanceof Error?err.message:String(err);return redactSecrets(raw).replace(/(?:\/[^\s/:"']+){2,}/g,match=>`\u2026/${match.slice(match.lastIndexOf("/")+1)}`)}function artifactErrorEnvelope(err,status){let message=safeArtifactMessage(err);return console.error(`[phase-artifacts] ${message}`),{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[status]??"BAD_REQUEST",status,message})}]}}function artifactOkEnvelope(payload){return{content:[{type:"text",text:JSON.stringify(payload,null,2)}]}}registerTool("get_phase_context",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Inline pipeline phases only: return the step IDs this plan phase owns plus the durable ledger of what earlier phases already settled. Ownership is derived from the plan's provenance metadata, never from plan prose. Reading creates nothing.",inputSchema:{ticket_key:z18.string().describe("Ticket key, e.g. BAPI-123."),phase:z18.enum(PLAN_PHASES).describe("Which plan phase's routing to return.")}},async({ticket_key,phase})=>{try{let context=await loadInlinePhaseContext({docsDir:await getDocsDir(),ticketKey:ticket_key,phase,planMetadata:await fetchPlanMetadata(ticket_key)});return artifactOkEnvelope({status:"ok",...context})}catch(err){return artifactErrorEnvelope(err,400)}});registerTool("record_phase_result",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Inline pipeline phases only: validate a phase-result envelope against the steps the phase owns and persist it to the durable ledger. Rejects an envelope that leaves an owned step unsettled, reports a step it does not own, or walks a settled step back. Call this as the phase's final action, then continue with the next recipe step.",inputSchema:{ticket_key:z18.string().describe("Ticket key, e.g. BAPI-123."),phase_result:z18.record(z18.string(),z18.unknown()).describe("The bapi-phase-result envelope: {version, phase, records:[{stepId, provenanceClass, disposition, evidence}]}.")}},async({ticket_key,phase_result})=>{try{let result=await writeInlinePhaseResult({docsDir:await getDocsDir(),ticketKey:ticket_key,planMetadata:await fetchPlanMetadata(ticket_key),phaseResult:phase_result});return artifactOkEnvelope({status:"recorded",...result})}catch(err){return artifactErrorEnvelope(err,400)}});registerTool("record_checkpoint",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"Inline pipeline phases only: record the branch and SHA the produced work was pushed at. Accepted only when the push is proven durable on origin (pushed and remote_matches_head both true).",inputSchema:{ticket_key:z18.string().describe("Ticket key, e.g. BAPI-123."),branch:z18.string().describe("The branch that was pushed."),sha:z18.string().describe("The pushed HEAD SHA."),pushed:z18.boolean().describe("True only when the push succeeded."),remote_matches_head:z18.boolean().describe("True only when the remote tip equals the local HEAD.")}},async({ticket_key,branch,sha,pushed,remote_matches_head})=>{try{let result=await writeInlineCheckpoint({docsDir:await getDocsDir(),ticketKey:ticket_key,checkpoint:{branch,sha,pushed,remoteMatchesHead:remote_matches_head}});return artifactOkEnvelope({status:"recorded",...result})}catch(err){return artifactErrorEnvelope(err,400)}});var REVIEW_WORKSPACE_PREFIX="bridge-review-",REVIEW_WORKSPACE_TTL_MS=1440*60*1e3;async function pruneStaleReviewWorkspaces(){let tmpDir=os23.tmpdir(),entries;try{entries=await readdir6(tmpDir)}catch{return}let now=Date.now();for(let entry of entries){if(!entry.startsWith(REVIEW_WORKSPACE_PREFIX))continue;let fullPath=path52.join(tmpDir,entry);try{let info=await stat11(fullPath);now-info.mtimeMs>REVIEW_WORKSPACE_TTL_MS&&await rm7(fullPath,{recursive:!0,force:!0})}catch{}}}registerTool("materialize_fresh_base",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:'Materialize a pinned origin/<base_branch> tree via git archive into a unique temp dir, without mutating the working tree, index, stash, or branches. Returns { base_sha, fresh_base_root }. base_branch precedence: param > config > "main". no_refresh_base: "true" skips the fetch, returning the local project root with base_sha "local-stale".',inputSchema:{base_branch:z18.string().optional().describe(`Branch to fetch and materialize from origin. Defaults to the 'base_branch' config field, else "main".`),base_sha:z18.string().optional().describe("Pre-resolved commit SHA to materialize (skips the fetch+resolve step, e.g. a batch-pinned SHA from review-tickets)."),no_refresh_base:z18.string().optional().describe('Pass "true" to skip fetch/materialization and fall back to the local project root as-is.')}},async({base_branch,base_sha,no_refresh_base})=>{let projectRoot=await getProjectRoot();if(no_refresh_base==="true")return{content:[{type:"text",text:JSON.stringify({base_sha:"local-stale",fresh_base_root:projectRoot})}]};let startTicketsDeps={...createDefaultStartTicketsDeps(),cwd:projectRoot},resolvedBaseSha=(base_sha??"").trim(),effectiveBaseBranch=(base_branch??"").trim();if(effectiveBaseBranch.length===0)try{let access2={repoName:REPO_NAME,apiKey:await getResolvedApiKey(),baseUrl:BASE_URL},configValue=await fetchStartTicketsConfigField(access2,BASE_BRANCH_CONFIG_FIELD);typeof configValue=="string"&&configValue.trim().length>0&&(effectiveBaseBranch=configValue.trim())}catch{}effectiveBaseBranch.length===0&&(effectiveBaseBranch="main");let branchError=validateBranchName(effectiveBaseBranch);if(branchError)return{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[400]??"BAD_REQUEST",status:400,message:`Invalid base_branch '${effectiveBaseBranch}': ${branchError}`})}]};if(resolvedBaseSha.length===0){let fetchResult=await fetchAndResolveBaseSha(startTicketsDeps,effectiveBaseBranch);if(!fetchResult.ok)return{content:[{type:"text",text:JSON.stringify({error:"FETCH_FAILED",status:502,message:fetchResult.error,base_branch:effectiveBaseBranch})}]};resolvedBaseSha=fetchResult.base_sha}else if(!/^[0-9a-f]{7,40}$/i.test(resolvedBaseSha))return{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[400]??"BAD_REQUEST",status:400,message:`Invalid base_sha '${resolvedBaseSha}': must be a hex commit SHA.`})}]};let tempDir;try{tempDir=await mkdtemp4(path52.join(os23.tmpdir(),REVIEW_WORKSPACE_PREFIX))}catch(err){let message=err instanceof Error?err.message:String(err);return{content:[{type:"text",text:JSON.stringify({error:"TEMP_DIR_FAILED",status:500,message:`Failed to create a review workspace temp directory: ${message}`})}]}}let archivePath=path52.join(tempDir,"archive.tar"),archiveResult=await startTicketsDeps.runCommand("git",["archive","--format=tar",resolvedBaseSha,"-o",archivePath],{cwd:projectRoot});if(archiveResult.exitCode!==0)return await rm7(tempDir,{recursive:!0,force:!0}).catch(()=>{}),{content:[{type:"text",text:JSON.stringify({error:"ARCHIVE_FAILED",status:500,message:`git archive of ${resolvedBaseSha} failed: ${archiveResult.stderr||archiveResult.stdout}`,base_sha:resolvedBaseSha})}]};let extractResult=await startTicketsDeps.runCommand("tar",["-xf",archivePath],{cwd:tempDir});return await unlink4(archivePath).catch(()=>{}),extractResult.exitCode!==0?(await rm7(tempDir,{recursive:!0,force:!0}).catch(()=>{}),{content:[{type:"text",text:JSON.stringify({error:"EXTRACT_FAILED",status:500,message:`tar extraction of the archived base tree failed: ${extractResult.stderr||extractResult.stdout}`,base_sha:resolvedBaseSha})}]}):{content:[{type:"text",text:JSON.stringify({base_sha:resolvedBaseSha,base_branch:effectiveBaseBranch,fresh_base_root:tempDir})}]}});registerTool("cleanup_fresh_base",{annotations:{readOnlyHint:!1,destructiveHint:!0,idempotentHint:!0,openWorldHint:!1},description:"Remove a review workspace temp directory previously returned by materialize_fresh_base. Strictly namespace-scoped: refuses to delete any path outside the OS temp dir's 'bridge-review-' prefix.",inputSchema:{fresh_base_root:z18.string().describe("The fresh_base_root path returned by a prior materialize_fresh_base call.")}},async({fresh_base_root})=>{let allowedPrefix=path52.join(os23.tmpdir(),REVIEW_WORKSPACE_PREFIX),resolvedTarget=path52.resolve(fresh_base_root),resolvedTmpDir=path52.resolve(os23.tmpdir()),isDirectChildOfTmpDir=path52.dirname(resolvedTarget)===resolvedTmpDir,hasReviewPrefix=path52.basename(resolvedTarget).startsWith(REVIEW_WORKSPACE_PREFIX);return!isDirectChildOfTmpDir||!hasReviewPrefix?{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[400]??"BAD_REQUEST",status:400,message:`Refusing to delete '${fresh_base_root}': it is outside the review workspace namespace ('${allowedPrefix}*').`})}]}:(await rm7(resolvedTarget,{recursive:!0,force:!0}),{content:[{type:"text",text:JSON.stringify({status:"ok",message:`Removed review workspace at ${fresh_base_root}.`})}]})});ACTIVE_GROUPS.has("pipeline-authoring")&&(registerTool("list_pipelines",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"List all available pipeline recipes with their names, descriptions, and required variables. No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",inputSchema:{}},async()=>{await ensureCustomPipelinesLoaded();let list=Object.entries(PIPELINES2).map(([key,pipeline])=>({name:key,description:pipeline.description??"",variables:(pipeline.variables??[]).filter(v=>v!=="docs_dir"&&v!=="idea_hash"),source:userPipelineKeys.has(key)?"user":"bundled"}));return{content:[{type:"text",text:JSON.stringify(list,null,2)}]}}),registerTool("run_pipeline",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, dispatching mcp_call steps in-process and pausing on agent_task steps with a needs_agent_task envelope. Returns a unified envelope keyed on `status`: `completed` (terminal success with `results`), `needs_agent_task` (pause \u2014 read `instruction`, perform the task, then call `resume_pipeline` with the resulting string as `agent_result`), or `failed` (terminal error \u2014 check `error_code`: VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). Paused runs auto-expire after an idle TTL (default 24 hours; override with `ttl_seconds`). The TTL is reset on every state transition.\n\nCall list_pipelines for the current resolved catalog of available pipeline names (bundled plus any custom user pipelines).",inputSchema:{pipeline:z18.string().describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),variables:z18.record(z18.string(),z18.string()).optional().describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' }). Do NOT pass `auto_approve` here \u2014 use the top-level parameter."),auto_approve:z18.union([z18.boolean(),z18.literal("true"),z18.literal("false")]).optional().describe("When true, approval-gated mcp_call steps execute directly. When false or omitted, the orchestrator synthesises a needs_agent_task pause so the agent can confirm with the user before resuming. Accepts boolean or 'true'/'false' strings for MCP clients that serialize booleans as strings."),ttl_seconds:z18.number().int().positive().optional().describe("Override the default 24-hour idle TTL for this run. Must be a positive integer.")}},async input=>{let result=await runPipeline(await buildPipelineOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}}),registerTool("resume_pipeline",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Resume a paused pipeline run with the result of the agent_task. Provide the `pipeline_run_id` returned by the prior needs_agent_task envelope, and the string the instruction's `## Return` section asked you to produce as `agent_result`. `agent_result` is always a string \u2014 do not wrap it in JSON unless the instruction explicitly asked you to serialize structured output. Returns the same unified envelope shape as `run_pipeline`.",inputSchema:{pipeline_run_id:z18.string().describe("The pipeline_run_id returned by a prior needs_agent_task envelope"),agent_result:z18.string().describe("The string the paused instruction's ## Return section asked you to produce")}},async input=>{let result=await resumePipeline(await buildPipelineOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}}),registerTool("list_pipeline_runs",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"List recent pipeline runs for the configured repository, newest first. Returns metadata only \u2014 `resolved_recipe`, resolved params, instruction text, results, and agent outputs are intentionally excluded. Use this to recover a `pipeline_run_id` when an earlier needs_agent_task envelope is no longer in scope (e.g. after compaction or a client restart). Optionally filter by `status`: running | paused | completed | failed | expired.",inputSchema:{status:z18.enum(["running","paused","completed","failed","expired"]).optional().describe("Optional status filter")}},async input=>{let result=await listPipelineRuns(await buildPipelineOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}}),registerTool("delete_pipeline_run",{annotations:{readOnlyHint:!1,destructiveHint:!0,idempotentHint:!0,openWorldHint:!0},description:"Delete a pipeline run row (any status). Use this to discard orphaned `running` rows from a previous session that can't be resumed (resume_pipeline only accepts `paused`), to clean up after a failed run, or to remove a no-longer-needed paused session. Returns `{ status: 'completed', deleted: true, pipeline_run_id }` on success, or a `failed` envelope with error_code in (VALIDATION | NOT_FOUND | REPO_MISMATCH | TOOL_ERROR). Repo-scoped: the row's stored repo_name must match the caller's repo.",inputSchema:{pipeline_run_id:z18.string().describe("UUID of the pipeline run to delete.")}},async input=>{let result=await deletePipelineRun(await buildPipelineOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}}));registerTool("run_full_automation",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Run the full-automation chain for an idea: create ticket(s) (idea-to-ticket), review each created ticket (review-ticket fan-out), then emit the exact `/start-tickets ...` command for you to invoke in this same session. Returns the chain envelope keyed on `status`: `needs_agent_task` (perform the `next_action.instruction`, then call `resume_full_automation` with the result as `agent_result`), `completed`, or `failed` (check `error_code`: VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). Provide the idea via `idea` or `idea_file` (mutually exclusive).",inputSchema:{idea:z18.string().optional(),idea_file:z18.string().optional(),auto_approve:z18.union([z18.boolean(),z18.literal("true"),z18.literal("false")]).optional(),scheduled_at:z18.string().optional(),max_children:z18.number().int().positive().optional(),allow_duplicate:z18.boolean().optional(),agent:z18.enum(["claude"]).optional(),ttl_seconds:z18.number().int().positive().optional()}},async input=>{let{idea,idea_file,...rest}=input;if(idea!==void 0&&idea_file!==void 0)return{content:[{type:"text",text:JSON.stringify({error:"BAD_REQUEST",status:400,message:"Provide exactly one of `idea` or `idea_file`, not both."})}]};let resolved=await resolveTextOrFile(idea,idea_file,"idea");if(!resolved.ok)return resolved.errorResponse;let result=await runFullAutomation(await buildChainOrchestratorDeps(),{idea:resolved.text,...rest});return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}});registerTool("resume_full_automation",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Resume a paused full-automation chain run. Provide the `chain_run_id` returned by the prior needs_agent_task envelope and the string the instruction asked you to produce as `agent_result`. Returns the same chain envelope shape as `run_full_automation`.",inputSchema:{chain_run_id:z18.string(),agent_result:z18.string()}},async input=>{let result=await resumeFullAutomation(await buildChainOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}});function containsUnsafeEncodedPathToken(value){return/%2e/i.test(value)||/%2f/i.test(value)||/%5c/i.test(value)}function isPlatformAbsolutePath(value){return path52.posix.isAbsolute(value)||path52.win32.isAbsolute(value)||path52.isAbsolute(value)}function validateDecisionPageOutputSubdir(value){return value.trim().length===0?"Invalid output_subdir: must not be empty or whitespace-only.":value.includes("\0")?"Invalid output_subdir: must not contain null bytes.":containsUnsafeEncodedPathToken(value)?`Invalid output_subdir "${value}": must not contain encoded path tokens (%2e, %2f, %5c).`:isPlatformAbsolutePath(value)?`Invalid output_subdir "${value}": must be a relative path, not an absolute path.`:value.includes("\\")?`Invalid output_subdir "${value}": backslashes are not allowed; use "/" to separate nested directories.`:value.split(/[/\\]/).some(segment=>segment==="..")?`Invalid output_subdir "${value}": must not contain ".." path segments.`:null}function validateDecisionPageOutputFilename(value){return value.trim().length===0?"Invalid output_filename: must not be empty or whitespace-only.":value.includes("\0")?"Invalid output_filename: must not contain null bytes.":containsUnsafeEncodedPathToken(value)?`Invalid output_filename "${value}": must not contain encoded path tokens (%2e, %2f, %5c).`:value.includes("/")||value.includes("\\")?`Invalid output_filename "${value}": must not contain path separators.`:value==="."||value===".."?`Invalid output_filename "${value}": must be a real filename, not "." or "..".`:value.endsWith(".html")?null:`Invalid output_filename "${value}": must end with the ".html" suffix.`}async function resolveDecisionPageOutputTarget(outputSubdir,outputFilename){let subdirError=validateDecisionPageOutputSubdir(outputSubdir);if(subdirError)return{ok:!1,message:subdirError};let filenameError=validateDecisionPageOutputFilename(outputFilename);if(filenameError)return{ok:!1,message:filenameError};let docsBase=path52.resolve(await getDocsDir()),resolvedTarget=path52.resolve(docsBase,outputSubdir,outputFilename);return resolvedTarget.startsWith(docsBase+path52.sep)?{ok:!0,docsPath:path52.dirname(resolvedTarget),filePath:resolvedTarget}:{ok:!1,message:"Invalid output target: the resolved output path must stay under the docs directory."}}var DECISION_PAGE_CONTENT_CONTRACT="Expected shape: content.actionable_items[n] must have id, question, why_it_matters, recommendation_explanation, options (2-4 strings), option_consequences (same length as options), recommendation_index (0-based within options).",DECISION_PAGE_CONTENT_EXAMPLE='{"ticket_key":"BAPI-123","content":{"actionable_items":[{"id":"D-1","question":"Which approach?","why_it_matters":"Affects performance.","recommendation_explanation":"Option A is safer.","options":["A","B"],"option_consequences":["Safe path.","Risky path."],"recommendation_index":0}]}}';function formatDecisionPageValidationError(err){let first=err.issues[0],pathStr=first?.path?.length?first.path.join("."):"(root)",msg=first?.message??"Unknown validation error";return`Validation error at "${pathStr}": ${msg}. ${DECISION_PAGE_CONTENT_CONTRACT} Example: ${DECISION_PAGE_CONTENT_EXAMPLE}`}registerTool("generate_decision_page",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Use to generate a local, review-shaped HTML decision page for capturing user decisions. Returns the local file path and a summary of the rendered items.",inputSchema:DecisionPageLeanInputShape},async input=>{let validationError2=message=>({content:[{type:"text",text:JSON.stringify({error:"VALIDATION_ERROR",status:400,message})}]});if(!/^[A-Za-z][A-Za-z0-9_-]*$/.test(input.ticket_key))return validationError2(`Invalid ticket_key "${input.ticket_key}": must start with a letter and contain only letters, digits, hyphens, or underscores.`);if(input.content===void 0)return validationError2(`No \`content\` supplied. All decision data must be nested under \`content\` \u2014 root-level actionable_items / system_goals / clear_improvements / implementation_order are dropped by the lean input schema. ${DECISION_PAGE_CONTENT_CONTRACT} Example: ${DECISION_PAGE_CONTENT_EXAMPLE}`);let rawPayload={...input.content||{},ticket_key:input.ticket_key,artifact_type:input.artifact_type,output_subdir:input.output_subdir,output_filename:input.output_filename,labels:input.labels},parsed;try{parsed=DecisionPageInputSchema.parse(rawPayload)}catch(err){if(err instanceof z18.ZodError)return validationError2(formatDecisionPageValidationError(err));throw err}let hasPlanningContent=parsed.system_goals!==void 0||(parsed.implementation_order?.length??0)>0;if(parsed.actionable_items.length===0&&!hasPlanningContent)return{content:[{type:"text",text:JSON.stringify({status:"no_decisions_needed",ticket_key:parsed.ticket_key,clear_improvements_count:parsed.clear_improvements.length})}]};let seenIds=new Set;for(let item of parsed.actionable_items){if(seenIds.has(item.id))return validationError2(`Duplicate actionable_items id: "${item.id}"`);seenIds.add(item.id);let noneLabel=item.options.find(label=>label.toLowerCase()==="none of these");if(noneLabel)return validationError2(`Item "${item.id}": option label "${noneLabel}" is reserved and auto-appended by the tool.`)}let seenCiIds=new Set;for(let ci of parsed.clear_improvements){if(seenCiIds.has(ci.id))return validationError2(`Duplicate clear_improvements id: "${ci.id}"`);seenCiIds.add(ci.id)}let seenNfrCategories=new Set;for(let nfr of parsed.system_goals?.nfrs??[]){if(seenNfrCategories.has(nfr.category))return validationError2(`Duplicate system_goals.nfrs category: "${nfr.category}"`);seenNfrCategories.add(nfr.category)}let seenAcIds=new Set;for(let ac of parsed.system_goals?.acceptance_criteria??[]){if(seenAcIds.has(ac.id))return validationError2(`Duplicate system_goals.acceptance_criteria id: "${ac.id}"`);seenAcIds.add(ac.id)}let outputSubdir=parsed.output_subdir??"review",outputFilename=parsed.output_filename??`${parsed.ticket_key}-decisions.html`,outputTarget=await resolveDecisionPageOutputTarget(outputSubdir,outputFilename);if(!outputTarget.ok)return validationError2(outputTarget.message);let projectRootForAssets=await getProjectRoot(),pkgRoot=path52.resolve(path52.dirname(fileURLToPath4(import.meta.url)),"../"),assetsDir;try{await stat11(path52.join(projectRootForAssets,"design-assets")),assetsDir=path52.join(projectRootForAssets,"design-assets")}catch{assetsDir=path52.join(pkgRoot,"design-assets")}let faviconBase64="",logoBase64="";try{faviconBase64=(await readFile18(path52.join(assetsDir,"favicon","favicon-32x32.png"))).toString("base64")}catch{}try{logoBase64=(await readFile18(path52.join(assetsDir,"just-logo-rough-draft.png"))).toString("base64")}catch{}let docsPath=outputTarget.docsPath,filePath=outputTarget.filePath,html=generateDecisionPageHtml(parsed,{faviconBase64,logoBase64});return await mkdir15(docsPath,{recursive:!0}),await writeFile14(filePath,html,"utf-8"),{content:[{type:"text",text:JSON.stringify({status:"decision_page_generated",file_path:filePath,artifact_type:parsed.artifact_type,actionable_items_count:parsed.actionable_items.length,clear_improvements_count:parsed.clear_improvements.length,system_goals_nfr_count:parsed.system_goals?.nfrs?.length??0,system_goals_acceptance_criteria_count:parsed.system_goals?.acceptance_criteria?.length??0,implementation_order_count:parsed.implementation_order?.length??0})}]}});var updateStatusManager=createUpdateStatusManager({warn:message=>console.error(message),onLateStale:()=>{try{server.server.sendToolListChanged()}catch{}}});updateStatusManager.start();var toolSurfaceGate=null;if(TOOL_SURFACE_GATING_ENABLED&&toolSurfaceStartupProbe)try{let protocolServer=server.server,capturedOriginalListHandler=null,gate=createToolSurfaceGate({startupProbe:toolSurfaceStartupProbe,advertised:ADVERTISED,originalListHandler:(request,extra)=>capturedOriginalListHandler?capturedOriginalListHandler(request,extra):Promise.resolve({tools:[]}),freshProbe:()=>runToolSurfaceProbe(),notify:()=>server.server.sendToolListChanged(),logger:message=>console.error(message),lifecycleController:toolSurfaceLifecycle});capturedOriginalListHandler=installToolSurfaceListOverride(protocolServer,ListToolsRequestSchema,createUpdateAdvisoryListHandler(gate.handleList,()=>updateAdvisoryFor(updateStatusManager.getStatus()),()=>updateStatusManager.markListServed())),toolSurfaceGate=gate;let existingOnClose=server.server.onclose?.bind(server.server);server.server.onclose=()=>{try{gate.close()}finally{existingOnClose?.()}}}catch{toolSurfaceGate=null,toolSurfaceLifecycle.abort(),console.error("tool-surface gating: reason=disabled subtype=sdk-incompatible hidden=0 revision=n/a hidden_tools=[]")}else TOOL_SURFACE_GATING_ENABLED||console.error("tool-surface gating: reason=kill-switch subtype=n/a hidden=0 revision=n/a hidden_tools=[]");if(!toolSurfaceGate)try{let protocolServer=server.server,capturedOriginal=null,handler=createUpdateAdvisoryListHandler((request,extra)=>capturedOriginal?capturedOriginal(request,extra):Promise.resolve({tools:[]}),()=>updateAdvisoryFor(updateStatusManager.getStatus()),()=>updateStatusManager.markListServed());capturedOriginal=installToolSurfaceListOverride(protocolServer,ListToolsRequestSchema,handler)}catch{}var transport=new StdioServerTransport;console.error(`Bridge API MCP server ${VERSION} (commit ${BUILD_COMMIT}) starting on stdio, waiting for an MCP client. To set up a project, run: npx -y ${MCP_PACKAGE_NAME} install`);await server.connect(transport);serverConnected=!0;TOOL_SURFACE_POLL_ENABLED&&toolSurfaceGate?.startPolling();pruneStaleReviewWorkspaces().catch(()=>{});export{containsUnsafeEncodedPathToken,formatRecoverablePollGiveUp,formatTriggerConnectionFailure,isPlatformAbsolutePath,resolveDecisionPageOutputTarget,validateDecisionPageOutputFilename,validateDecisionPageOutputSubdir};
7948
+ `)}registerTool("request_council",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Use to start an async council that fans out a task to multiple opinion-provider LLMs. Returns a brainstorm_id immediately (or the full result envelope if wait_for_result is true). Use get_council to retrieve. Generates and persists a retrievable artifact.",inputSchema:{task_description:z18.string().describe("Free-form description of the task for the council to weigh in on. Sent verbatim \u2014 this tool does NOT read task_description from a file."),repo_name:commonFields.repo_name,ticket_number:commonFields.ticket_number.optional(),providers:z18.array(z18.string()).optional().describe("Opinion-provider LLMs. Defaults to ['openai', 'gemini']. A single-provider request runs one opinion provider and returns that provider's markdown directly."),concerns:z18.string().optional().describe("Optional caller-supplied concerns to surface to the council agents."),wait_for_result:commonFields.wait_for_result,save_locally:commonFields.save_locally,prior_brainstorm_id:z18.string().optional().describe("Optional brainstorm_id (the id field returned by an earlier council) to refine. When provided, the prior council's completed opinion-provider markdowns are concatenated and supplied as prior context."),mode:z18.enum(["technical","design","discovery","general"]).optional().describe("Preferred council-mode selector for new callers. 'technical' (default) is the implementation/architecture council; 'design' is web-page/UI visual-direction ideation; 'discovery' generates grouped discovery questions for early/vague tasks. 'general' convenes the council from the supplied brief alone, with no indexed repository context required, unlike 'technical'/'discovery'. Takes precedence over the legacy boolean design field."),design:z18.boolean().optional().describe('Legacy compatibility flag: set to true for web-page/UI design ideation focused on visual appeal and conversion. New callers should use mode: "design" instead. Omit this field when not requesting design mode; absent is treated as false.'),lenses:z18.array(z18.string()).optional().describe("Optional reasoning lenses (e.g. 'simplicity', 'robustness', 'blast-radius') assigned one per provider, applies to technical/design modes only. Omitting this defaults to an automatic Simplicity + Extensibility pair."),debate:z18.boolean().optional().describe("Opt-in second cross-examination round (default off; ignored in discovery mode). Each provider critiques the others' round-1 output, appended under '## Cross-examination'. Costs an extra round and measured no better than the default \u2014 prefer omitting it.")}},async({task_description,repo_name,ticket_number,providers,concerns,wait_for_result,save_locally,prior_brainstorm_id,mode,design,lenses,debate})=>{let effectiveRepo=repo_name&&repo_name.length>0?repo_name:REPO_NAME,effectiveProviders=providers!==void 0?providers:["openai","gemini"],shouldWait=wait_for_result===!0,shouldSave=save_locally!==!1,submitPayload={repo_name:effectiveRepo,task_description,providers:effectiveProviders};ticket_number&&(submitPayload.ticket_number=ticket_number),concerns&&(submitPayload.concerns=concerns),prior_brainstorm_id&&(submitPayload.prior_brainstorm_request_id=prior_brainstorm_id),mode&&(submitPayload.mode=mode),design&&(submitPayload.design=!0),lenses&&(submitPayload.lenses=lenses),debate&&(submitPayload.debate=!0);let submitResp;try{submitResp=await fetch(buildUrl("/brainstorms"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(submitPayload)})}catch{return{content:[{type:"text",text:formatTriggerConnectionFailure("the request_council tool")}]}}if(!submitResp.ok)return{content:[{type:"text",text:await handleResponse(submitResp)}]};let submitBody=await submitResp.json();if(!shouldWait)return{content:[{type:"text",text:`Council submitted (brainstorm_id: ${submitBody.brainstorm_id}). Providers: ${submitBody.providers.join(", ")}. Synthesis step: removed; provider opinions will be returned directly. Use get_council with brainstorm_id ${submitBody.brainstorm_id} to retrieve results.`}]};let pollOutcome=await pollBrainstormUntilTerminal(submitBody.brainstorm_id,effectiveRepo);if(pollOutcome.kind==="giveup")return{content:[{type:"text",text:pollOutcome.text}]};if(!pollOutcome.envelope)return{content:[{type:"text",text:`Council could not confirm terminal status (brainstorm_id: ${submitBody.brainstorm_id}). Use get_council later.`}]};let resultUrl=buildGetUrl(`/brainstorms/${submitBody.brainstorm_id}/result`,{repo_name:effectiveRepo}),resultResp=await fetch(resultUrl,{headers:await getGetHeaders()});if(!resultResp.ok)return{content:[{type:"text",text:await handleResponse(resultResp)}]};let envelope2=await resultResp.json(),savedPaths=[];return shouldSave&&(savedPaths=await saveBrainstormResultsLocally(envelope2,task_description)),{content:[{type:"text",text:formatBrainstormToolResponse(envelope2,savedPaths)}]}});registerTool("get_council",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Use to retrieve the result envelope for a previously submitted council by brainstorm_id. Returns opinion-provider rows only (including error_kind for each row). Does NOT start a new council \u2014 use request_council first if none exists. Returns not-found when still processing.",inputSchema:{brainstorm_id:z18.string().describe("The brainstorm_id (UUID) returned by request_council."),repo_name:commonFields.repo_name,save_locally:commonFields.save_locally}},async({brainstorm_id,repo_name,save_locally})=>{let effectiveRepo=repo_name&&repo_name.length>0?repo_name:REPO_NAME,shouldSave=save_locally!==!1,resultUrl=buildGetUrl(`/brainstorms/${brainstorm_id}/result`,{repo_name:effectiveRepo}),resultResp=await fetch(resultUrl,{headers:await getGetHeaders()});if(!resultResp.ok)return{content:[{type:"text",text:await handleResponse(resultResp)}]};let envelope2=await resultResp.json(),savedPaths=[];return shouldSave&&(savedPaths=await saveBrainstormResultsLocally(envelope2)),{content:[{type:"text",text:formatBrainstormToolResponse(envelope2,savedPaths)}]}});registerTool("create_pull_request",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Create a pull request on the configured VCS provider (GitHub or Bitbucket). Returns a structured response with {available, reason, action, detail}. If a PR already exists for the head branch, returns it with created=false. Capability issues (missing VCS config, API errors) return available=false, not errors. The repo_name is automatically injected from the configured environment.",inputSchema:{head_branch:z18.string().describe("The source branch name for the pull request"),base_branch:z18.string().describe("The target/destination branch name for the pull request"),title:z18.string().describe("The title of the pull request"),body:z18.string().optional().describe("The description/body of the pull request")}},async({head_branch,base_branch,title,body})=>{let payload={repo_name:REPO_NAME,head_branch,base_branch,title};body!==void 0&&(payload.body=body);let resp=await fetch(buildUrl("/vcs/pull-requests"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(payload)});return{content:[{type:"text",text:await handleResponse(resp)}]}});registerTool("merge_pull_request",{annotations:{readOnlyHint:!1,destructiveHint:!0,idempotentHint:!0,openWorldHint:!0},description:"Ask the server to merge a pull request; the SERVER decides the outcome. Only merged:true is success \u2014 dry_run, pending_approval, and lease_held are non-progress outcomes where nothing merged. Returns one JSON envelope: merged, outcome, reason, retry_hint, evaluated_head_sha, pr_number. repo_name is injected from the environment.",inputSchema:{pr_number:z18.number().int().positive().describe("Pull request number to merge."),expected_head_sha:z18.string().regex(/^[0-9a-fA-F]{40}$/).describe("Full 40-character head commit SHA the merge is bound to.")}},async args=>mergePullRequestHandler(mergePullRequestDeps,args));var resolveCiChecksTool=registerTool("resolve_ci_checks",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Discover and classify CI checks for the configured repository. Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), then uses Branch Protection API or LLM to determine which checks are required for merging. Results are cached per-project \u2014 subsequent calls return cached config unless force_rerun is true. Returns {available, reason, action, detail} envelope. The repo_name is automatically injected from the configured environment.",inputSchema:{commit_ref:z18.string().describe("Git commit SHA to discover checks for"),force_rerun:z18.boolean().optional().describe("Set to true to bypass cache and re-resolve CI checks")}},async({commit_ref,force_rerun})=>{let payload={repo_name:REPO_NAME,commit_ref};force_rerun!==void 0&&(payload.force_rerun=force_rerun);let resp=await fetch(buildUrl("/resolve-ci-checks"),{method:"POST",headers:await getPostHeaders(),body:JSON.stringify(payload)}),text4=await handleResponse(resp);try{JSON.parse(text4).available===!0&&pollCiChecksTool.enable()}catch{}return{content:[{type:"text",text:text4}]}}),pollCiChecksTool=registerTool("poll_ci_checks",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Poll the current status of CI checks for a specific commit. Requires that resolve_ci_checks has been called first to populate the check configuration. Returns per-check status, all_complete, all_passed, and unknown_checks fields. For failed checks with detail_level 'full', includes annotations and/or log tails. Returns {available, reason, action, detail} envelope. The repo_name is automatically injected from the configured environment.",inputSchema:{commit_ref:z18.string().describe("Git commit SHA to poll CI checks for")}},async({commit_ref})=>{let url=buildGetUrl("/poll-ci-checks",{repo_name:REPO_NAME,commit_ref}),resp=await fetch(url,{headers:await getGetHeaders()}),text4=await handleResponse(resp);try{let parsed=JSON.parse(text4);parsed!==null&&typeof parsed=="object"&&!("error"in parsed)&&(Array.isArray(parsed.checks)||typeof parsed.all_complete=="boolean")&&observePrCiFromPollResponse(commit_ref,parsed,{resolveRunId:resolveDispatchRunIdForBinding}).catch(()=>{})}catch{}return{content:[{type:"text",text:text4}]}});async function checkCiConfigAndDisablePoll(){try{let url=buildGetUrl("/config-field/ci_check_config",{repo_name:REPO_NAME}),resp=await fetch(url,{headers:await getGetHeaders()});if(resp.ok){let value=(await resp.json()).value;value==null&&(pollCiChecksTool.disable(),console.error("poll_ci_checks disabled: ci_check_config is null"))}else pollCiChecksTool.disable(),console.error("poll_ci_checks disabled: could not read ci_check_config")}catch(err){pollCiChecksTool.disable(),console.error(`poll_ci_checks disabled: ${err}`)}}await checkCiConfigAndDisablePoll();registerTool("get_docs_dir",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, which requires shell access and may be blocked on some AI coding platforms.",inputSchema:{}},async()=>({content:[{type:"text",text:await getDocsDir()}]}));async function buildPipelineOrchestratorDeps(){return await ensureCustomPipelinesLoaded(),{baseUrl:BASE_URL,apiKey:await getResolvedApiKey(),repoName:REPO_NAME,docsDir:await getDocsDir(),pipelines:PIPELINES2,instructions:INSTRUCTIONS2,toolHandlers:TOOL_HANDLERS,includeUpgradeAdviceSurfacing:UPGRADE_ADVICE_SURFACING_ENABLED}}async function buildChainOrchestratorDeps(){return await ensureCustomPipelinesLoaded(),{baseUrl:BASE_URL,apiKey:await getResolvedApiKey(),repoName:REPO_NAME,docsDir:await getDocsDir(),pipelines:PIPELINES2,chainRecipes:CHAIN_RECIPES,instructions:INSTRUCTIONS2,toolHandlers:TOOL_HANDLERS,includeUpgradeAdviceSurfacing:UPGRADE_ADVICE_SURFACING_ENABLED}}registerTool("get_pipeline_recipe",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction file references to inline content, and returns an ordered array of executable steps. Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). You execute the returned steps yourself (execution_mode: inline); no server-side orchestrator runs them. Use list_pipelines to discover available pipeline names first. Note: the 'docs_dir' variable is automatically set from BAPI_DOCS_DIR \u2014 callers should omit it.",inputSchema:{pipeline:z18.string().describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),variables:z18.record(z18.string(),z18.string()).optional().describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' })"),skip_steps:z18.array(z18.string()).optional().describe("Step tool names or descriptions to omit from the recipe"),auto_approve:z18.boolean().optional().describe("When true, auto-approve all approval-gated steps (skips the commit/push pause for implement-ticket; skips the HTML decision page for review-ticket, picking each item's recommended option). Pass via this top-level parameter."),rounds:z18.union([z18.literal(1),z18.literal(2)]).optional().describe("Round count (1|2); wins over adaptive routing. Omit for backend auto-routing.")}},async({pipeline:pipelineName,variables,skip_steps,auto_approve,rounds})=>{await ensureCustomPipelinesLoaded();let pipelineDef=PIPELINES2[pipelineName];if(!pipelineDef){let available=Object.keys(PIPELINES2).join(", ");return{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[404]??"NOT_FOUND",status:404,message:`Pipeline "${pipelineName}" not found. Available pipelines: ${available||"(none)"}`})}]}}if(variables&&"auto_approve"in variables)return{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[400]??"BAD_REQUEST",status:400,message:"Pass auto_approve via the top-level parameter, not via the variables map."})}]};try{let mergedVariables={docs_dir:await getDocsDir(),provider:"",rounds:"",second_opinion:"",auto_approve:auto_approve?"true":"",base_branch:"",base_sha:"",no_refresh_base:"",...variables??{}};"idea"in mergedVariables&&(mergedVariables.idea_hash=deriveIdeaHash(mergedVariables.idea)),(rounds===1||rounds===2)&&(mergedVariables.rounds=String(rounds));let effectiveSkipSteps=skip_steps?[...skip_steps]:[],recipe=resolveRecipe(pipelineDef,INSTRUCTIONS2,mergedVariables,effectiveSkipSteps,!!auto_approve,{includeUpgradeAdviceSurfacing:UPGRADE_ADVICE_SURFACING_ENABLED,executionMode:"inline"});return{content:[{type:"text",text:JSON.stringify(recipe,null,2)}]}}catch(err){let message=err instanceof Error?err.message:String(err),isServerError=message.includes("not found in bundled instructions"),status=isServerError?500:400,code=isServerError?"PIPELINE_DATA_ERROR":ERROR_CODES[400]??"BAD_REQUEST";return{content:[{type:"text",text:JSON.stringify({error:code,status,message})}]}}});async function fetchPlanMetadata(ticketKey){let resp=await fetch(buildGetUrl(`/ticket/${encodeURIComponent(ticketKey)}/plan`,{repo_name:REPO_NAME,include_metadata:"true"}),{headers:await getGetHeaders()});if(resp.status===202)throw new Error(`Plan generation for ${ticketKey} is still running; routing metadata is not available yet.`);if(!resp.ok)throw new Error(`Could not read plan routing metadata for ${ticketKey} (HTTP ${resp.status}).`);let body;try{body=await resp.json()}catch{throw new Error(`Plan routing metadata for ${ticketKey} was not valid JSON.`)}let envelope2=body??{},status=envelope2.plan_metadata_status;if(envelope2.plan_metadata===null||envelope2.plan_metadata===void 0)throw new Error(`Plan for ${ticketKey} carries no routing metadata (plan_metadata_status: ${String(status??"absent")}). Ownership must not be inferred from plan prose \u2014 regenerate the plan or execute every step.`);return envelope2.plan_metadata}function safeArtifactMessage(err){let raw=err instanceof Error?err.message:String(err);return redactSecrets(raw).replace(/(?:\/[^\s/:"']+){2,}/g,match=>`\u2026/${match.slice(match.lastIndexOf("/")+1)}`)}function artifactErrorEnvelope(err,status){let message=safeArtifactMessage(err);return console.error(`[phase-artifacts] ${message}`),{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[status]??"BAD_REQUEST",status,message})}]}}function artifactOkEnvelope(payload){return{content:[{type:"text",text:JSON.stringify(payload,null,2)}]}}registerTool("get_phase_context",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Inline pipeline phases only: return the step IDs this plan phase owns plus the durable ledger of what earlier phases already settled. Ownership is derived from the plan's provenance metadata, never from plan prose. Reading creates nothing.",inputSchema:{ticket_key:z18.string().describe("Ticket key, e.g. BAPI-123."),phase:z18.enum(PLAN_PHASES).describe("Which plan phase's routing to return.")}},async({ticket_key,phase})=>{try{let context=await loadInlinePhaseContext({docsDir:await getDocsDir(),ticketKey:ticket_key,phase,planMetadata:await fetchPlanMetadata(ticket_key)});return artifactOkEnvelope({status:"ok",...context})}catch(err){return artifactErrorEnvelope(err,400)}});registerTool("record_phase_result",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Inline pipeline phases only: validate a phase-result envelope against the steps the phase owns and persist it to the durable ledger. Rejects an envelope that leaves an owned step unsettled, reports a step it does not own, or walks a settled step back. Call this as the phase's final action, then continue with the next recipe step.",inputSchema:{ticket_key:z18.string().describe("Ticket key, e.g. BAPI-123."),phase_result:z18.record(z18.string(),z18.unknown()).describe("The bapi-phase-result envelope: {version, phase, records:[{stepId, provenanceClass, disposition, evidence}]}.")}},async({ticket_key,phase_result})=>{try{let result=await writeInlinePhaseResult({docsDir:await getDocsDir(),ticketKey:ticket_key,planMetadata:await fetchPlanMetadata(ticket_key),phaseResult:phase_result});return artifactOkEnvelope({status:"recorded",...result})}catch(err){return artifactErrorEnvelope(err,400)}});registerTool("record_checkpoint",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"Inline pipeline phases only: record the branch and SHA the produced work was pushed at. Accepted only when the push is proven durable on origin (pushed and remote_matches_head both true).",inputSchema:{ticket_key:z18.string().describe("Ticket key, e.g. BAPI-123."),branch:z18.string().describe("The branch that was pushed."),sha:z18.string().describe("The pushed HEAD SHA."),pushed:z18.boolean().describe("True only when the push succeeded."),remote_matches_head:z18.boolean().describe("True only when the remote tip equals the local HEAD.")}},async({ticket_key,branch,sha,pushed,remote_matches_head})=>{try{let result=await writeInlineCheckpoint({docsDir:await getDocsDir(),ticketKey:ticket_key,checkpoint:{branch,sha,pushed,remoteMatchesHead:remote_matches_head}});return artifactOkEnvelope({status:"recorded",...result})}catch(err){return artifactErrorEnvelope(err,400)}});var REVIEW_WORKSPACE_PREFIX="bridge-review-",REVIEW_WORKSPACE_TTL_MS=1440*60*1e3;async function pruneStaleReviewWorkspaces(){let tmpDir=os23.tmpdir(),entries;try{entries=await readdir6(tmpDir)}catch{return}let now=Date.now();for(let entry of entries){if(!entry.startsWith(REVIEW_WORKSPACE_PREFIX))continue;let fullPath=path52.join(tmpDir,entry);try{let info=await stat11(fullPath);now-info.mtimeMs>REVIEW_WORKSPACE_TTL_MS&&await rm7(fullPath,{recursive:!0,force:!0})}catch{}}}registerTool("materialize_fresh_base",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:'Materialize a pinned origin/<base_branch> tree via git archive into a unique temp dir, without mutating the working tree, index, stash, or branches. Returns { base_sha, fresh_base_root }. base_branch precedence: param > config > "main". no_refresh_base: "true" skips the fetch, returning the local project root with base_sha "local-stale".',inputSchema:{base_branch:z18.string().optional().describe(`Branch to fetch and materialize from origin. Defaults to the 'base_branch' config field, else "main".`),base_sha:z18.string().optional().describe("Pre-resolved commit SHA to materialize (skips the fetch+resolve step, e.g. a batch-pinned SHA from review-tickets)."),no_refresh_base:z18.string().optional().describe('Pass "true" to skip fetch/materialization and fall back to the local project root as-is.')}},async({base_branch,base_sha,no_refresh_base})=>{let projectRoot=await getProjectRoot();if(no_refresh_base==="true")return{content:[{type:"text",text:JSON.stringify({base_sha:"local-stale",fresh_base_root:projectRoot})}]};let startTicketsDeps={...createDefaultStartTicketsDeps(),cwd:projectRoot},resolvedBaseSha=(base_sha??"").trim(),effectiveBaseBranch=(base_branch??"").trim();if(effectiveBaseBranch.length===0)try{let access2={repoName:REPO_NAME,apiKey:await getResolvedApiKey(),baseUrl:BASE_URL},configValue=await fetchStartTicketsConfigField(access2,BASE_BRANCH_CONFIG_FIELD);typeof configValue=="string"&&configValue.trim().length>0&&(effectiveBaseBranch=configValue.trim())}catch{}effectiveBaseBranch.length===0&&(effectiveBaseBranch="main");let branchError=validateBranchName(effectiveBaseBranch);if(branchError)return{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[400]??"BAD_REQUEST",status:400,message:`Invalid base_branch '${effectiveBaseBranch}': ${branchError}`})}]};if(resolvedBaseSha.length===0){let fetchResult=await fetchAndResolveBaseSha(startTicketsDeps,effectiveBaseBranch);if(!fetchResult.ok)return{content:[{type:"text",text:JSON.stringify({error:"FETCH_FAILED",status:502,message:fetchResult.error,base_branch:effectiveBaseBranch})}]};resolvedBaseSha=fetchResult.base_sha}else if(!/^[0-9a-f]{7,40}$/i.test(resolvedBaseSha))return{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[400]??"BAD_REQUEST",status:400,message:`Invalid base_sha '${resolvedBaseSha}': must be a hex commit SHA.`})}]};let tempDir;try{tempDir=await mkdtemp4(path52.join(os23.tmpdir(),REVIEW_WORKSPACE_PREFIX))}catch(err){let message=err instanceof Error?err.message:String(err);return{content:[{type:"text",text:JSON.stringify({error:"TEMP_DIR_FAILED",status:500,message:`Failed to create a review workspace temp directory: ${message}`})}]}}let archivePath=path52.join(tempDir,"archive.tar"),archiveResult=await startTicketsDeps.runCommand("git",["archive","--format=tar",resolvedBaseSha,"-o",archivePath],{cwd:projectRoot});if(archiveResult.exitCode!==0)return await rm7(tempDir,{recursive:!0,force:!0}).catch(()=>{}),{content:[{type:"text",text:JSON.stringify({error:"ARCHIVE_FAILED",status:500,message:`git archive of ${resolvedBaseSha} failed: ${archiveResult.stderr||archiveResult.stdout}`,base_sha:resolvedBaseSha})}]};let extractResult=await startTicketsDeps.runCommand("tar",["-xf",archivePath],{cwd:tempDir});return await unlink4(archivePath).catch(()=>{}),extractResult.exitCode!==0?(await rm7(tempDir,{recursive:!0,force:!0}).catch(()=>{}),{content:[{type:"text",text:JSON.stringify({error:"EXTRACT_FAILED",status:500,message:`tar extraction of the archived base tree failed: ${extractResult.stderr||extractResult.stdout}`,base_sha:resolvedBaseSha})}]}):{content:[{type:"text",text:JSON.stringify({base_sha:resolvedBaseSha,base_branch:effectiveBaseBranch,fresh_base_root:tempDir})}]}});registerTool("cleanup_fresh_base",{annotations:{readOnlyHint:!1,destructiveHint:!0,idempotentHint:!0,openWorldHint:!1},description:"Remove a review workspace temp directory previously returned by materialize_fresh_base. Strictly namespace-scoped: refuses to delete any path outside the OS temp dir's 'bridge-review-' prefix.",inputSchema:{fresh_base_root:z18.string().describe("The fresh_base_root path returned by a prior materialize_fresh_base call.")}},async({fresh_base_root})=>{let allowedPrefix=path52.join(os23.tmpdir(),REVIEW_WORKSPACE_PREFIX),resolvedTarget=path52.resolve(fresh_base_root),resolvedTmpDir=path52.resolve(os23.tmpdir()),isDirectChildOfTmpDir=path52.dirname(resolvedTarget)===resolvedTmpDir,hasReviewPrefix=path52.basename(resolvedTarget).startsWith(REVIEW_WORKSPACE_PREFIX);return!isDirectChildOfTmpDir||!hasReviewPrefix?{content:[{type:"text",text:JSON.stringify({error:ERROR_CODES[400]??"BAD_REQUEST",status:400,message:`Refusing to delete '${fresh_base_root}': it is outside the review workspace namespace ('${allowedPrefix}*').`})}]}:(await rm7(resolvedTarget,{recursive:!0,force:!0}),{content:[{type:"text",text:JSON.stringify({status:"ok",message:`Removed review workspace at ${fresh_base_root}.`})}]})});ACTIVE_GROUPS.has("pipeline-authoring")&&(registerTool("list_pipelines",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1},description:"List all available pipeline recipes with their names, descriptions, and required variables. No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",inputSchema:{}},async()=>{await ensureCustomPipelinesLoaded();let list=Object.entries(PIPELINES2).map(([key,pipeline])=>({name:key,description:pipeline.description??"",variables:(pipeline.variables??[]).filter(v=>v!=="docs_dir"&&v!=="idea_hash"),source:userPipelineKeys.has(key)?"user":"bundled"}));return{content:[{type:"text",text:JSON.stringify(list,null,2)}]}}),registerTool("run_pipeline",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, dispatching mcp_call steps in-process and pausing on agent_task steps with a needs_agent_task envelope. Returns a unified envelope keyed on `status`: `completed` (terminal success with `results`), `needs_agent_task` (pause \u2014 read `instruction`, perform the task, then call `resume_pipeline` with the resulting string as `agent_result`), or `failed` (terminal error \u2014 check `error_code`: VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). Paused runs auto-expire after an idle TTL (default 24 hours; override with `ttl_seconds`). The TTL is reset on every state transition.\n\nCall list_pipelines for the current resolved catalog of available pipeline names (bundled plus any custom user pipelines).",inputSchema:{pipeline:z18.string().describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),variables:z18.record(z18.string(),z18.string()).optional().describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' }). Do NOT pass `auto_approve` here \u2014 use the top-level parameter."),auto_approve:z18.union([z18.boolean(),z18.literal("true"),z18.literal("false")]).optional().describe("When true, approval-gated mcp_call steps execute directly. When false or omitted, the orchestrator synthesises a needs_agent_task pause so the agent can confirm with the user before resuming. Accepts boolean or 'true'/'false' strings for MCP clients that serialize booleans as strings."),ttl_seconds:z18.number().int().positive().optional().describe("Override the default 24-hour idle TTL for this run. Must be a positive integer.")}},async input=>{let result=await runPipeline(await buildPipelineOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}}),registerTool("resume_pipeline",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Resume a paused pipeline run with the result of the agent_task. Provide the `pipeline_run_id` returned by the prior needs_agent_task envelope, and the string the instruction's `## Return` section asked you to produce as `agent_result`. `agent_result` is always a string \u2014 do not wrap it in JSON unless the instruction explicitly asked you to serialize structured output. Returns the same unified envelope shape as `run_pipeline`.",inputSchema:{pipeline_run_id:z18.string().describe("The pipeline_run_id returned by a prior needs_agent_task envelope"),agent_result:z18.string().describe("The string the paused instruction's ## Return section asked you to produce")}},async input=>{let result=await resumePipeline(await buildPipelineOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}}),registerTool("list_pipeline_runs",{annotations:{readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"List recent pipeline runs for the configured repository, newest first. Returns metadata only \u2014 `resolved_recipe`, resolved params, instruction text, results, and agent outputs are intentionally excluded. Use this to recover a `pipeline_run_id` when an earlier needs_agent_task envelope is no longer in scope (e.g. after compaction or a client restart). Optionally filter by `status`: running | paused | completed | failed | expired.",inputSchema:{status:z18.enum(["running","paused","completed","failed","expired"]).optional().describe("Optional status filter")}},async input=>{let result=await listPipelineRuns(await buildPipelineOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}}),registerTool("delete_pipeline_run",{annotations:{readOnlyHint:!1,destructiveHint:!0,idempotentHint:!0,openWorldHint:!0},description:"Delete a pipeline run row (any status). Use this to discard orphaned `running` rows from a previous session that can't be resumed (resume_pipeline only accepts `paused`), to clean up after a failed run, or to remove a no-longer-needed paused session. Returns `{ status: 'completed', deleted: true, pipeline_run_id }` on success, or a `failed` envelope with error_code in (VALIDATION | NOT_FOUND | REPO_MISMATCH | TOOL_ERROR). Repo-scoped: the row's stored repo_name must match the caller's repo.",inputSchema:{pipeline_run_id:z18.string().describe("UUID of the pipeline run to delete.")}},async input=>{let result=await deletePipelineRun(await buildPipelineOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}}));registerTool("run_full_automation",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Run the full-automation chain for an idea: create ticket(s) (idea-to-ticket), review each created ticket (review-ticket fan-out), then emit the exact `/start-tickets ...` command for you to invoke in this same session. Returns the chain envelope keyed on `status`: `needs_agent_task` (perform the `next_action.instruction`, then call `resume_full_automation` with the result as `agent_result`), `completed`, or `failed` (check `error_code`: VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). Provide the idea via `idea` or `idea_file` (mutually exclusive).",inputSchema:{idea:z18.string().optional(),idea_file:z18.string().optional(),auto_approve:z18.union([z18.boolean(),z18.literal("true"),z18.literal("false")]).optional(),scheduled_at:z18.string().optional(),max_children:z18.number().int().positive().optional(),allow_duplicate:z18.boolean().optional(),agent:z18.enum(["claude"]).optional(),ttl_seconds:z18.number().int().positive().optional()}},async input=>{let{idea,idea_file,...rest}=input;if(idea!==void 0&&idea_file!==void 0)return{content:[{type:"text",text:JSON.stringify({error:"BAD_REQUEST",status:400,message:"Provide exactly one of `idea` or `idea_file`, not both."})}]};let resolved=await resolveTextOrFile(idea,idea_file,"idea");if(!resolved.ok)return resolved.errorResponse;let result=await runFullAutomation(await buildChainOrchestratorDeps(),{idea:resolved.text,...rest});return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}});registerTool("resume_full_automation",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1,openWorldHint:!0},description:"Resume a paused full-automation chain run. Provide the `chain_run_id` returned by the prior needs_agent_task envelope and the string the instruction asked you to produce as `agent_result`. Returns the same chain envelope shape as `run_full_automation`.",inputSchema:{chain_run_id:z18.string(),agent_result:z18.string()}},async input=>{let result=await resumeFullAutomation(await buildChainOrchestratorDeps(),input);return{content:[{type:"text",text:JSON.stringify(result,null,2)}]}});function containsUnsafeEncodedPathToken(value){return/%2e/i.test(value)||/%2f/i.test(value)||/%5c/i.test(value)}function isPlatformAbsolutePath(value){return path52.posix.isAbsolute(value)||path52.win32.isAbsolute(value)||path52.isAbsolute(value)}function validateDecisionPageOutputSubdir(value){return value.trim().length===0?"Invalid output_subdir: must not be empty or whitespace-only.":value.includes("\0")?"Invalid output_subdir: must not contain null bytes.":containsUnsafeEncodedPathToken(value)?`Invalid output_subdir "${value}": must not contain encoded path tokens (%2e, %2f, %5c).`:isPlatformAbsolutePath(value)?`Invalid output_subdir "${value}": must be a relative path, not an absolute path.`:value.includes("\\")?`Invalid output_subdir "${value}": backslashes are not allowed; use "/" to separate nested directories.`:value.split(/[/\\]/).some(segment=>segment==="..")?`Invalid output_subdir "${value}": must not contain ".." path segments.`:null}function validateDecisionPageOutputFilename(value){return value.trim().length===0?"Invalid output_filename: must not be empty or whitespace-only.":value.includes("\0")?"Invalid output_filename: must not contain null bytes.":containsUnsafeEncodedPathToken(value)?`Invalid output_filename "${value}": must not contain encoded path tokens (%2e, %2f, %5c).`:value.includes("/")||value.includes("\\")?`Invalid output_filename "${value}": must not contain path separators.`:value==="."||value===".."?`Invalid output_filename "${value}": must be a real filename, not "." or "..".`:value.endsWith(".html")?null:`Invalid output_filename "${value}": must end with the ".html" suffix.`}async function resolveDecisionPageOutputTarget(outputSubdir,outputFilename){let subdirError=validateDecisionPageOutputSubdir(outputSubdir);if(subdirError)return{ok:!1,message:subdirError};let filenameError=validateDecisionPageOutputFilename(outputFilename);if(filenameError)return{ok:!1,message:filenameError};let docsBase=path52.resolve(await getDocsDir()),resolvedTarget=path52.resolve(docsBase,outputSubdir,outputFilename);return resolvedTarget.startsWith(docsBase+path52.sep)?{ok:!0,docsPath:path52.dirname(resolvedTarget),filePath:resolvedTarget}:{ok:!1,message:"Invalid output target: the resolved output path must stay under the docs directory."}}var DECISION_PAGE_CONTENT_CONTRACT="Expected shape: content.actionable_items[n] must have id, question, why_it_matters, recommendation_explanation, options (2-4 strings), option_consequences (same length as options), recommendation_index (0-based within options).",DECISION_PAGE_CONTENT_EXAMPLE='{"ticket_key":"BAPI-123","content":{"actionable_items":[{"id":"D-1","question":"Which approach?","why_it_matters":"Affects performance.","recommendation_explanation":"Option A is safer.","options":["A","B"],"option_consequences":["Safe path.","Risky path."],"recommendation_index":0}]}}';function formatDecisionPageValidationError(err){let first=err.issues[0],pathStr=first?.path?.length?first.path.join("."):"(root)",msg=first?.message??"Unknown validation error";return`Validation error at "${pathStr}": ${msg}. ${DECISION_PAGE_CONTENT_CONTRACT} Example: ${DECISION_PAGE_CONTENT_EXAMPLE}`}registerTool("generate_decision_page",{annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0},description:"Use to generate a local, review-shaped HTML decision page for capturing user decisions. Returns the local file path and a summary of the rendered items.",inputSchema:DecisionPageLeanInputShape},async input=>{let validationError2=message=>({content:[{type:"text",text:JSON.stringify({error:"VALIDATION_ERROR",status:400,message})}]});if(!/^[A-Za-z][A-Za-z0-9_-]*$/.test(input.ticket_key))return validationError2(`Invalid ticket_key "${input.ticket_key}": must start with a letter and contain only letters, digits, hyphens, or underscores.`);if(input.content===void 0)return validationError2(`No \`content\` supplied. All decision data must be nested under \`content\` \u2014 root-level actionable_items / system_goals / clear_improvements / implementation_order are dropped by the lean input schema. ${DECISION_PAGE_CONTENT_CONTRACT} Example: ${DECISION_PAGE_CONTENT_EXAMPLE}`);let rawPayload={...input.content||{},ticket_key:input.ticket_key,artifact_type:input.artifact_type,output_subdir:input.output_subdir,output_filename:input.output_filename,labels:input.labels},parsed;try{parsed=DecisionPageInputSchema.parse(rawPayload)}catch(err){if(err instanceof z18.ZodError)return validationError2(formatDecisionPageValidationError(err));throw err}let hasPlanningContent=parsed.system_goals!==void 0||(parsed.implementation_order?.length??0)>0;if(parsed.actionable_items.length===0&&!hasPlanningContent)return{content:[{type:"text",text:JSON.stringify({status:"no_decisions_needed",ticket_key:parsed.ticket_key,clear_improvements_count:parsed.clear_improvements.length})}]};let seenIds=new Set;for(let item of parsed.actionable_items){if(seenIds.has(item.id))return validationError2(`Duplicate actionable_items id: "${item.id}"`);seenIds.add(item.id);let noneLabel=item.options.find(label=>label.toLowerCase()==="none of these");if(noneLabel)return validationError2(`Item "${item.id}": option label "${noneLabel}" is reserved and auto-appended by the tool.`)}let seenCiIds=new Set;for(let ci of parsed.clear_improvements){if(seenCiIds.has(ci.id))return validationError2(`Duplicate clear_improvements id: "${ci.id}"`);seenCiIds.add(ci.id)}let seenNfrCategories=new Set;for(let nfr of parsed.system_goals?.nfrs??[]){if(seenNfrCategories.has(nfr.category))return validationError2(`Duplicate system_goals.nfrs category: "${nfr.category}"`);seenNfrCategories.add(nfr.category)}let seenAcIds=new Set;for(let ac of parsed.system_goals?.acceptance_criteria??[]){if(seenAcIds.has(ac.id))return validationError2(`Duplicate system_goals.acceptance_criteria id: "${ac.id}"`);seenAcIds.add(ac.id)}let outputSubdir=parsed.output_subdir??"review",outputFilename=parsed.output_filename??`${parsed.ticket_key}-decisions.html`,outputTarget=await resolveDecisionPageOutputTarget(outputSubdir,outputFilename);if(!outputTarget.ok)return validationError2(outputTarget.message);let projectRootForAssets=await getProjectRoot(),pkgRoot=path52.resolve(path52.dirname(fileURLToPath4(import.meta.url)),"../"),assetsDir;try{await stat11(path52.join(projectRootForAssets,"design-assets")),assetsDir=path52.join(projectRootForAssets,"design-assets")}catch{assetsDir=path52.join(pkgRoot,"design-assets")}let faviconBase64="",logoBase64="";try{faviconBase64=(await readFile18(path52.join(assetsDir,"favicon","favicon-32x32.png"))).toString("base64")}catch{}try{logoBase64=(await readFile18(path52.join(assetsDir,"just-logo-rough-draft.png"))).toString("base64")}catch{}let docsPath=outputTarget.docsPath,filePath=outputTarget.filePath,html=generateDecisionPageHtml(parsed,{faviconBase64,logoBase64});return await mkdir15(docsPath,{recursive:!0}),await writeFile14(filePath,html,"utf-8"),{content:[{type:"text",text:JSON.stringify({status:"decision_page_generated",file_path:filePath,artifact_type:parsed.artifact_type,actionable_items_count:parsed.actionable_items.length,clear_improvements_count:parsed.clear_improvements.length,system_goals_nfr_count:parsed.system_goals?.nfrs?.length??0,system_goals_acceptance_criteria_count:parsed.system_goals?.acceptance_criteria?.length??0,implementation_order_count:parsed.implementation_order?.length??0})}]}});var updateStatusManager=createUpdateStatusManager({warn:message=>console.error(message),onLateStale:()=>{try{server.server.sendToolListChanged()}catch{}},enabled:UPDATE_CHECK_ENABLED});UPDATE_CHECK_ENABLED&&updateStatusManager.start();var toolSurfaceGate=null;if(TOOL_SURFACE_GATING_ENABLED&&toolSurfaceStartupProbe)try{let protocolServer=server.server,capturedOriginalListHandler=null,gate=createToolSurfaceGate({startupProbe:toolSurfaceStartupProbe,advertised:ADVERTISED,originalListHandler:(request,extra)=>capturedOriginalListHandler?capturedOriginalListHandler(request,extra):Promise.resolve({tools:[]}),freshProbe:()=>runToolSurfaceProbe(),notify:()=>server.server.sendToolListChanged(),logger:message=>console.error(message),lifecycleController:toolSurfaceLifecycle});capturedOriginalListHandler=installToolSurfaceListOverride(protocolServer,ListToolsRequestSchema,createUpdateAdvisoryListHandler(gate.handleList,()=>updateAdvisoryFor(updateStatusManager.getStatus()),()=>updateStatusManager.markListServed())),toolSurfaceGate=gate;let existingOnClose=server.server.onclose?.bind(server.server);server.server.onclose=()=>{try{gate.close()}finally{existingOnClose?.()}}}catch{toolSurfaceGate=null,toolSurfaceLifecycle.abort(),console.error("tool-surface gating: reason=disabled subtype=sdk-incompatible hidden=0 revision=n/a hidden_tools=[]")}else TOOL_SURFACE_GATING_ENABLED||console.error("tool-surface gating: reason=kill-switch subtype=n/a hidden=0 revision=n/a hidden_tools=[]");if(!toolSurfaceGate)try{let protocolServer=server.server,capturedOriginal=null,handler=createUpdateAdvisoryListHandler((request,extra)=>capturedOriginal?capturedOriginal(request,extra):Promise.resolve({tools:[]}),()=>updateAdvisoryFor(updateStatusManager.getStatus()),()=>updateStatusManager.markListServed());capturedOriginal=installToolSurfaceListOverride(protocolServer,ListToolsRequestSchema,handler)}catch{}var transport=new StdioServerTransport;console.error(`Bridge API MCP server ${VERSION} (commit ${BUILD_COMMIT}) starting on stdio, waiting for an MCP client. To set up a project, run: npx -y ${MCP_PACKAGE_NAME} install`);await server.connect(transport);serverConnected=!0;TOOL_SURFACE_POLL_ENABLED&&toolSurfaceGate?.startPolling();pruneStaleReviewWorkspaces().catch(()=>{});export{containsUnsafeEncodedPathToken,formatRecoverablePollGiveUp,formatTriggerConnectionFailure,isPlatformAbsolutePath,resolveDecisionPageOutputTarget,validateDecisionPageOutputFilename,validateDecisionPageOutputSubdir};