@bridge_gpt/mcp-server 0.2.39 → 0.2.42

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 (74) hide show
  1. package/README.md +10 -10
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/claude-user-config-doctor.js +42 -11
  5. package/build/cli-release.js +2 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conduct-epic/bridge-client.js +354 -113
  8. package/build/conduct-epic/checkpoint-store.js +75 -2
  9. package/build/conduct-epic/cli.js +795 -109
  10. package/build/conduct-epic/cut-protocol.js +327 -0
  11. package/build/conduct-epic/pr-state.js +113 -24
  12. package/build/conduct-epic/spawn.js +14 -2
  13. package/build/conductor/bridge-api-client.js +27 -1
  14. package/build/conductor/cli.js +46 -1
  15. package/build/conductor/doctor.js +101 -16
  16. package/build/conductor/epic-reconcile.js +72 -19
  17. package/build/conductor/epic-runtime.js +15 -3
  18. package/build/conductor/errors.js +47 -0
  19. package/build/conductor/git-hooks.js +205 -11
  20. package/build/conductor/install-doctor.js +230 -1
  21. package/build/conductor/local-merge.js +130 -28
  22. package/build/conductor/tools.js +32 -3
  23. package/build/conductor/worker-ledger-cli.js +27 -1
  24. package/build/conductor-bin.js +15 -15
  25. package/build/credentials-cli.js +3 -2
  26. package/build/doctor.js +107 -41
  27. package/build/executor/cli.js +48 -1
  28. package/build/executor/env.js +21 -0
  29. package/build/executor/index-scope.js +39 -0
  30. package/build/executor/job-log-registry.js +69 -0
  31. package/build/executor/job-runner.js +148 -26
  32. package/build/executor/live-worker-registry.js +83 -0
  33. package/build/executor/observation.js +167 -6
  34. package/build/executor/platform.js +147 -3
  35. package/build/executor/process.js +58 -14
  36. package/build/executor/runner.js +235 -48
  37. package/build/executor/test-clock.js +3 -2
  38. package/build/index-scope-contract.js +96 -0
  39. package/build/index.js +153 -204
  40. package/build/init.js +83 -22
  41. package/build/install-bridge-conductor.js +323 -14
  42. package/build/install-bridge.js +202 -38
  43. package/build/install-doctor.js +23 -9
  44. package/build/install-reexec.js +2 -1
  45. package/build/launcher-config-inspection.js +83 -22
  46. package/build/mcp-host-config.js +331 -67
  47. package/build/mcp-host-targets.js +45 -21
  48. package/build/mcp-identity.js +92 -0
  49. package/build/mcp-install-state.js +94 -1
  50. package/build/mcp-invoke.js +2 -1
  51. package/build/mcp-provisioning.js +45 -12
  52. package/build/mcp-registration-doctor.js +35 -13
  53. package/build/mcp-server-invocation.js +4 -2
  54. package/build/merge-pull-request.js +208 -9
  55. package/build/pipelines.generated.js +3 -3
  56. package/build/plane/defaults.js +4 -1
  57. package/build/plane/preflight.js +81 -10
  58. package/build/plane/test-fakes.js +9 -1
  59. package/build/readme.generated.js +1 -1
  60. package/build/regression-check.js +3 -2
  61. package/build/review-tickets.js +8 -7
  62. package/build/run-unit-tests-launcher.js +74 -1
  63. package/build/schedule-run.js +3 -2
  64. package/build/setup-epic.js +453 -78
  65. package/build/sfcc/tool-wrapper.js +15 -0
  66. package/build/start-tickets-prereqs.js +11 -6
  67. package/build/start-tickets.js +91 -85
  68. package/build/update-check.js +3 -2
  69. package/build/upgrade-advice.js +2 -1
  70. package/build/upgrade-cli.js +50 -18
  71. package/build/version.generated.js +1 -1
  72. package/docs/CONDUCTOR.md +22 -0
  73. package/docs/install/mcp-tool-integrations.md +19 -3
  74. package/package.json +2 -2
@@ -0,0 +1,327 @@
1
+ /**
2
+ * The shared EXACT-CUT protocol and scope-readiness poll (BAPI-843; BAPI-850).
3
+ *
4
+ * Extracted from `conduct-epic init` so that the LLM-conductor pilot and the v2
5
+ * `setup-epic --feature-branch` entry point drive ONE implementation of the
6
+ * local cut — the same local `git` fetch, the same remote-ref existence check,
7
+ * the same exact-SHA push, and the same mismatch classification. This module is
8
+ * the single owner of those operations; neither CLI re-implements any of them.
9
+ *
10
+ * What the cut is: the server leases a hold on the canonical parse lock and
11
+ * names the commit the canonical index actually covers (`cut/begin`); this
12
+ * module creates `origin/<feature>` at EXACTLY that commit with the operator's
13
+ * own `git` — never the GitHub App, which is `contents: read` and cannot create
14
+ * refs — reads the ref back, and asks the server to record it as the scope's
15
+ * immutable cut (`cut/commit`). The hold is released on every outcome.
16
+ *
17
+ * Contract properties, fixed here and relied on by both CLIs:
18
+ *
19
+ * - **Never force-updates an existing branch.** The push refspec has no leading
20
+ * `+`, so it can only create. A branch that already exists at any commit other
21
+ * than the held cut commit is a refusal, never a repoint.
22
+ * - **Takes an already validated branch and SHA.** Callers validate the branch
23
+ * name (`validateBranchName`) and normalize the candidate SHA before calling;
24
+ * this module re-proves the SHA shape it pushes, but it is not a parser.
25
+ * - **No credential ever reaches argv.** The only subprocess is `git`, and its
26
+ * arguments are refs, remotes, and SHAs. Bridge and GitHub credentials travel
27
+ * only inside the typed client's headers.
28
+ * - **Every failure is classified, never thrown.** Callers get a discriminated
29
+ * outcome with operator-ready sentences and the bounded facts (expected and
30
+ * observed SHAs) they need to report; there is nothing to `try/catch`.
31
+ */
32
+ import { execFile } from "node:child_process";
33
+ import { abandonIndexScopeCut, beginIndexScopeCut, commitIndexScopeCut, getIndexScopeStatus, } from "./bridge-client.js";
34
+ /**
35
+ * Poll bound for a scope bootstrap, shared by `conduct-epic init` and
36
+ * `setup-epic`. A seed copies a repository's whole parse cache and the
37
+ * verifying parse then downloads and change-detects it, so the ceiling is
38
+ * generous; the interval is what keeps the poll cheap.
39
+ */
40
+ export const SCOPE_BOOTSTRAP_POLL_INTERVAL_MS = 5_000;
41
+ export const SCOPE_BOOTSTRAP_MAX_POLLS = 240; // ~20 minutes at the interval above.
42
+ /**
43
+ * Build the default list-argument subprocess runner both CLIs use for `git`.
44
+ *
45
+ * One body rather than two copies: `execFile` with `shell: false`, a generous
46
+ * buffer for porcelain output, and an exit code that never throws — a missing
47
+ * binary resolves to a non-zero code the caller classifies.
48
+ */
49
+ export function createExecFileRunCommand() {
50
+ return (file, args, options) => new Promise((resolve) => {
51
+ execFile(file, args, {
52
+ cwd: options?.cwd,
53
+ // Git porcelain output for a many-worktree checkout can be large.
54
+ maxBuffer: 16 * 1024 * 1024,
55
+ encoding: "utf-8",
56
+ timeout: options?.timeoutMs,
57
+ // Explicit: arguments are a list, never a concatenated shell string.
58
+ shell: false,
59
+ }, (error, stdout, stderr) => {
60
+ const code = error?.code;
61
+ resolve({
62
+ stdout: stdout ?? "",
63
+ stderr: stderr ?? "",
64
+ exitCode: typeof code === "number" ? code : error ? 1 : 0,
65
+ });
66
+ });
67
+ });
68
+ }
69
+ /** Run `git` in the operator's checkout. */
70
+ export function runGit(deps, args) {
71
+ return Promise.resolve(deps.runCommand("git", args, { cwd: deps.cwd }));
72
+ }
73
+ /** The single trimmed line a `git rev-parse`-style command produced, or null. */
74
+ export function firstOutputLine(result) {
75
+ const value = result.stdout.split("\n")[0]?.trim() ?? "";
76
+ return value.length === 0 ? null : value;
77
+ }
78
+ /** The SHA from `git ls-remote --heads origin <ref>` output, or null. */
79
+ export function lsRemoteSha(result) {
80
+ const line = firstOutputLine(result);
81
+ if (line === null)
82
+ return null;
83
+ const sha = line.split(/\s+/)[0]?.trim() ?? "";
84
+ return sha.length === 0 ? null : sha;
85
+ }
86
+ /**
87
+ * Normalize a commit SHA to its canonical 40-character lowercase form, or `null`.
88
+ *
89
+ * Mirrors the server's own guard so a malformed value is refused HERE — before it
90
+ * becomes a `git push` refspec — rather than becoming an opaque git error or, far
91
+ * worse, a ref pushed at something that is not a commit.
92
+ */
93
+ export function normalizeCommitSha(value) {
94
+ if (typeof value !== "string")
95
+ return null;
96
+ const normalized = value.trim().toLowerCase();
97
+ return /^[0-9a-f]{40}$/.test(normalized) ? normalized : null;
98
+ }
99
+ /**
100
+ * Read the head of `origin/<branch>` WITHOUT mutating anything.
101
+ *
102
+ * `sha: null` means the branch does not exist on origin. A failed `ls-remote`
103
+ * (no remote, no network, no git) is its own outcome, never "absent": treating an
104
+ * unanswered read as absence is how a push lands on top of a branch nobody saw.
105
+ */
106
+ export async function readRemoteBranchHead(deps, branch) {
107
+ const result = await runGit(deps, ["ls-remote", "--heads", "origin", `refs/heads/${branch}`]);
108
+ if (result.exitCode !== 0) {
109
+ return { ok: false, error: `git ls-remote could not read origin/${branch}.` };
110
+ }
111
+ return { ok: true, sha: lsRemoteSha(result) };
112
+ }
113
+ /**
114
+ * Make sure the exact cut object is resolvable locally, fetching it if needed.
115
+ *
116
+ * The cut is pushed BY SHA, so the object must exist in the operator's
117
+ * repository. A base-branch fetch usually brings it along; when it did not — the
118
+ * index covers a commit that is no longer an ancestor of the base tip — one
119
+ * targeted, NON-MUTATING fetch of that SHA is attempted (it updates no ref,
120
+ * creates no branch, and checks nothing out). Returns `true` when the commit
121
+ * resolves.
122
+ */
123
+ export async function ensureCommitResolvableLocally(deps, commitSha) {
124
+ const present = await runGit(deps, ["rev-parse", "--verify", "--quiet", `${commitSha}^{commit}`]);
125
+ if (present.exitCode === 0)
126
+ return true;
127
+ await runGit(deps, ["fetch", "origin", commitSha]);
128
+ const retry = await runGit(deps, ["rev-parse", "--verify", "--quiet", `${commitSha}^{commit}`]);
129
+ return retry.exitCode === 0;
130
+ }
131
+ /**
132
+ * Drive the exact cut: `cut/begin` → re-check the remote ref under the hold →
133
+ * create the ref at the held commit (if absent) → read it back → `cut/commit`,
134
+ * releasing the hold on EVERY outcome.
135
+ *
136
+ * Everything between `begin` and the release happens while the SERVER holds the
137
+ * canonical repository's parse lock, so the commit the index covers cannot move
138
+ * underneath the ref this module creates.
139
+ */
140
+ export async function performExactIndexScopeCut(deps, access, request) {
141
+ const { featureBranch, baseBranch, candidateCommitSha } = request;
142
+ const lease = await beginIndexScopeCut(access, {
143
+ featureBranch,
144
+ baseBranch,
145
+ candidateCommitSha,
146
+ epicRunId: request.epicRunId ?? null,
147
+ }, deps.fetchImpl);
148
+ if (!lease.ok) {
149
+ return {
150
+ ok: false,
151
+ kind: "begin_refused",
152
+ failures: [`The index-scope cut could not begin: ${lease.error}`],
153
+ lease: null,
154
+ expectedSha: candidateCommitSha,
155
+ observedSha: null,
156
+ };
157
+ }
158
+ const cut = lease.value;
159
+ try {
160
+ if (cut.cut_commit_sha !== candidateCommitSha) {
161
+ // The server re-read the canonical snapshot under its own hold and named a
162
+ // different commit. Nothing has been pushed yet, so refusing costs nothing
163
+ // and continuing would cut the epic at a commit the preflight never checked.
164
+ return {
165
+ ok: false,
166
+ kind: "canonical_moved",
167
+ failures: [
168
+ `The canonical index moved while init was preparing ` +
169
+ `(preflight saw ${candidateCommitSha}, the cut hold names ${cut.cut_commit_sha}). Re-run init.`,
170
+ ],
171
+ lease: cut,
172
+ expectedSha: candidateCommitSha,
173
+ observedSha: cut.cut_commit_sha,
174
+ };
175
+ }
176
+ // Re-check `origin/<feature>` WHILE the hold is active. A preflight
177
+ // observation is evidence for a refusal, never for a mutation: another
178
+ // operator may have created the ref in between.
179
+ const held = await readRemoteBranchHead(deps, featureBranch);
180
+ if (!held.ok) {
181
+ return {
182
+ ok: false,
183
+ kind: "ls_remote_failed",
184
+ failures: [held.error],
185
+ lease: cut,
186
+ expectedSha: cut.cut_commit_sha,
187
+ observedSha: null,
188
+ };
189
+ }
190
+ const heldSha = held.sha;
191
+ if (heldSha !== null && heldSha !== cut.cut_commit_sha) {
192
+ return {
193
+ ok: false,
194
+ kind: "existing_ref_mismatch",
195
+ failures: [
196
+ `origin/${featureBranch} exists at ${heldSha}, which is not the canonical indexed ` +
197
+ `commit ${cut.cut_commit_sha}. Delete it or finish the previous run first.`,
198
+ ],
199
+ lease: cut,
200
+ expectedSha: cut.cut_commit_sha,
201
+ observedSha: heldSha,
202
+ };
203
+ }
204
+ let branchCreated = false;
205
+ if (heldSha === null) {
206
+ // Create the ref from the EXACT commit, with the operator's own git. No
207
+ // local checkout, no branch, no worktree — and deliberately not the GitHub
208
+ // App, which is `contents: read` and cannot create refs. Not a force push:
209
+ // the refspec has no leading `+`, so it can only create.
210
+ const pushed = await runGit(deps, [
211
+ "push",
212
+ "origin",
213
+ `${cut.cut_commit_sha}:refs/heads/${featureBranch}`,
214
+ ]);
215
+ if (pushed.exitCode !== 0) {
216
+ return {
217
+ ok: false,
218
+ kind: "push_failed",
219
+ failures: [
220
+ `Could not create origin/${featureBranch} at the canonical indexed commit ${cut.cut_commit_sha}.`,
221
+ ],
222
+ lease: cut,
223
+ expectedSha: cut.cut_commit_sha,
224
+ observedSha: null,
225
+ };
226
+ }
227
+ branchCreated = true;
228
+ }
229
+ // Read the ref BACK from origin. What matters is what the remote now holds,
230
+ // not what this process intended to push.
231
+ const confirmed = await readRemoteBranchHead(deps, featureBranch);
232
+ const confirmedSha = confirmed.ok ? confirmed.sha : null;
233
+ if (confirmedSha !== cut.cut_commit_sha) {
234
+ return {
235
+ ok: false,
236
+ kind: "confirm_failed",
237
+ failures: [`origin/${featureBranch} did not resolve to ${cut.cut_commit_sha} after the push.`],
238
+ lease: cut,
239
+ expectedSha: cut.cut_commit_sha,
240
+ observedSha: confirmedSha,
241
+ };
242
+ }
243
+ const committed = await commitIndexScopeCut(access, { scopeId: cut.scope_id, cutHoldId: cut.cut_hold_id, epicRefCommitSha: confirmedSha }, deps.fetchImpl);
244
+ if (!committed.ok) {
245
+ return {
246
+ ok: false,
247
+ kind: "commit_refused",
248
+ failures: [`The index-scope cut could not be recorded: ${committed.error}`],
249
+ lease: cut,
250
+ expectedSha: cut.cut_commit_sha,
251
+ observedSha: confirmedSha,
252
+ };
253
+ }
254
+ return { ok: true, lease: cut, branchCreated, outcome: committed.value.outcome };
255
+ }
256
+ finally {
257
+ // The hold is released on EVERY pre-seed outcome, including the success path
258
+ // (where the server already released it — abandon is idempotent). The seed
259
+ // acquires this same canonical lock itself and it is not reentrant, so
260
+ // handing off while still holding it would deadlock the epic against its own
261
+ // seed. A failed release never masks the primary failure: it is reported and
262
+ // the original outcome stands.
263
+ const abandoned = await abandonIndexScopeCut(access, { scopeId: cut.scope_id, cutHoldId: cut.cut_hold_id }, deps.fetchImpl);
264
+ if (!abandoned.ok) {
265
+ deps.errorLog(`announced: the cut hold could not be released cleanly: ${abandoned.error}`);
266
+ }
267
+ }
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // Scope-readiness poll (BAPI-843; shared with `setup-epic` by BAPI-850)
271
+ // ---------------------------------------------------------------------------
272
+ /** The operator-facing label for each lifecycle state the poll reports. */
273
+ export const SCOPE_LIFECYCLE_LABELS = Object.freeze({
274
+ provisioning: "Provisioning",
275
+ seeding: "Seeding",
276
+ verifying: "Verifying",
277
+ ready: "Ready",
278
+ failed: "Failed",
279
+ });
280
+ /**
281
+ * Poll a scope's lifecycle until it is `ready`, `failed`, or the bounded wait
282
+ * elapses, reporting each NEWLY observed lifecycle transition exactly once, in
283
+ * order, through `onTransition`.
284
+ *
285
+ * Readiness is a server-side fact this poll observes rather than concludes from
286
+ * any request of its own: `ready` is accepted ONLY when the status also proves
287
+ * `indexed_commit_sha == cut_commit_sha`. A `ready` whose watermark disagrees is
288
+ * reported as `ready_mismatch` — the control plane contradicting itself, which
289
+ * is worth refusing rather than proceeding on.
290
+ *
291
+ * A transient read failure is not a verdict: the poll keeps going and lets the
292
+ * bound be the thing that gives up. The interval and cap are the shared
293
+ * {@link SCOPE_BOOTSTRAP_POLL_INTERVAL_MS} / {@link SCOPE_BOOTSTRAP_MAX_POLLS}.
294
+ */
295
+ export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {}) {
296
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
297
+ const maxPolls = options.maxPolls ?? SCOPE_BOOTSTRAP_MAX_POLLS;
298
+ const intervalMs = options.intervalMs ?? SCOPE_BOOTSTRAP_POLL_INTERVAL_MS;
299
+ let lastState = "unknown";
300
+ let lastStatus = null;
301
+ let lastReportedState = null;
302
+ for (let poll = 0; poll < maxPolls; poll += 1) {
303
+ await sleep(intervalMs);
304
+ const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
305
+ if (!status.ok) {
306
+ lastState = `unreadable (${status.error})`;
307
+ continue;
308
+ }
309
+ lastStatus = status.value;
310
+ lastState = status.value.lifecycle_state;
311
+ if (lastState !== lastReportedState) {
312
+ lastReportedState = lastState;
313
+ options.onTransition?.(lastState, status.value);
314
+ }
315
+ if (lastState === "ready") {
316
+ if (status.value.indexed_commit_sha !== null &&
317
+ status.value.indexed_commit_sha === status.value.cut_commit_sha) {
318
+ return { kind: "ready", status: status.value };
319
+ }
320
+ return { kind: "ready_mismatch", status: status.value };
321
+ }
322
+ if (lastState === "failed") {
323
+ return { kind: "failed", status: status.value, reason: status.value.last_error ?? "unknown" };
324
+ }
325
+ }
326
+ return { kind: "timeout", lastState, lastStatus };
327
+ }
@@ -8,9 +8,13 @@
8
8
  * `conductor/epic-runtime.ts#parsePrBindingFromGhJson` fails closed on any
9
9
  * non-`OPEN` state, which is correct for binding a merge action and exactly
10
10
  * wrong here, and importing it would pull the whole v1 runtime along. So this
11
- * module parses the same `gh pr view` payload with a different, terminal-state-
12
- * preserving contract, while REUSING the shared mergeability normalizer so a
13
- * conflict is classified identically everywhere.
11
+ * module parses `gh` PR records with a different, terminal-state-preserving
12
+ * contract, while REUSING the shared mergeability normalizer so a conflict is
13
+ * classified identically everywhere.
14
+ *
15
+ * The probe reads `gh pr list --head <branch>`, whose output is an ARRAY, and
16
+ * selects one record from it. `gh pr view` was replaced because it cannot
17
+ * express "no pull request" as anything but a non-zero exit (BAPI-825/B1).
14
18
  *
15
19
  * **Worktree discovery.** `/review-and-start` names branches
16
20
  * `feature/<KEY>-<slug>` and the loop cannot know the slug. When
@@ -24,8 +28,24 @@
24
28
  */
25
29
  import { isPrMergeConflict, parseGhPrMergeabilityFields } from "../conductor/github-mergeability.js";
26
30
  import { runGhCommand } from "../conductor/pr-discovery.js";
27
- /** The `gh pr view --json` field list the status contract fixes. */
31
+ /**
32
+ * The `--json` field list the status contract fixes.
33
+ *
34
+ * Passed to `gh pr list` (BAPI-825/B1), which returns an ARRAY of records with
35
+ * exactly these keys. The name is retained because it is the published contract
36
+ * identifier and is asserted by name in the tests; only the subcommand changed.
37
+ */
28
38
  export const CONDUCT_EPIC_GH_PR_VIEW_FIELDS = "number,state,headRefOid,mergeable,mergeStateStatus,baseRefName,updatedAt";
39
+ /**
40
+ * How many candidate pull requests the probe asks `gh` for.
41
+ *
42
+ * More than one, deliberately: a branch routinely carries a closed pull request
43
+ * plus a reopened one, and `--limit 1` would hand back whichever `gh` ordered
44
+ * first rather than letting {@link selectConductEpicPrState} prefer the OPEN
45
+ * one. Bounded so a branch with a long pull-request history cannot turn one
46
+ * probe into an unbounded read.
47
+ */
48
+ export const CONDUCT_EPIC_GH_PR_LIST_LIMIT = 20;
29
49
  /** The accepted GitHub PR states. Terminal states are RETAINED, not filtered. */
30
50
  export const CONDUCT_EPIC_PR_STATES = ["OPEN", "MERGED", "CLOSED"];
31
51
  function isRecord(value) {
@@ -75,14 +95,57 @@ export function conductEpicPrHasConflict(pr) {
75
95
  return isPrMergeConflict({ mergeable: pr.mergeable, mergeStateStatus: pr.merge_state });
76
96
  }
77
97
  /**
78
- * Run `gh pr view <branch> --json …` and normalize the result.
98
+ * Choose the one pull request the loop acts on from `gh`'s candidate list.
99
+ *
100
+ * An OPEN pull request always wins: it is the only one any action row can
101
+ * advance, and a stale closed sibling must never displace it. Among terminal
102
+ * candidates the most recently updated wins, because that is the one whose
103
+ * MERGED or CLOSED state describes the branch's current reality.
104
+ *
105
+ * Deterministic when timestamps tie or are unavailable: a valid `updated_at`
106
+ * outranks an absent one, and source order breaks any remaining tie. A probe
107
+ * that returned a different pull request run-to-run for identical input would
108
+ * make every downstream row unreproducible.
109
+ */
110
+ export function selectConductEpicPrState(candidates) {
111
+ if (candidates.length === 0)
112
+ return null;
113
+ const open = candidates.find((pr) => pr.state === "OPEN");
114
+ if (open)
115
+ return open;
116
+ let best = candidates[0];
117
+ for (const candidate of candidates.slice(1)) {
118
+ const bestAt = Date.parse(best.updated_at ?? "");
119
+ const candidateAt = Date.parse(candidate.updated_at ?? "");
120
+ const bestUsable = Number.isFinite(bestAt);
121
+ const candidateUsable = Number.isFinite(candidateAt);
122
+ // Strictly greater, so an equal timestamp keeps the earlier record and
123
+ // source order remains the final tie-breaker.
124
+ if (candidateUsable && (!bestUsable || candidateAt > bestAt))
125
+ best = candidate;
126
+ }
127
+ return best;
128
+ }
129
+ /**
130
+ * Run `gh pr list --head <branch> --state all --json …` and normalize the result.
79
131
  *
80
132
  * Nothing throws. The three outcomes are kept strictly apart:
81
133
  *
82
- * - **`ok`** — `gh` succeeded and returned a parseable PR record.
83
- * - **`none`** — `gh` SUCCEEDED and returned nothing. Confirmed absence.
134
+ * - **`ok`** — `gh` succeeded and returned at least one parseable PR record.
135
+ * - **`none`** — `gh` SUCCEEDED and returned an empty array. Confirmed absence.
84
136
  * - **`error`** — `gh` could not be run, exited non-zero, or returned output
85
- * that is not a valid PR record. Unavailable evidence, not absence.
137
+ * that is not a valid PR array. Unavailable evidence, not absence.
138
+ *
139
+ * **`gh pr list`, not `gh pr view` (BAPI-825/B1).** `gh pr view <branch>` prints
140
+ * `no pull requests found for branch "…"` to stderr and **exits 1** when the
141
+ * branch has no pull request, and `runGhCommand` discards stderr, so every
142
+ * pre-PR tick produced `{ok: false, stdout: ""}`. Under BAPI-814/B2's correct
143
+ * rule that a non-zero exit is a failed probe, `none` became unreachable in
144
+ * production: the loop saw `pr: null` PLUS a `probe_errors` entry on every tick
145
+ * before a worker opened its pull request, so the "no PR yet" rows could never
146
+ * fire and a worker that died early parked at the three-hour hard deadline
147
+ * instead of getting its one-hour respawn. `gh pr list` exits 0 with `[]`,
148
+ * which makes absence expressible without weakening the failure rule.
86
149
  *
87
150
  * Every `error` reason is a fixed bounded string chosen here. The raw output is
88
151
  * never included — a `gh` failure message can echo a URL, a token hint, or the
@@ -92,33 +155,43 @@ export async function discoverConductEpicPrState(branch, options = {}) {
92
155
  const runGh = options.runGh ?? runGhCommand;
93
156
  let result;
94
157
  try {
95
- result = await runGh(["pr", "view", branch, "--json", CONDUCT_EPIC_GH_PR_VIEW_FIELDS], {
96
- cwd: options.cwd,
97
- });
158
+ result = await runGh([
159
+ "pr",
160
+ "list",
161
+ "--head",
162
+ branch,
163
+ "--state",
164
+ "all",
165
+ "--json",
166
+ CONDUCT_EPIC_GH_PR_VIEW_FIELDS,
167
+ "--limit",
168
+ String(CONDUCT_EPIC_GH_PR_LIST_LIMIT),
169
+ ], { cwd: options.cwd });
98
170
  }
99
171
  catch {
100
172
  return { kind: "error", reason: "the gh command could not be run" };
101
173
  }
102
174
  // A non-zero `gh` exit is a FAILED PROBE, never "no PR" (BAPI-814/B2).
103
175
  //
104
- // `gh` exits non-zero both when the branch genuinely has no pull request and
105
- // when it is unauthenticated, rate-limited, or offline, and this module
106
- // deliberately does not parse its stderr to tell those apart. Reporting the
107
- // ambiguity as absence is the dangerous half of that choice: the loop reads a
108
- // GitHub outage as "no PR yet" and keeps waiting on a pull request it can no
109
- // longer see. Reporting it as a failed probe is the safe half — the status
110
- // contract turns that into `pr: null` PLUS a `probe_errors` entry, which the
111
- // command treats as unavailable evidence rather than a negative result.
176
+ // `gh pr list` exits non-zero only when it could not answer unauthenticated,
177
+ // rate-limited, offline, not a repository. Absence is now expressed as a
178
+ // successful `[]`, so nothing legitimate is lost by keeping this strict: the
179
+ // status contract turns an error into `pr: null` PLUS a `probe_errors` entry,
180
+ // which the command treats as unavailable evidence rather than a negative
181
+ // result.
112
182
  //
113
183
  // The reason is a FIXED string. `gh`'s own output can echo a URL, a token
114
184
  // hint, or the repository layout, so none of it is interpolated here.
115
185
  if (!result.ok)
116
186
  return { kind: "error", reason: "the gh command failed" };
117
- // A SUCCESSFUL command with empty output is genuine absence: `gh` ran, looked,
118
- // and found nothing. That stays distinct from the failure above.
187
+ // Empty stdout from a SUCCESSFUL `--json` run is malformed, not absence: the
188
+ // documented answer for "no pull requests" is the two bytes `[]`. Silence is
189
+ // a shape this command should never produce, and reading it as a confirmed
190
+ // negative is precisely the inference this module refuses to make.
119
191
  const raw = typeof result.stdout === "string" ? result.stdout.trim() : "";
120
- if (raw.length === 0)
121
- return { kind: "none" };
192
+ if (raw.length === 0) {
193
+ return { kind: "error", reason: "gh returned no output for a --json query" };
194
+ }
122
195
  let parsed;
123
196
  try {
124
197
  parsed = JSON.parse(raw);
@@ -126,7 +199,23 @@ export async function discoverConductEpicPrState(branch, options = {}) {
126
199
  catch {
127
200
  return { kind: "error", reason: "gh returned output that is not valid JSON" };
128
201
  }
129
- const pr = parseConductEpicPrState(parsed);
202
+ if (!Array.isArray(parsed)) {
203
+ return { kind: "error", reason: "gh returned a PR list in an unexpected shape" };
204
+ }
205
+ // A successful, well-formed empty list is the ONLY confirmed absence.
206
+ if (parsed.length === 0)
207
+ return { kind: "none" };
208
+ const records = [];
209
+ for (const entry of parsed) {
210
+ const pr = parseConductEpicPrState(entry);
211
+ // An unusable record is a shape error, never silently dropped: discarding
212
+ // it could empty the list and turn a real pull request into "no PR".
213
+ if (pr === null) {
214
+ return { kind: "error", reason: "gh returned a PR record in an unexpected shape" };
215
+ }
216
+ records.push(pr);
217
+ }
218
+ const pr = selectConductEpicPrState(records);
130
219
  if (pr === null)
131
220
  return { kind: "error", reason: "gh returned a PR record in an unexpected shape" };
132
221
  return { kind: "ok", pr };
@@ -21,6 +21,7 @@
21
21
  * argv would be visible to every process on the machine via `ps`.
22
22
  */
23
23
  import { powershellSquote, shSquoteInner, } from "../start-tickets.js";
24
+ import { INDEX_SCOPE_ENV_VAR } from "../index-scope-contract.js";
24
25
  /** The agents `conduct-epic spawn` may launch. */
25
26
  export const CONDUCT_EPIC_AGENTS = ["claude", "cursor-agent"];
26
27
  /** The repository's established default agent. */
@@ -52,6 +53,11 @@ export function resolveConductEpicAgent(agent) {
52
53
  * Returns the command as a string rather than an argv array because that is what
53
54
  * every terminal spawner in this repository consumes — a tab is opened by handing
54
55
  * a shell a command line, not by `exec`ing a process.
56
+ *
57
+ * BAPI-844: a validated `indexScope` is prefixed as an environment assignment
58
+ * using the SAME quoting helpers as the rest of the command — the shell's own
59
+ * environment mechanism, not an argument the agent can see. An absent scope emits
60
+ * no prefix, so the unscoped command is unchanged byte-for-byte.
55
61
  */
56
62
  export function buildConductEpicAgentCommand(input) {
57
63
  const resolved = resolveConductEpicAgent(input.agent);
@@ -61,15 +67,21 @@ export function buildConductEpicAgentCommand(input) {
61
67
  return { ok: false, error: "A worktree path is required to build the agent command." };
62
68
  }
63
69
  if (input.platform === "win32") {
70
+ const scopePrefix = input.indexScope
71
+ ? `$env:${INDEX_SCOPE_ENV_VAR} = ${powershellSquote(input.indexScope)}; `
72
+ : "";
64
73
  return {
65
74
  ok: true,
66
- command: `Set-Location -LiteralPath ${powershellSquote(input.worktreePath)}; ` +
75
+ command: `${scopePrefix}Set-Location -LiteralPath ${powershellSquote(input.worktreePath)}; ` +
67
76
  `${resolved.agent} ${powershellSquote(input.prompt)}`,
68
77
  };
69
78
  }
79
+ const scopePrefix = input.indexScope
80
+ ? `export ${INDEX_SCOPE_ENV_VAR}='${shSquoteInner(input.indexScope)}' && `
81
+ : "";
70
82
  return {
71
83
  ok: true,
72
- command: `cd '${shSquoteInner(input.worktreePath)}' && ` +
84
+ command: `${scopePrefix}cd '${shSquoteInner(input.worktreePath)}' && ` +
73
85
  `${resolved.agent} '${shSquoteInner(input.prompt)}'`,
74
86
  };
75
87
  }
@@ -1078,6 +1078,13 @@ function parseShadowDispatchFreshnessResult(parsed) {
1078
1078
  indexedCommitSha: typeof obj.indexed_commit_sha === "string" ? obj.indexed_commit_sha : null,
1079
1079
  shadowRepoName: typeof obj.shadow_repo_name === "string" ? obj.shadow_repo_name : null,
1080
1080
  lastError: typeof obj.last_error === "string" ? obj.last_error : null,
1081
+ // Fail closed: only an explicit `true` expires a deadline. An older backend
1082
+ // that does not send the field at all keeps the previous behavior (hold
1083
+ // forever on this side, bounded by the Python reconciler's own park).
1084
+ deadlineExpired: obj.deadline_expired === true,
1085
+ blockedAdvanceReason: typeof obj.blocked_advance_reason === "string" && obj.blocked_advance_reason.length > 0
1086
+ ? obj.blocked_advance_reason
1087
+ : null,
1081
1088
  };
1082
1089
  }
1083
1090
  /**
@@ -1176,7 +1183,26 @@ function parseValidateEpicPlanResult(parsed) {
1176
1183
  insertedEdges < 0) {
1177
1184
  throw new ConductorBridgeApiError("server");
1178
1185
  }
1179
- return { planHash, serializationEnabled, insertedEdges };
1186
+ // BAPI-848 the coverage diagnostics are read TOLERANTLY, unlike the fields
1187
+ // above. A server that predates them is not a protocol error, and the CLI must
1188
+ // keep working against one; the defaults below are the honest reading of an
1189
+ // absent field (nothing reported), and the renderer states the scope it was
1190
+ // actually given rather than inventing coverage it cannot see.
1191
+ return {
1192
+ planHash,
1193
+ serializationEnabled,
1194
+ insertedEdges,
1195
+ overlappingPairsFound: safeCount(p["overlapping_pairs_found"]),
1196
+ undeclaredNodes: safeCount(p["undeclared_nodes"]),
1197
+ undeclaredPairsSkipped: safeCount(p["undeclared_pairs_skipped"]),
1198
+ coverageScope: typeof p["coverage_scope"] === "string" && p["coverage_scope"].trim() !== ""
1199
+ ? p["coverage_scope"]
1200
+ : "unreported",
1201
+ };
1202
+ }
1203
+ /** Non-negative safe integer, or 0 for anything else (absent field included). */
1204
+ function safeCount(value) {
1205
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
1180
1206
  }
1181
1207
  /**
1182
1208
  * POST the immutable plan blob to the durable-store endpoint. The blob is