@bridge_gpt/mcp-server 0.2.49 → 0.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +24 -7
  2. package/build/base-ref.js +28 -3
  3. package/build/claude-review-workflow-drift-probe.js +130 -0
  4. package/build/claude-review-workflow-drift.js +173 -0
  5. package/build/claude-review-workflow.js +81 -16
  6. package/build/commands.generated.js +5 -5
  7. package/build/conductor/done-gate.js +25 -3
  8. package/build/conductor/install-doctor.js +65 -5
  9. package/build/conductor/latest-check-selector.js +170 -0
  10. package/build/conductor/local-merge.js +8 -6
  11. package/build/conductor-bin.js +1 -1
  12. package/build/{brainstorm-files.js → council-files.js} +15 -15
  13. package/build/decision-page-schema.js +1 -1
  14. package/build/docs.generated.js +1 -1
  15. package/build/doctor.js +162 -4
  16. package/build/executor/worktree.js +46 -1
  17. package/build/index.js +92 -51
  18. package/build/init.js +9 -2
  19. package/build/install-bridge.js +60 -2
  20. package/build/install-reexec.js +47 -9
  21. package/build/pipelines.generated.js +1 -1
  22. package/build/plane/cli.js +12 -2
  23. package/build/plane/manifest.js +25 -1
  24. package/build/plane/member-roster.js +61 -7
  25. package/build/plane/preflight.js +24 -9
  26. package/build/plane/supervisor.js +77 -5
  27. package/build/plane/types.js +23 -3
  28. package/build/readme.generated.js +1 -1
  29. package/build/run-unit-tests-launcher.js +2 -1
  30. package/build/stale-worktree-doctor.js +120 -0
  31. package/build/start-tickets-prereqs.js +70 -0
  32. package/build/start-tickets.js +91 -3
  33. package/build/version.generated.js +3 -2
  34. package/package.json +4 -2
  35. package/build/chain-orchestrator.js +0 -1457
  36. package/build/chain-utils.js +0 -68
  37. package/build/command-catalog.js +0 -376
  38. package/build/schedule-run.js +0 -1300
  39. package/build/schedule-store.js +0 -172
  40. package/build/scheduled-prompt.js +0 -115
  41. package/build/scheduler-backends/at-fallback.js +0 -139
  42. package/build/scheduler-backends/escaping.js +0 -143
  43. package/build/scheduler-backends/index.js +0 -72
  44. package/build/scheduler-backends/launchd.js +0 -225
  45. package/build/scheduler-backends/systemd-user.js +0 -250
  46. package/build/scheduler-backends/task-scheduler.js +0 -214
  47. package/build/scheduler-backends/types.js +0 -23
package/README.md CHANGED
@@ -343,6 +343,7 @@ package to an exact version and pass `--prefer-offline`, which is what the
343
343
  generated entries do and what keeps npx from resolving a different build on some
344
344
  later boot.
345
345
 
346
+ <!-- BEGIN GENERATED: mcp-host-examples (managed by mcp_server/scripts/sync-readme-host-examples.js — DO NOT EDIT BY HAND) -->
346
347
  <details>
347
348
  <summary><strong>Claude Code (.mcp.json)</strong></summary>
348
349
 
@@ -351,7 +352,7 @@ later boot.
351
352
  "mcpServers": {
352
353
  "bridge": {
353
354
  "command": "npx",
354
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.49", "serve"],
355
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.50", "serve"],
355
356
  "env": {
356
357
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
357
358
  "BAPI_REPO_NAME": "your-repo",
@@ -373,7 +374,7 @@ later boot.
373
374
  "bridge": {
374
375
  "type": "stdio",
375
376
  "command": "npx",
376
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.49", "serve"],
377
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.50", "serve"],
377
378
  "env": {
378
379
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
379
380
  "BAPI_REPO_NAME": "your-repo",
@@ -395,7 +396,7 @@ later boot.
395
396
  "bridge": {
396
397
  "type": "stdio",
397
398
  "command": "npx",
398
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.49", "serve"],
399
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.50", "serve"],
399
400
  "env": {
400
401
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
401
402
  "BAPI_REPO_NAME": "your-repo",
@@ -420,7 +421,7 @@ you select `copilot-cli`; the shape below is what it produces.
420
421
  "bridge": {
421
422
  "type": "local",
422
423
  "command": "npx",
423
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.49", "serve"],
424
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.50", "serve"],
424
425
  "tools": ["*"],
425
426
  "env": {
426
427
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
@@ -444,7 +445,7 @@ Windsurf only supports global MCP configuration.
444
445
  "mcpServers": {
445
446
  "bridge": {
446
447
  "command": "npx",
447
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.49", "serve"],
448
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.50", "serve"],
448
449
  "env": {
449
450
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
450
451
  "BAPI_REPO_NAME": "your-repo",
@@ -463,7 +464,7 @@ Windsurf only supports global MCP configuration.
463
464
  ```toml
464
465
  [mcp_servers.bridge]
465
466
  command = "npx"
466
- args = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.49", "serve"]
467
+ args = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.50", "serve"]
467
468
 
468
469
  [mcp_servers.bridge.env]
469
470
  BAPI_BASE_URL = "https://bridgegpt-api.com"
@@ -474,6 +475,7 @@ BAPI_DOCS_DIR = "docs/tmp"
474
475
 
475
476
  > Codex users: set `BAPI_PROJECT_ROOT` manually in your config (see [Environment Variables](#environment-variables)).
476
477
  </details>
478
+ <!-- END GENERATED: mcp-host-examples -->
477
479
 
478
480
  After saving, reload your editor and ask your assistant to call the `ping` tool to
479
481
  confirm the connection.
@@ -768,7 +770,6 @@ Repository and ticket housekeeping commands, beyond the tiers above. Implementat
768
770
  | Command | What it does |
769
771
  |---|---|
770
772
  | `/parse-repository` | Queue a background job to index the repository for Bridge AI agents |
771
- | `/check-parse-status` | Check whether a background repository parse job is still running |
772
773
  | `/scan-tickets` | Sync recently-updated Jira tickets and backfill workflow timestamps |
773
774
 
774
775
  > Commands are designed for Claude Code. Other editors may support slash commands differently — check your editor's documentation for how to invoke prompt files.
@@ -1022,6 +1023,22 @@ authenticate. See
1022
1023
 
1023
1024
  It 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.
1024
1025
 
1026
+ **`claude-review` workflow lineage (advisory, read-only).** `doctor` also reports
1027
+ whether the current branch's `.github/workflows/claude-review.yml` matches the
1028
+ repository default branch's copy. This matters because
1029
+ `anthropics/claude-code-action` refuses to run when the two differ — so a
1030
+ long-lived base branch that has fallen behind silently blinds automated review for
1031
+ **every pull request based on it**, even though those pull requests change no
1032
+ workflow file at all. The probe runs `git symbolic-ref`, `git rev-parse`, and two
1033
+ `git show` reads against the local object database: it **fetches nothing, spawns
1034
+ nothing, and creates no worktree**, and an unresolvable ref simply reports the
1035
+ comparison as unavailable. It is **advisory only** — it is registered as a
1036
+ doctor-only diagnostic and **never changes `doctor`'s required-prerequisite exit
1037
+ code**, whether it finds drift or cannot complete the comparison. Spawning a
1038
+ worker against a stale base prints the same advisory and **still spawns the
1039
+ worker**. Remedy and full diagnosis:
1040
+ `docs/claude/runbooks/claude-review-base-branch-drift.md`.
1041
+
1025
1042
  ### `drive-epic`
1026
1043
 
1027
1044
  The one conductor entry point every Bridge surface names. Give it an epic key and
package/build/base-ref.js CHANGED
@@ -23,6 +23,7 @@
23
23
  * acyclic (mirrors the existing `worktree-core.ts` / prereqs pattern).
24
24
  */
25
25
  import path from "path";
26
+ import { resolveRepositoryDefaultBranch } from "./claude-review-workflow-drift-probe.js";
26
27
  import { commandSucceeded } from "./start-tickets-prereqs.js";
27
28
  /**
28
29
  * Returns an error string for an unsafe branch name, or null when valid.
@@ -55,6 +56,16 @@ export function validateBranchName(branch) {
55
56
  return null;
56
57
  }
57
58
  // ---------------------------------------------------------------------------
59
+ // `claude-review` workflow drift evidence (BAPI-941)
60
+ // ---------------------------------------------------------------------------
61
+ // The git-backed acquisition lives in the LEAF `claude-review-workflow-drift-probe`
62
+ // module, not here: `doctor`'s read-only probe in `start-tickets-prereqs.ts` needs
63
+ // the same functions, and this module already imports `commandSucceeded` FROM
64
+ // that module — so defining them here would close a cycle in a runtime graph both
65
+ // modules document as acyclic. They are re-exported so `fetchAndResolveBaseSha`'s
66
+ // callers keep one import site.
67
+ export { resolveRepositoryDefaultBranch, probeClaudeReviewWorkflowDrift, } from "./claude-review-workflow-drift-probe.js";
68
+ // ---------------------------------------------------------------------------
58
69
  // Per-repository fetch serialization
59
70
  // ---------------------------------------------------------------------------
60
71
  /**
@@ -130,9 +141,23 @@ export async function fetchAndResolveBaseSha(deps, baseBranch) {
130
141
  }
131
142
  const repoKey = normalizeRepoKey(deps.cwd);
132
143
  return withRepoFetchLock(repoKey, async () => {
133
- const fetch = await deps.runCommand("git", ["fetch", "origin", baseBranch], {
134
- cwd: deps.cwd,
135
- });
144
+ // BAPI-941: refresh the DEFAULT branch in the same invocation whenever it
145
+ // differs from the base, so the caller's drift advisory costs no additional
146
+ // network round trip. Deduplicated when the base IS the default branch.
147
+ const defaultBranch = await resolveRepositoryDefaultBranch(deps);
148
+ const extraRefs = defaultBranch && defaultBranch !== baseBranch && validateBranchName(defaultBranch) === null
149
+ ? [defaultBranch]
150
+ : [];
151
+ let fetch = await deps.runCommand("git", ["fetch", "origin", baseBranch, ...extraRefs], { cwd: deps.cwd });
152
+ if (!commandSucceeded(fetch) && extraRefs.length > 0) {
153
+ // The advisory's extra refspec must never turn a resolvable base into a
154
+ // failure — a renamed or deleted default branch on the remote would do
155
+ // exactly that. Retry the historical single-ref fetch and keep the
156
+ // original success semantics; the drift check simply goes unverified.
157
+ fetch = await deps.runCommand("git", ["fetch", "origin", baseBranch], {
158
+ cwd: deps.cwd,
159
+ });
160
+ }
136
161
  if (!commandSucceeded(fetch)) {
137
162
  return {
138
163
  ok: false,
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Git-backed acquisition for the `claude-review` workflow drift advisory (BAPI-941).
3
+ *
4
+ * `claude-review-workflow-drift.ts` is a pure classifier — it takes bytes and
5
+ * returns a verdict, and touches neither git nor the network. This module is the
6
+ * thin layer that GETS those bytes out of the local object database.
7
+ *
8
+ * It is a deliberate LEAF: it imports the pure classifier and a type-only
9
+ * `RunCommand`, and nothing else. That matters because two very different
10
+ * callers need it —
11
+ *
12
+ * - `base-ref.ts` (the worker-spawn advisory, on both the fetch-only conductor
13
+ * path and the interactive refresh path), and
14
+ * - `start-tickets-prereqs.ts` (the read-only `doctor` probe)
15
+ *
16
+ * — and `base-ref.ts` already imports `commandSucceeded` FROM
17
+ * `start-tickets-prereqs.ts`. Putting these functions in either of those modules
18
+ * would close a cycle in a runtime graph both modules document as acyclic. The
19
+ * one-line exit-code predicate below is duplicated for exactly that reason; it is
20
+ * cheaper than the cycle it avoids.
21
+ *
22
+ * Every operation here is READ-ONLY. Nothing fetches, checks out, resets,
23
+ * merges, or moves a ref — `git show <ref>:<path>` reads straight out of the
24
+ * object database, which is what makes this safe to run against a checkout an
25
+ * operator's live dev server is editing.
26
+ */
27
+ import { CLAUDE_REVIEW_WORKFLOW_RELPATH, classifyClaudeReviewWorkflowDrift, } from "./claude-review-workflow-drift.js";
28
+ /** See the module docstring: duplicated to keep this module a leaf. */
29
+ function succeeded(result) {
30
+ return result.exitCode === 0;
31
+ }
32
+ /**
33
+ * Resolve the repository's default branch from the local clone, with no network
34
+ * call and no hardcoded `main`.
35
+ *
36
+ * `refs/remotes/origin/HEAD` is git's own record of the remote's default branch,
37
+ * written at clone time and refreshable with `git remote set-head origin -a`. A
38
+ * clone that lacks it (a bare `git init` + `git remote add`, or an older shallow
39
+ * fetch) yields `null`, which callers map to an `unverified` classification —
40
+ * the advisory fails open rather than guessing a branch name.
41
+ */
42
+ export async function resolveRepositoryDefaultBranch(deps) {
43
+ const result = await deps.runCommand("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: deps.cwd });
44
+ if (!succeeded(result))
45
+ return null;
46
+ const ref = result.stdout.trim();
47
+ if (ref.length === 0)
48
+ return null;
49
+ // `origin/main` -> `main`. Only the leading remote name is stripped, so a
50
+ // branch whose own name contains `origin/` survives intact.
51
+ const prefix = "origin/";
52
+ const branch = ref.startsWith(prefix) ? ref.slice(prefix.length) : ref;
53
+ return branch.length > 0 ? branch : null;
54
+ }
55
+ /**
56
+ * Read the reviewed workflow's bytes at a ref without checking anything out.
57
+ *
58
+ * A missing file at that ref and an unresolvable ref are indistinguishable from
59
+ * git's exit code alone, and both are equally inconclusive here, so they share
60
+ * one reason code chosen by the caller. Git's stderr is never propagated — it
61
+ * can echo absolute paths.
62
+ */
63
+ async function readWorkflowBlobAtRef(deps, ref, unreadableReason) {
64
+ const result = await deps.runCommand("git", ["show", `${ref}:${CLAUDE_REVIEW_WORKFLOW_RELPATH}`], { cwd: deps.cwd });
65
+ if (!succeeded(result)) {
66
+ return { ok: false, reason: unreadableReason };
67
+ }
68
+ return { ok: true, content: Buffer.from(result.stdout, "utf8") };
69
+ }
70
+ /**
71
+ * Compare the reviewed workflow at a base ref against the default branch's copy.
72
+ *
73
+ * Network-free: every ref it reads must already be present locally, which is why
74
+ * the spawn-time caller invokes this immediately AFTER its own fetch rather than
75
+ * fetching again, and why the doctor caller can run it without touching the
76
+ * network at all. Never throws and never returns an error — the advisory has
77
+ * exactly three outcomes, and an unusable comparison is one of them.
78
+ */
79
+ export async function probeClaudeReviewWorkflowDrift(deps, options) {
80
+ const { baseRef } = options;
81
+ const defaultRef = options.defaultRef;
82
+ if (!defaultRef) {
83
+ return classifyClaudeReviewWorkflowDrift({
84
+ baseRef,
85
+ defaultRef: "",
86
+ base: { ok: false, reason: "default_branch_unknown" },
87
+ default: { ok: false, reason: "default_branch_unknown" },
88
+ });
89
+ }
90
+ if (baseRef === defaultRef) {
91
+ // A ref cannot be stale relative to itself. The classifier short-circuits on
92
+ // this too; returning early keeps the two `git show` invocations unspent.
93
+ return classifyClaudeReviewWorkflowDrift({
94
+ baseRef,
95
+ defaultRef,
96
+ base: { ok: false, reason: "comparison_unavailable" },
97
+ default: { ok: false, reason: "comparison_unavailable" },
98
+ });
99
+ }
100
+ // Read against the REMOTE-TRACKING refs, which are what a preceding fetch
101
+ // actually refreshed. A local `epic/BAPI-902` may lag its origin counterpart,
102
+ // and the drift that matters is the one a worker's pull request will inherit.
103
+ const base = await readWorkflowBlobAtRef(deps, `origin/${baseRef}`, "base_workflow_unreadable");
104
+ const defaultBlob = await readWorkflowBlobAtRef(deps, `origin/${defaultRef}`, "default_workflow_unreadable");
105
+ return classifyClaudeReviewWorkflowDrift({
106
+ baseRef,
107
+ defaultRef,
108
+ base,
109
+ default: defaultBlob,
110
+ });
111
+ }
112
+ /**
113
+ * Resolve the ref a read-only diagnostic should treat as "the base": the current
114
+ * checkout's branch. `doctor` has no ticket and no run, so the branch the
115
+ * operator is standing on is the one whose lineage they are asking about.
116
+ *
117
+ * Returns `null` on a detached HEAD or any git failure — both are inconclusive,
118
+ * and the caller reports that rather than inventing a ref.
119
+ */
120
+ export async function resolveCurrentBranch(deps) {
121
+ const result = await deps.runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
122
+ cwd: deps.cwd,
123
+ });
124
+ if (!succeeded(result))
125
+ return null;
126
+ const branch = result.stdout.trim();
127
+ if (branch.length === 0 || branch === "HEAD")
128
+ return null;
129
+ return branch;
130
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Shared `claude-review` workflow drift identity and classification (BAPI-941).
3
+ *
4
+ * `anthropics/claude-code-action` refuses to run when the workflow file invoking
5
+ * it is not byte-identical to the copy on the repository's DEFAULT branch — its
6
+ * own supply-chain guard. The failure everyone recognizes is "this pull request
7
+ * edited the workflow". The failure nobody recognized is the TRANSITIVE one: a
8
+ * pull request whose own commits never touch `.github/` still trips the guard
9
+ * when its BASE branch carries a stale copy. Every open PR based on a long-lived
10
+ * `epic/**` branch goes silently unreviewed the moment the workflow changes on
11
+ * `main`, and a conductor-driven epic then parks at `needs_human` with
12
+ * `review_verdictless_ceiling_reached` an hour later, having produced no verdict
13
+ * comment at any point.
14
+ *
15
+ * This module is the ONE place that knows how to establish workflow identity and
16
+ * how to name the three outcomes. Worker spawning (`base-ref` / `start-tickets`),
17
+ * the read-only `doctor` probe, and the conductor install doctor all classify
18
+ * through here, so they cannot develop independent SHA logic that disagrees
19
+ * about what "the same workflow" means.
20
+ *
21
+ * Deliberate boundaries:
22
+ *
23
+ * - **No CLI dispatch, no spawning, no filesystem or network access.** Callers
24
+ * acquire the bytes however their own layer already does (a `git show` against
25
+ * an already-fetched ref, a Contents API lookup, a local read) and hand the
26
+ * result in. That keeps this importable from `base-ref.ts`, `doctor.ts`, and
27
+ * `conductor/install-doctor.ts` without a dependency cycle.
28
+ * - **Acquisition failure is never drift.** A missing ref, an unreadable blob,
29
+ * an unresolved default branch, or a provider error maps to `unverified`,
30
+ * never to `drifted`. The guard is advisory and fail-open everywhere it is
31
+ * consumed; reporting drift we could not actually prove would train operators
32
+ * to ignore the one diagnostic that matters.
33
+ * - **Nothing here logs file content.** Diagnostics carry refs, the workflow
34
+ * path, and a fixed reason code — never bytes, never provider exception text.
35
+ */
36
+ import crypto from "crypto";
37
+ /**
38
+ * Repo-relative path of the reviewed workflow. Defined here rather than in
39
+ * `claude-review-workflow.ts` so the drift module stays a leaf: the renderer
40
+ * imports and re-exports this, and the import direction never reverses.
41
+ */
42
+ export const CLAUDE_REVIEW_WORKFLOW_RELPATH = ".github/workflows/claude-review.yml";
43
+ /**
44
+ * Git's blob object header, reproduced so a locally-read file can be compared
45
+ * against the GitHub Contents API's own `sha` without downloading or decoding the
46
+ * remote file. Kept as a shared constant because the rendered workflow preflight
47
+ * builds the identical header inside its `actions/github-script` body — one
48
+ * construction, two runtimes.
49
+ */
50
+ export function gitBlobHeader(byteLength) {
51
+ return Buffer.from(`blob ${byteLength}\0`, "utf8");
52
+ }
53
+ /**
54
+ * The lowercase Git blob SHA-1 of raw bytes: `sha1("blob <len>\0" + content)`.
55
+ *
56
+ * Takes BYTES, not text, deliberately. Decoding to a string and re-encoding would
57
+ * normalize nothing useful and could silently change the byte length on invalid
58
+ * UTF-8, producing an identity that disagrees with git's — which is the exact
59
+ * class of bug a second independent implementation introduces.
60
+ */
61
+ export function computeGitBlobSha(content) {
62
+ const raw = Buffer.isBuffer(content) ? content : Buffer.from(content);
63
+ return crypto
64
+ .createHash("sha1")
65
+ .update(Buffer.concat([gitBlobHeader(raw.length), raw]))
66
+ .digest("hex")
67
+ .toLowerCase();
68
+ }
69
+ /** Human-readable, secret-free gloss for each reason code. */
70
+ const UNAVAILABLE_REASON_TEXT = {
71
+ default_branch_unknown: "the repository default branch could not be determined",
72
+ base_ref_unresolved: "the base ref could not be resolved",
73
+ default_ref_unresolved: "the default branch ref could not be resolved",
74
+ base_workflow_unreadable: "the workflow could not be read at the base ref",
75
+ default_workflow_unreadable: "the workflow could not be read at the default branch",
76
+ comparison_unavailable: "the comparison could not be completed",
77
+ };
78
+ /**
79
+ * Classify a base ref's copy of the reviewed workflow against the default
80
+ * branch's copy.
81
+ *
82
+ * A base ref that IS the default branch is `aligned` without comparing anything:
83
+ * it cannot be stale relative to itself, and short-circuiting keeps callers from
84
+ * paying for bytes they do not need.
85
+ */
86
+ export function classifyClaudeReviewWorkflowDrift(input) {
87
+ const meta = {
88
+ baseRef: input.baseRef,
89
+ defaultRef: input.defaultRef,
90
+ workflowPath: input.workflowPath ?? CLAUDE_REVIEW_WORKFLOW_RELPATH,
91
+ };
92
+ if (input.defaultRef.trim().length === 0) {
93
+ return { ...meta, state: "unverified", reason: "default_branch_unknown" };
94
+ }
95
+ if (input.baseRef.trim().length === 0) {
96
+ return { ...meta, state: "unverified", reason: "base_ref_unresolved" };
97
+ }
98
+ if (input.baseRef === input.defaultRef) {
99
+ return { ...meta, state: "aligned" };
100
+ }
101
+ // Acquisition failures are inconclusive, never drift. The base side is checked
102
+ // first only so the reported reason names the ref the operator would act on.
103
+ if (!input.base.ok) {
104
+ return { ...meta, state: "unverified", reason: input.base.reason };
105
+ }
106
+ if (!input.default.ok) {
107
+ return { ...meta, state: "unverified", reason: input.default.reason };
108
+ }
109
+ const baseSha = computeGitBlobSha(input.base.content);
110
+ const defaultSha = computeGitBlobSha(input.default.content);
111
+ if (baseSha === defaultSha) {
112
+ return { ...meta, state: "aligned" };
113
+ }
114
+ return { ...meta, state: "drifted", baseSha, defaultSha };
115
+ }
116
+ /**
117
+ * Render the operator-facing diagnostic for a classification.
118
+ *
119
+ * Wording rules, all deliberate:
120
+ *
121
+ * - Leads with a color-independent `WARNING:` / `INFO:` / `OK:` label, because
122
+ * these lines land on `stderr`, in a plain-text doctor report, and in CI logs
123
+ * where color is absent or stripped.
124
+ * - Names the STALE BASE first. The original incident's investigation went
125
+ * down the wrong path precisely because the diagnostic blamed the pull
126
+ * request, so the subject of the sentence is the ref that is actually wrong.
127
+ * - States the impact (`Claude review cannot run`) and the remedy (reconcile
128
+ * the base with the default branch) in that order.
129
+ * - Ends by saying work continues, so an advisory is never mistaken for a stop.
130
+ */
131
+ export function formatClaudeReviewWorkflowDriftDiagnostic(classification) {
132
+ const { baseRef, defaultRef, workflowPath } = classification;
133
+ if (classification.state === "aligned") {
134
+ return `OK: '${baseRef}' matches '${defaultRef}' for ${workflowPath}.`;
135
+ }
136
+ if (classification.state === "unverified") {
137
+ return (`INFO: could not compare ${workflowPath} between '${baseRef}' and ` +
138
+ `'${defaultRef}' — ${UNAVAILABLE_REASON_TEXT[classification.reason]}. ` +
139
+ `This check is advisory and fails open; continuing.`);
140
+ }
141
+ return (`WARNING: base branch '${baseRef}' carries a stale ${workflowPath} ` +
142
+ `(it differs from the default branch '${defaultRef}'). Claude review cannot ` +
143
+ `run for pull requests based on it — the review action refuses to run and no ` +
144
+ `verdict is ever posted, so a conductor run parks at needs_human about an hour ` +
145
+ `later. Remedy: reconcile '${baseRef}' with '${defaultRef}' (merge it in, or ` +
146
+ `sync ${workflowPath} together with ` +
147
+ `tests/pytest/commands/test_claude_review_workflow_prompt.py, which carries the ` +
148
+ `protected-region byte baselines). Retrying the review cannot clear this. ` +
149
+ `Continuing; this advisory does not block your work.`);
150
+ }
151
+ /**
152
+ * Compact one-line detail for space-constrained surfaces (the `doctor`
153
+ * prerequisite line and the install-doctor section detail), where the full
154
+ * paragraph above would swamp the report. Same facts, same order, no color.
155
+ */
156
+ export function summarizeClaudeReviewWorkflowDrift(classification) {
157
+ const { baseRef, defaultRef, workflowPath } = classification;
158
+ if (classification.state === "aligned") {
159
+ return `'${baseRef}' matches '${defaultRef}' for ${workflowPath}`;
160
+ }
161
+ if (classification.state === "unverified") {
162
+ return (`comparison unavailable (${classification.reason}) — advisory check failed open, ` +
163
+ `${workflowPath} lineage unknown`);
164
+ }
165
+ return (`WARNING: '${baseRef}' carries a stale ${workflowPath} (differs from ` +
166
+ `'${defaultRef}') — Claude review cannot run for pull requests based on it; ` +
167
+ `reconcile '${baseRef}' with '${defaultRef}'`);
168
+ }
169
+ /** Secret-free remediation sentence shared by the doctor and install-doctor surfaces. */
170
+ export const CLAUDE_REVIEW_WORKFLOW_DRIFT_REMEDIATION = `Reconcile the base branch with the repository default branch — merge the default ` +
171
+ `branch in, or sync ${CLAUDE_REVIEW_WORKFLOW_RELPATH} together with ` +
172
+ `tests/pytest/commands/test_claude_review_workflow_prompt.py. See ` +
173
+ `docs/claude/runbooks/claude-review-base-branch-drift.md.`;
@@ -20,8 +20,18 @@
20
20
  * All filesystem access is injected so the module is unit-testable with
21
21
  * in-memory fakes.
22
22
  */
23
- /** Repo-relative path this module owns. It writes NOTHING else. */
24
- export const CLAUDE_REVIEW_WORKFLOW_RELPATH = ".github/workflows/claude-review.yml";
23
+ import { CLAUDE_REVIEW_WORKFLOW_RELPATH } from "./claude-review-workflow-drift.js";
24
+ /**
25
+ * Repo-relative path this module owns. It writes NOTHING else.
26
+ *
27
+ * BAPI-941: the constant now lives in `claude-review-workflow-drift.ts` — the
28
+ * leaf module that also owns the Git blob identity the rendered preflight below
29
+ * reproduces — and is re-exported here so every existing importer is unchanged.
30
+ * The import direction is renderer → drift and never reverses, so `base-ref.ts`,
31
+ * `doctor.ts`, and `conductor/install-doctor.ts` can depend on the drift module
32
+ * without pulling in the renderer or creating a cycle.
33
+ */
34
+ export { CLAUDE_REVIEW_WORKFLOW_RELPATH };
25
35
  /** Thrown when a parameter fails validation. Carries no rendered YAML. */
26
36
  export class ClaudeReviewWorkflowValidationError extends Error {
27
37
  constructor(message) {
@@ -311,11 +321,28 @@ export function renderClaudeReviewWorkflow(params) {
311
321
  * reports that as a warning and exits SUCCESS, so without this preflight the
312
322
  * attempts would each publish nothing and the run would blame
313
323
  * `retries_exhausted_without_verdict`, naming a cause that did not occur.
324
+ *
325
+ * BAPI-941: the mismatch has two sources and they need different remedies. The
326
+ * obvious one is a pull request that edits this file. The quiet one is
327
+ * INHERITED drift — a pull request whose own commits never touch `.github/`
328
+ * still trips the guard when its BASE branch carries a stale copy, because the
329
+ * action compares against the DEFAULT branch, not the base. Long-lived release
330
+ * or `epic/**` branches go silently unreviewed that way for days. So resolve
331
+ * three identities (head, base ref, default branch) and attribute the mismatch
332
+ * to the ref that is actually stale; blaming the pull request for its base's
333
+ * staleness prints the wrong remedy to the wrong person.
334
+ *
335
+ * The Git blob identity built below is the same construction
336
+ * `computeGitBlobSha` performs in-process for the spawn-time and doctor
337
+ * probes — `blob <byte-length>\0` followed by the raw bytes, SHA-1'd. It is
338
+ * reproduced here rather than imported because this body executes inside
339
+ * GitHub Actions, not in this process.
314
340
  */
315
341
  const preflight = () => ` - name: Preflight the review action
316
342
  id: preflight
317
343
  env:
318
344
  DEFAULT_BRANCH: ${gh("github.event.repository.default_branch")}
345
+ BASE_REF: ${gh("github.event.pull_request.base.ref")}
319
346
  uses: actions/github-script@v9
320
347
  with:
321
348
  script: |
@@ -328,18 +355,26 @@ export function renderClaudeReviewWorkflow(params) {
328
355
  const localSha = crypto.createHash('sha1')
329
356
  .update(Buffer.concat([header, raw]))
330
357
  .digest('hex');
331
- let remoteSha = '';
332
- try {
333
- const res = await github.rest.repos.getContent({
334
- owner: context.repo.owner,
335
- repo: context.repo.repo,
336
- path: '.github/workflows/claude-review.yml',
337
- ref: String(process.env.DEFAULT_BRANCH || ''),
338
- });
339
- remoteSha = String(res.data.sha || '');
340
- } catch (err) {
341
- remoteSha = '';
342
- }
358
+ const defaultBranch = String(process.env.DEFAULT_BRANCH || '');
359
+ const baseRef = String(process.env.BASE_REF || '');
360
+ // Never surfaces provider exception text: a failed lookup becomes an
361
+ // empty SHA, and every message below is assembled from refs and the
362
+ // workflow path only.
363
+ const shaAtRef = async (ref) => {
364
+ if (!ref) return '';
365
+ try {
366
+ const res = await github.rest.repos.getContent({
367
+ owner: context.repo.owner,
368
+ repo: context.repo.repo,
369
+ path: '.github/workflows/claude-review.yml',
370
+ ref,
371
+ });
372
+ return String(res.data.sha || '');
373
+ } catch (err) {
374
+ return '';
375
+ }
376
+ };
377
+ const remoteSha = await shaAtRef(defaultBranch);
343
378
  if (!remoteSha) {
344
379
  // Cannot prove a mismatch. Fail OPEN toward attempting the review: a
345
380
  // suppressed review that would have worked is worse than a wasted
@@ -352,11 +387,37 @@ export function renderClaudeReviewWorkflow(params) {
352
387
  if (localSha === remoteSha) {
353
388
  core.setOutput('action_runnable', 'true');
354
389
  core.setOutput('status', 'ok');
355
- } else {
390
+ return;
391
+ }
392
+ const blockAsModified = () => {
356
393
  core.setOutput('action_runnable', 'false');
357
394
  core.setOutput('status', 'workflow_modified');
358
395
  core.warning('This pull request modifies the review workflow, so the review action will refuse to run against it. Suppressing all attempts; review this pull request by hand.');
396
+ };
397
+ // When the base ref IS the default branch the two comparisons are the
398
+ // same one, so the mismatch can only be the pull request's own edit.
399
+ if (baseRef === defaultBranch) {
400
+ blockAsModified();
401
+ return;
402
+ }
403
+ const baseSha = await shaAtRef(baseRef);
404
+ if (!baseSha) {
405
+ // The mismatch is real but cannot be attributed. Fail OPEN for the
406
+ // same reason as above: naming the wrong culprit prints the wrong
407
+ // remedy, and suppressing on an unproven attribution is worse.
408
+ core.setOutput('action_runnable', 'true');
409
+ core.setOutput('status', 'unverified');
410
+ core.info('This workflow differs from the default branch, but the base ref copy could not be read, so the mismatch cannot be attributed; proceeding.');
411
+ return;
412
+ }
413
+ if (localSha === baseSha) {
414
+ // head === base, base !== default: inherited, not authored.
415
+ core.setOutput('action_runnable', 'false');
416
+ core.setOutput('status', 'base_workflow_stale');
417
+ core.warning('Base branch ' + baseRef + ' carries a stale .github/workflows/claude-review.yml (it differs from ' + defaultBranch + '); this pull request did not modify the file and inherited the drift. The review action will refuse to run, so no verdict can be published. Reconcile the base branch ' + baseRef + ' with ' + defaultBranch + ' — retrying this run cannot clear it.');
418
+ return;
359
419
  }
420
+ blockAsModified();
360
421
 
361
422
  `;
362
423
  /** One paid review attempt: guard the head, review, then check for a verdict. */
@@ -565,7 +626,11 @@ ${attempt(3)}
565
626
  let reason;
566
627
  if (success) {
567
628
  reason = 'verdict_published';
568
- } else if (env.PREFLIGHT_STATUS === 'workflow_modified') {
629
+ } else if (env.PREFLIGHT_STATUS === 'workflow_modified' || env.PREFLIGHT_STATUS === 'base_workflow_stale') {
630
+ // BAPI-941: both blocked preflight statuses map to the SAME terminal
631
+ // reason, ahead of every generic cause. \`base_workflow_stale\` is a
632
+ // new attribution, not a new terminal shape — the aggregate
633
+ // vocabulary is a documented contract with downstream readers.
569
634
  reason = 'review_action_blocked_by_workflow_modification';
570
635
  } else if (guards.includes('drift')) {
571
636
  reason = 'head_drift';