@bridge_gpt/mcp-server 0.2.51 → 0.2.53
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 +59 -13
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +7 -5
- package/build/conduct-epic/cut-protocol.js +17 -3
- package/build/conductor/bridge-api-client.js +232 -5
- package/build/conductor/cli.js +23 -0
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/doctor.js +428 -5
- package/build/conductor/install-doctor.js +65 -656
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor/readiness-cli.js +152 -0
- package/build/conductor/readiness-sections.js +666 -0
- package/build/conductor/readiness.js +710 -0
- package/build/conductor/tools.js +56 -3
- package/build/conductor-bin.js +21 -17
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +40 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +10 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +130 -28
- package/build/executor/merge-job.js +67 -16
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +4734 -4270
- package/build/install-bridge.js +95 -0
- package/build/install-doctor.js +154 -2
- package/build/pipelines.generated.js +6 -4
- package/build/plan-epic-conductor-eligibility.js +37 -7
- package/build/plane/alembic-head.js +40 -11
- package/build/plane/build-freshness.js +22 -11
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +402 -44
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +118 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readiness-check.js +412 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +560 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +4 -3
- package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
|
@@ -35,20 +35,26 @@
|
|
|
35
35
|
import { spawn } from "child_process";
|
|
36
36
|
import { pollCiChecksForCommit, ConductorBridgeApiError, safeDiagnosticMessage, } from "./bridge-api-client.js";
|
|
37
37
|
import { isLikelyGhMergeConflictOutput, isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
|
|
38
|
-
import { selectLatestChecksByName } from "./latest-check-selector.js";
|
|
38
|
+
import { normalizeSelectorCheckName, selectLatestChecksByName } from "./latest-check-selector.js";
|
|
39
39
|
const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
|
|
40
40
|
/**
|
|
41
41
|
* `gh pr view` field set for a merged-state / head-drift read. Includes
|
|
42
42
|
* `mergeCommit` so an already-merged PR can be detected (and its merge commit
|
|
43
43
|
* SHA recorded) directly from the same read that guards head drift.
|
|
44
|
+
*
|
|
45
|
+
* BAPI-1021: also includes `baseRefName`/`headRefName` so the ref-verification
|
|
46
|
+
* guard (AC-11) can compare the PR's CURRENT base/head branches against the
|
|
47
|
+
* caller's expectations from the same single read — no extra `gh pr view`
|
|
48
|
+
* subprocess is added solely for ref verification.
|
|
44
49
|
*/
|
|
45
|
-
const PR_STATE_JSON = "headRefOid,state,mergeCommit";
|
|
50
|
+
const PR_STATE_JSON = "headRefOid,state,mergeCommit,baseRefName,headRefName";
|
|
46
51
|
/**
|
|
47
52
|
* Broader `gh pr view` field set used only for the post-merge-failure re-read:
|
|
48
53
|
* it must detect BOTH "already merged at the expected head" and a merge conflict
|
|
49
|
-
* (`mergeable` / `mergeStateStatus`) from a single read.
|
|
54
|
+
* (`mergeable` / `mergeStateStatus`) from a single read. BAPI-1021: also carries
|
|
55
|
+
* `baseRefName`/`headRefName` for the same ref-verification reason as above.
|
|
50
56
|
*/
|
|
51
|
-
const PR_STATE_MERGEABILITY_JSON = "headRefOid,state,mergeCommit,mergeable,mergeStateStatus";
|
|
57
|
+
const PR_STATE_MERGEABILITY_JSON = "headRefOid,state,mergeCommit,mergeable,mergeStateStatus,baseRefName,headRefName";
|
|
52
58
|
/**
|
|
53
59
|
* Hard wall-clock cap on every `gh` subprocess. The epic-tick runs in a single
|
|
54
60
|
* stateless process — a `gh` call that hangs (network stall, an auth prompt that
|
|
@@ -166,12 +172,50 @@ function buildResponse(request, status, reason, terminal, ledgerEvents) {
|
|
|
166
172
|
ledger_events: ledgerEvents,
|
|
167
173
|
};
|
|
168
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* BAPI-1001 — check-run conclusions GitHub itself treats as NEUTRAL. A required
|
|
177
|
+
* check concluding `skipped` or `neutral` does not hold a pull request back on
|
|
178
|
+
* GitHub, which reports it CLEAN/MERGEABLE. Deliberately NOT a catch-all for
|
|
179
|
+
* "not success": `failure`, `cancelled`, `timed_out`, `action_required`, every
|
|
180
|
+
* pending/queued state, and every unrecognized conclusion stay non-green.
|
|
181
|
+
*
|
|
182
|
+
* Kept LOCAL to merge admission rather than pushed into the shared selector: the
|
|
183
|
+
* selector's other consumers render CI state for humans, where a skipped check
|
|
184
|
+
* is worth showing as skipped rather than silently folded into "pass".
|
|
185
|
+
*/
|
|
186
|
+
const NEUTRAL_CHECK_CONCLUSIONS = new Set(["skipped", "neutral"]);
|
|
187
|
+
/** True when a check record's `conclusion` is one GitHub treats as neutral. */
|
|
188
|
+
function isNeutralCheckRecord(record) {
|
|
189
|
+
if (!record)
|
|
190
|
+
return false;
|
|
191
|
+
const conclusion = typeof record.conclusion === "string" ? record.conclusion.trim().toLowerCase() : "";
|
|
192
|
+
return NEUTRAL_CHECK_CONCLUSIONS.has(conclusion);
|
|
193
|
+
}
|
|
169
194
|
/**
|
|
170
195
|
* Decide whether every required CI check is green for the polled head SHA. When
|
|
171
196
|
* the gate lists no required checks, fall back to the poll's `all_passed` flag.
|
|
172
197
|
* Defensive against the poll response's exact shape: a check counts as green if
|
|
173
198
|
* any of `conclusion==="success"`, `status==="success"`, `green===true`, or
|
|
174
199
|
* `bucket==="pass"`.
|
|
200
|
+
*
|
|
201
|
+
* BAPI-1001 adds neutral arbitration AHEAD of the BAPI-933 latest-wins selection.
|
|
202
|
+
* Four outcomes, and each is a distinct case:
|
|
203
|
+
*
|
|
204
|
+
* - `success` (by any of the four spellings above) — green, unchanged.
|
|
205
|
+
* - `skipped` / `neutral` — non-blocking. It never becomes the selected record
|
|
206
|
+
* for a name that also carries a meaningful observation, and when it is the
|
|
207
|
+
* ONLY observation for a required name it passes, because GitHub admits it.
|
|
208
|
+
* - a meaningful failure (`failure`, `cancelled`, `timed_out`,
|
|
209
|
+
* `action_required`, an unorderable conflict) — non-green, unchanged. A
|
|
210
|
+
* neutral duplicate can never hide one, because neutral records are the ones
|
|
211
|
+
* dropped, never the meaningful ones.
|
|
212
|
+
* - pending, queued, missing, malformed — non-green, unchanged.
|
|
213
|
+
*
|
|
214
|
+
* The defect this closes: BAPI-996's PR #1177 carried two `claude-review`
|
|
215
|
+
* check-runs at the head, one `success` and one NEWER `skipped`. Latest-wins
|
|
216
|
+
* selected the `skipped` record, `isGreen` read it as not-green, and the bounded
|
|
217
|
+
* CI-green wait refused a CLEAN/MERGEABLE pull request with `ci_not_green` until
|
|
218
|
+
* the merge re-fire budget parked the ticket.
|
|
175
219
|
*/
|
|
176
220
|
export function allRequiredChecksGreen(pollResponse, requiredChecks) {
|
|
177
221
|
if (pollResponse === null || typeof pollResponse !== "object")
|
|
@@ -198,14 +242,43 @@ export function allRequiredChecksGreen(pollResponse, requiredChecks) {
|
|
|
198
242
|
// reported MERGEABLE/CLEAN (BAPI-912 / PR #1107, three parked attempts).
|
|
199
243
|
// `selectLatestChecksByName` also OMITS a name whose duplicates could not be
|
|
200
244
|
// ordered, so the `every(...)` below fails it closed as a missing context.
|
|
201
|
-
|
|
245
|
+
// Neutral arbitration runs BEFORE selection, because recency cannot arbitrate
|
|
246
|
+
// between "a verdict" and "no verdict". A neutral duplicate is withheld from
|
|
247
|
+
// the selector whenever the same name also carries a meaningful observation,
|
|
248
|
+
// so latest-wins then arbitrates only among records that actually decided
|
|
249
|
+
// something. Names with nothing but neutral records keep them and are judged
|
|
250
|
+
// by `isGreen` below; every meaningful record is passed through untouched, so
|
|
251
|
+
// the selector's ambiguity/fail-closed handling is unchanged.
|
|
252
|
+
const meaningfulNames = new Set();
|
|
253
|
+
for (const record of rawChecks) {
|
|
254
|
+
if (record === null || typeof record !== "object" || Array.isArray(record))
|
|
255
|
+
continue;
|
|
256
|
+
const name = normalizeSelectorCheckName(record.name);
|
|
257
|
+
if (name !== null && !isNeutralCheckRecord(record))
|
|
258
|
+
meaningfulNames.add(name);
|
|
259
|
+
}
|
|
260
|
+
const arbitrated = rawChecks.filter((record) => {
|
|
261
|
+
if (record === null || typeof record !== "object" || Array.isArray(record))
|
|
262
|
+
return true;
|
|
263
|
+
const name = normalizeSelectorCheckName(record.name);
|
|
264
|
+
if (name === null)
|
|
265
|
+
return true;
|
|
266
|
+
return !(isNeutralCheckRecord(record) && meaningfulNames.has(name));
|
|
267
|
+
});
|
|
268
|
+
const { byName } = selectLatestChecksByName(arbitrated);
|
|
202
269
|
const isGreen = (c) => {
|
|
203
270
|
if (!c)
|
|
204
271
|
return false;
|
|
205
272
|
const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
|
|
206
273
|
const status = typeof c.status === "string" ? c.status.toLowerCase() : "";
|
|
207
274
|
const bucket = typeof c.bucket === "string" ? c.bucket.toLowerCase() : "";
|
|
208
|
-
return c.green === true ||
|
|
275
|
+
return (c.green === true ||
|
|
276
|
+
conclusion === "success" ||
|
|
277
|
+
status === "success" ||
|
|
278
|
+
bucket === "pass" ||
|
|
279
|
+
// Only reachable when the name carried no meaningful observation at all;
|
|
280
|
+
// otherwise the arbitration above already removed this record.
|
|
281
|
+
isNeutralCheckRecord(c));
|
|
209
282
|
};
|
|
210
283
|
return requiredChecks.every((name) => isGreen(byName.get(name)));
|
|
211
284
|
}
|
|
@@ -229,11 +302,26 @@ function isMergedAtExpectedHead(state, headOid, expectedSha) {
|
|
|
229
302
|
headOid.toLowerCase() === expectedSha.toLowerCase());
|
|
230
303
|
}
|
|
231
304
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
305
|
+
* Coerce a parsed `gh pr view` field to a bounded, nonblank ref name, or
|
|
306
|
+
* `undefined` when the field is absent, not a string, or whitespace-only.
|
|
307
|
+
* BAPI-1021: an unusable observed ref must be treated by the ref-verification
|
|
308
|
+
* guard as a MISMATCH when the caller supplied an expectation, never silently
|
|
309
|
+
* skipped — this coercion just normalizes the raw value, it does not decide
|
|
310
|
+
* that policy.
|
|
311
|
+
*/
|
|
312
|
+
function readBoundedRefName(value) {
|
|
313
|
+
if (typeof value !== "string")
|
|
314
|
+
return undefined;
|
|
315
|
+
const trimmed = value.trim();
|
|
316
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Read the PR's `headRefOid` + `state` + `mergeCommit` + `baseRefName` +
|
|
320
|
+
* `headRefName` via `gh pr view`. Extracted so the head-drift / not-open /
|
|
321
|
+
* already-merged / ref-mismatch guard can run BOTH before the CI wait and
|
|
322
|
+
* again right before provider merge (BAPI-566 Bug B: the head can drift while
|
|
323
|
+
* we wait for CI). Returns the raw parsed values; the caller applies the
|
|
324
|
+
* MERGED-at-head / OPEN / exact-head / matching-ref checks.
|
|
237
325
|
*/
|
|
238
326
|
async function readPrMergeState(run, ghEnv, pr, json = PR_STATE_JSON) {
|
|
239
327
|
const view = await run("gh", ["pr", "view", String(pr), "--json", json], ghEnv);
|
|
@@ -248,6 +336,8 @@ async function readPrMergeState(run, ghEnv, pr, json = PR_STATE_JSON) {
|
|
|
248
336
|
headOid: parsed.headRefOid,
|
|
249
337
|
state: parsed.state,
|
|
250
338
|
mergeCommitOid: extractMergeCommitOid(parsed),
|
|
339
|
+
baseRefName: readBoundedRefName(parsed.baseRefName),
|
|
340
|
+
headRefName: readBoundedRefName(parsed.headRefName),
|
|
251
341
|
raw: parsed,
|
|
252
342
|
};
|
|
253
343
|
}
|
|
@@ -381,6 +471,8 @@ async function waitForRequiredChecksGreen(pollCi, access, expectedSha, requiredC
|
|
|
381
471
|
*/
|
|
382
472
|
export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
383
473
|
const method = resolveLocalMergeMethod(options.method);
|
|
474
|
+
const expectedBaseBranch = readBoundedRefName(options.expectedBaseBranch);
|
|
475
|
+
const expectedHeadBranch = readBoundedRefName(options.expectedHeadBranch);
|
|
384
476
|
const rawRun = deps.runCommand ?? defaultRunCommand;
|
|
385
477
|
// Always await the runner: the production default is async (a hung `gh` would
|
|
386
478
|
// otherwise block the event loop and starve the executor heartbeat), and
|
|
@@ -479,6 +571,15 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
479
571
|
if (isMergedAtExpectedHead(firstRead.state, firstRead.headOid, expectedSha)) {
|
|
480
572
|
return buildAlreadyMergedResponse(request, baseDetails, firstRead.mergeCommitOid);
|
|
481
573
|
}
|
|
574
|
+
// 2b. BAPI-1021 (AC-11) — refuse before any provider merge if the PR's current
|
|
575
|
+
// base or head branch does not match the caller's expectation. Ordered
|
|
576
|
+
// after the already-merged short-circuit (an idempotent success must not
|
|
577
|
+
// be turned into a refusal by a since-changed ref) and before the
|
|
578
|
+
// head-drift / pr_not_open guards below.
|
|
579
|
+
const firstRefMismatch = findRefMismatch(expectedBaseBranch, expectedHeadBranch, firstRead);
|
|
580
|
+
if (firstRefMismatch) {
|
|
581
|
+
return buildRefMismatchResponse(request, baseDetails, firstRefMismatch);
|
|
582
|
+
}
|
|
482
583
|
if (typeof firstRead.headOid !== "string" || firstRead.headOid.toLowerCase() !== expectedSha.toLowerCase()) {
|
|
483
584
|
return fail("head_drift");
|
|
484
585
|
}
|
|
@@ -517,6 +618,13 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
517
618
|
if (isMergedAtExpectedHead(secondRead.state, secondRead.headOid, expectedSha)) {
|
|
518
619
|
return buildAlreadyMergedResponse(request, baseDetails, secondRead.mergeCommitOid);
|
|
519
620
|
}
|
|
621
|
+
// 3c. BAPI-1021 (AC-11) — repeat the ordered ref guard here too, so a PR
|
|
622
|
+
// retargeted while CI was pending cannot reach the merge command. Same
|
|
623
|
+
// ordering rationale as the first-read guard above.
|
|
624
|
+
const secondRefMismatch = findRefMismatch(expectedBaseBranch, expectedHeadBranch, secondRead);
|
|
625
|
+
if (secondRefMismatch) {
|
|
626
|
+
return buildRefMismatchResponse(request, baseDetails, secondRefMismatch);
|
|
627
|
+
}
|
|
520
628
|
if (typeof secondRead.headOid !== "string" || secondRead.headOid.toLowerCase() !== expectedSha.toLowerCase()) {
|
|
521
629
|
return fail("head_drift");
|
|
522
630
|
}
|
|
@@ -625,6 +733,57 @@ function buildConflictResponse(request, baseDetails, expectedSha, mergeability)
|
|
|
625
733
|
{ type: "merge.conflict", status: "failed", reason, details: conflictDetails },
|
|
626
734
|
]);
|
|
627
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* Compare the PR's CURRENT base/head refs (from a {@link readPrMergeState}
|
|
738
|
+
* read) against the caller's supplied expectations. Returns the first
|
|
739
|
+
* mismatch found (base checked before head), or `undefined` when every
|
|
740
|
+
* supplied expectation matches.
|
|
741
|
+
*
|
|
742
|
+
* An expectation with no corresponding usable actual ref — absent, not a
|
|
743
|
+
* string, or whitespace-only, already normalized to `undefined` by
|
|
744
|
+
* {@link readBoundedRefName} — counts as a MISMATCH, never a skip. Silently
|
|
745
|
+
* treating an unreadable ref as "nothing to check" would let exactly the
|
|
746
|
+
* unverifiable case slip through the guard it exists to provide.
|
|
747
|
+
*/
|
|
748
|
+
function findRefMismatch(expectedBaseBranch, expectedHeadBranch, actual) {
|
|
749
|
+
if (expectedBaseBranch !== undefined && actual.baseRefName !== expectedBaseBranch) {
|
|
750
|
+
return { field: "base", expected: expectedBaseBranch, actual: actual.baseRefName ?? "" };
|
|
751
|
+
}
|
|
752
|
+
if (expectedHeadBranch !== undefined && actual.headRefName !== expectedHeadBranch) {
|
|
753
|
+
return { field: "head", expected: expectedHeadBranch, actual: actual.headRefName ?? "" };
|
|
754
|
+
}
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Build a `ref_mismatch` failure (AC-11): a deliberate, terminal safety
|
|
759
|
+
* refusal issued BEFORE any provider-merge subprocess when the PR's current
|
|
760
|
+
* base or head branch does not match the caller's expectation.
|
|
761
|
+
*
|
|
762
|
+
* Details are allowlisted to the expected/actual branch values only — never
|
|
763
|
+
* raw `gh` output, stderr, response bodies, credentials, or exception text,
|
|
764
|
+
* mirroring {@link buildConflictResponse}'s secret-safety contract. The
|
|
765
|
+
* message states plainly that no provider merge was attempted and directs the
|
|
766
|
+
* operator to rebuild or cherry-pick the change against the expected run base
|
|
767
|
+
* — retargeting the pull request in the GitHub UI would only relabel a branch
|
|
768
|
+
* that has already drifted from what the run actually intends to merge, not
|
|
769
|
+
* fix it.
|
|
770
|
+
*/
|
|
771
|
+
function buildRefMismatchResponse(request, baseDetails, mismatch) {
|
|
772
|
+
const reason = "ref_mismatch";
|
|
773
|
+
const details = {
|
|
774
|
+
...baseDetails,
|
|
775
|
+
mismatched_field: mismatch.field,
|
|
776
|
+
[`expected_${mismatch.field}_branch`]: mismatch.expected,
|
|
777
|
+
[`actual_${mismatch.field}_branch`]: mismatch.actual,
|
|
778
|
+
message: `No provider merge was attempted: the pull request's current ${mismatch.field} branch ` +
|
|
779
|
+
`does not match the run's expected ${mismatch.field} branch. Rebuild or cherry-pick the ` +
|
|
780
|
+
"change against the expected run base rather than retargeting the pull request in the " +
|
|
781
|
+
"GitHub UI.",
|
|
782
|
+
};
|
|
783
|
+
return buildResponse(request, "failed", reason, false, [
|
|
784
|
+
{ type: "merge.failed", status: "failed", reason, details },
|
|
785
|
+
]);
|
|
786
|
+
}
|
|
628
787
|
/**
|
|
629
788
|
* Probe whether this host can merge with `gh` at all, BEFORE any merge is
|
|
630
789
|
* attempted.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `conductor readiness` command surface (BAPI-1055, AC-6).
|
|
3
|
+
*
|
|
4
|
+
* A CLI subcommand, deliberately, and NOT an MCP tool. Every peer diagnostic in
|
|
5
|
+
* this package — `doctor`, `plane`, `drive-epic`, `executor`, `install` — is an
|
|
6
|
+
* `argv[0]` subcommand dispatched before MCP server construction, and this one
|
|
7
|
+
* inherits that standard: stdout carries the report, stderr carries only fixed
|
|
8
|
+
* sanitized notices, and no tool is registered, so the budget-pinned `tools/list`
|
|
9
|
+
* surface is untouched.
|
|
10
|
+
*
|
|
11
|
+
* THIN BY CONSTRUCTION. Argument parsing, dependency construction, and
|
|
12
|
+
* formatting live here; every probe, verdict, and mapping belongs to
|
|
13
|
+
* {@link file:./readiness.ts} and the collectors it composes.
|
|
14
|
+
*
|
|
15
|
+
* ## Advisory exit policy
|
|
16
|
+
*
|
|
17
|
+
* This command returns 0 for every readiness outcome — pass, warn, fail, skip,
|
|
18
|
+
* an unavailable source, and a malformed upstream response alike. It never
|
|
19
|
+
* mutates `process.exitCode`, never brings a plane up, never approves a run, and
|
|
20
|
+
* never executes a remediation. An advisory command that could exit non-zero
|
|
21
|
+
* would be a gate, and the hard gates are `drive-epic`'s
|
|
22
|
+
* `V2_READINESS_REQUIREMENTS` and the server-side admission.
|
|
23
|
+
*
|
|
24
|
+
* The only non-zero path is a usage error (an unknown flag), which is the
|
|
25
|
+
* command failing to run at all rather than a readiness verdict.
|
|
26
|
+
*
|
|
27
|
+
* ## Credentials
|
|
28
|
+
*
|
|
29
|
+
* Resolution goes through `resolveConductorBridgeApiAccess`, which reads
|
|
30
|
+
* `BAPI_API_KEY` from the parent environment and then the user-scoped store at
|
|
31
|
+
* `~/.config/bridge/credentials.json`. That is the shell-spawned credential
|
|
32
|
+
* rule: this command runs in a plain shell, which never sees `.mcp.json` env —
|
|
33
|
+
* that file is visible to the MCP server process only.
|
|
34
|
+
*/
|
|
35
|
+
import os from "node:os";
|
|
36
|
+
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
37
|
+
import { collectConductorReadinessGate, formatConductorReadinessGateReport, } from "./readiness.js";
|
|
38
|
+
import { resolveConductorBridgeApiAccess } from "./bridge-api-client.js";
|
|
39
|
+
import { claudeReviewWorkflowPath } from "../claude-review-workflow.js";
|
|
40
|
+
import { runDefaultPlanePreflight } from "../plane/cli.js";
|
|
41
|
+
/** Fixed notice emitted to stderr when a source could not be collected. */
|
|
42
|
+
export const READINESS_COLLECTION_NOTICE = "one or more readiness sources could not be collected; the affected prerequisites are reported as failures.";
|
|
43
|
+
/** Usage text for `conductor readiness`. */
|
|
44
|
+
export function getReadinessUsage() {
|
|
45
|
+
return [
|
|
46
|
+
"Usage: conductor readiness [--json] [--no-deny-probe]",
|
|
47
|
+
"",
|
|
48
|
+
"Read-only ADVISORY report of every conductor prerequisite, consolidated from the",
|
|
49
|
+
"Bridge install checklist, the conductor doctor, the plane preflight, and the",
|
|
50
|
+
"server conductor-readiness collector. Each prerequisite reports pass/warn/fail/skip",
|
|
51
|
+
"and, on a failure, exactly ONE named remediation.",
|
|
52
|
+
"",
|
|
53
|
+
"Remediations are NOT run automatically. This command changes nothing: it starts no",
|
|
54
|
+
"plane, approves no run, writes no file, and always exits 0 — `drive-epic` remains",
|
|
55
|
+
"the only route-selection gate and the server-side admission the only hard refusal.",
|
|
56
|
+
"",
|
|
57
|
+
"Options:",
|
|
58
|
+
" --json Print the versioned structured report as JSON",
|
|
59
|
+
" --no-deny-probe Accepted for parity with `conductor doctor`; inert here, because",
|
|
60
|
+
" this command never spawns the deny-enforcement probe",
|
|
61
|
+
" --help Print this usage message",
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
64
|
+
/** Parse `conductor readiness` argv. Unknown flags and positionals are errors. */
|
|
65
|
+
export function parseReadinessArgs(argv) {
|
|
66
|
+
const parsed = { json: false, help: false, noDenyProbe: false };
|
|
67
|
+
for (const token of argv) {
|
|
68
|
+
if (token === "--json")
|
|
69
|
+
parsed.json = true;
|
|
70
|
+
else if (token === "--no-deny-probe")
|
|
71
|
+
parsed.noDenyProbe = true;
|
|
72
|
+
else if (token === "--help" || token === "-h")
|
|
73
|
+
parsed.help = true;
|
|
74
|
+
else
|
|
75
|
+
return { error: `Unknown argument "${token}". Run "conductor readiness --help" for usage.` };
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Build the production report for `repoRoot`.
|
|
81
|
+
*
|
|
82
|
+
* Every seam is a real, READ-ONLY one: the install doctor's own GET, the
|
|
83
|
+
* conductor doctor's local inspections, and the same plane preflight
|
|
84
|
+
* `plane up` and `drive-epic` run. Nothing is re-implemented here.
|
|
85
|
+
*/
|
|
86
|
+
async function collectProductionReport(repoRoot, env) {
|
|
87
|
+
const accessResult = await resolveConductorBridgeApiAccess({ env, cwd: repoRoot });
|
|
88
|
+
const access = accessResult.ok ? accessResult.access : null;
|
|
89
|
+
return collectConductorReadinessGate({
|
|
90
|
+
// Never guessed: an unresolved identity is reported as such rather than
|
|
91
|
+
// invented, and the repository ROOT is never placed in the report at all.
|
|
92
|
+
repoName: access?.repoName ?? null,
|
|
93
|
+
installDoctor: {
|
|
94
|
+
access,
|
|
95
|
+
accessError: accessResult.ok ? undefined : accessResult.error,
|
|
96
|
+
fetch: (...args) => fetch(...args),
|
|
97
|
+
// The consolidated gate reports workflow presence as a fact; it selects no
|
|
98
|
+
// per-run review policy, so applicability is evaluated under the policy
|
|
99
|
+
// that always consumes the workflow's verdict.
|
|
100
|
+
reviewPolicySource: "verdict_protocol",
|
|
101
|
+
readWorkflowFile: () => fsReadFile(claudeReviewWorkflowPath(repoRoot), "utf-8"),
|
|
102
|
+
projectRoot: repoRoot,
|
|
103
|
+
installDoctorDeps: {
|
|
104
|
+
env,
|
|
105
|
+
cwd: repoRoot,
|
|
106
|
+
platform: process.platform,
|
|
107
|
+
homedir: os.homedir,
|
|
108
|
+
readFile: (filePath) => fsReadFile(filePath, "utf-8"),
|
|
109
|
+
stat: (filePath) => fsStat(filePath),
|
|
110
|
+
fetch: (...args) => fetch(...args),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
// The deny-enforcement probe is never spawned from this command; the check
|
|
114
|
+
// is reported as an explicit skip naming `conductor doctor` instead.
|
|
115
|
+
conductorDoctorDeps: { env, skipDenyProbe: true },
|
|
116
|
+
runPlanePreflight: () => runDefaultPlanePreflight(repoRoot, env),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Run `conductor readiness`.
|
|
121
|
+
*
|
|
122
|
+
* Returns 0 for every readiness outcome; see the module docstring. The only
|
|
123
|
+
* non-zero return is a usage error.
|
|
124
|
+
*/
|
|
125
|
+
export async function runConductorReadinessCommand(argv, deps = {}) {
|
|
126
|
+
const sinks = deps.sinks ?? {
|
|
127
|
+
stdout: (line) => console.log(line),
|
|
128
|
+
stderr: (line) => console.error(line),
|
|
129
|
+
};
|
|
130
|
+
const parsed = parseReadinessArgs(argv);
|
|
131
|
+
if ("error" in parsed) {
|
|
132
|
+
sinks.stderr(`Error: ${parsed.error}`);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
if (parsed.help) {
|
|
136
|
+
sinks.stdout(getReadinessUsage());
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
140
|
+
const env = deps.env ?? process.env;
|
|
141
|
+
const collect = deps.collect ?? ((repoRoot) => collectProductionReport(repoRoot, env));
|
|
142
|
+
const report = await collect(cwd);
|
|
143
|
+
// Fixed, sanitized, and stderr-only: never a credential, exception, header,
|
|
144
|
+
// body, subprocess output, or absolute path. It fires on a COLLECTION gap,
|
|
145
|
+
// not on a failed prerequisite — a report that says "a source is missing"
|
|
146
|
+
// every time an ordinary check fails teaches operators to ignore it.
|
|
147
|
+
if (report.unavailableSources.length > 0)
|
|
148
|
+
sinks.stderr(READINESS_COLLECTION_NOTICE);
|
|
149
|
+
sinks.stdout(parsed.json ? JSON.stringify(report) : formatConductorReadinessGateReport(report));
|
|
150
|
+
// Advisory: the exit code is a constant, never derived from a check status.
|
|
151
|
+
return 0;
|
|
152
|
+
}
|