@bridge_gpt/mcp-server 0.2.48 → 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.
- package/README.md +24 -7
- package/build/base-ref.js +28 -3
- package/build/claude-review-workflow-drift-probe.js +130 -0
- package/build/claude-review-workflow-drift.js +173 -0
- package/build/claude-review-workflow.js +81 -16
- package/build/commands.generated.js +5 -5
- package/build/conductor/done-gate.js +25 -3
- package/build/conductor/install-doctor.js +65 -5
- package/build/conductor/latest-check-selector.js +170 -0
- package/build/conductor/local-merge.js +8 -6
- package/build/conductor-bin.js +1 -1
- package/build/{brainstorm-files.js → council-files.js} +15 -15
- package/build/decision-page-schema.js +1 -1
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +162 -4
- package/build/executor/worktree.js +46 -1
- package/build/index.js +92 -51
- package/build/init.js +9 -2
- package/build/install-bridge.js +60 -2
- package/build/install-reexec.js +47 -9
- package/build/pipelines.generated.js +1 -1
- package/build/plane/cli.js +12 -2
- package/build/plane/manifest.js +25 -1
- package/build/plane/member-roster.js +61 -7
- package/build/plane/preflight.js +24 -9
- package/build/plane/supervisor.js +77 -5
- package/build/plane/types.js +23 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -1
- package/build/stale-worktree-doctor.js +120 -0
- package/build/start-tickets-prereqs.js +70 -0
- package/build/start-tickets.js +91 -3
- package/build/version.generated.js +3 -2
- package/package.json +4 -2
- package/build/chain-orchestrator.js +0 -1457
- package/build/chain-utils.js +0 -68
- package/build/command-catalog.js +0 -376
- package/build/schedule-run.js +0 -1300
- package/build/schedule-store.js +0 -172
- package/build/scheduled-prompt.js +0 -115
- package/build/scheduler-backends/at-fallback.js +0 -139
- package/build/scheduler-backends/escaping.js +0 -143
- package/build/scheduler-backends/index.js +0 -72
- package/build/scheduler-backends/launchd.js +0 -225
- package/build/scheduler-backends/systemd-user.js +0 -250
- package/build/scheduler-backends/task-scheduler.js +0 -214
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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';
|