@bridge_gpt/mcp-server 0.2.50 → 0.2.51

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.
@@ -6,12 +6,17 @@
6
6
  * right base, that a checkpoint write is atomic, that two sessions cannot edit
7
7
  * one checkpoint, that a `MERGED` PR is visible, that `auto_merge_enabled` is on
8
8
  * before the first ticket is dispatched — are exactly the kind that fail
9
- * silently when they live in an instruction file. So they live here, behind five
9
+ * silently when they live in an instruction file. So they live here, behind the
10
10
  * verbs the loop calls and this file's tests pin:
11
11
  *
12
12
  * init | status | checkpoint set | finish | spawn
13
+ * recover | retire | reclaim | catch-up (index-scope lifecycle)
14
+ * scopes (repository-scoped inventory)
13
15
  *
14
- * Design rules that hold across all five:
16
+ * `scopes` (BAPI-963) is the one verb that takes no `<EPIC>` key: it answers
17
+ * "what does this repository own?", which is not a question about any one epic.
18
+ *
19
+ * Design rules that hold across all of them:
15
20
  *
16
21
  * - **Nothing creates or mutates an `epic_run`.** This CLI drives a LOCAL loop.
17
22
  * The one epic-run call it makes (`getEpicRunState`) is a read whose only
@@ -34,12 +39,13 @@ import os from "node:os";
34
39
  import path from "node:path";
35
40
  import { validateBranchName } from "../base-ref.js";
36
41
  import { parseDoneGateConfig } from "../conductor/done-gate.js";
42
+ import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "../epic-integration-pr.js";
37
43
  import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
38
44
  import { runGhCommand } from "../conductor/pr-discovery.js";
39
45
  import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "../start-tickets.js";
40
46
  import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
41
47
  import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
42
- import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, catchUpIndexScope, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
48
+ import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, catchUpIndexScope, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseDispatcherHealth, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
43
49
  import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
44
50
  import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
45
51
  import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
@@ -51,7 +57,7 @@ import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "..
51
57
  // local-git helpers live in ONE shared module that `setup-epic` drives too. This
52
58
  // file remains the pilot's owner of the preflight and of how a cut outcome is
53
59
  // reported; the cut itself is performed by the shared module.
54
- import { createExecFileRunCommand, firstOutputLine as firstLine, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, } from "./cut-protocol.js";
60
+ import { createExecFileRunCommand, describeScopeBootstrapWindow, firstOutputLine as firstLine, formatScopeBootstrapHeartbeat, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_BOOTSTRAP_UNREADABLE_STATE, } from "./cut-protocol.js";
55
61
  // Re-exported so existing importers of the pilot's normalizer keep compiling.
56
62
  export { normalizeCommitSha };
57
63
  /** Epic and ticket keys accepted by every verb. */
@@ -67,6 +73,7 @@ export const CONDUCT_EPIC_VERBS = [
67
73
  "retire",
68
74
  "reclaim",
69
75
  "catch-up",
76
+ "scopes",
70
77
  ];
71
78
  /** Per-ticket fields `checkpoint set` may assign. */
72
79
  const TICKET_FIELDS = [
@@ -91,9 +98,17 @@ export function createDefaultConductEpicDeps() {
91
98
  const runCommand = createExecFileRunCommand();
92
99
  const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
93
100
  const startTicketsDeps = createDefaultStartTicketsDeps();
101
+ // Strict enabled-value check (matches `MCP_INTEGRATION === "1"` in
102
+ // `integration/harness.ts`): only the exact value "1" activates the
103
+ // fixture, so no broadly truthy ambient environment value can enable it
104
+ // by accident.
105
+ const publishedIdentityFixture = process.env[CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE_ENV] === "1"
106
+ ? CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE
107
+ : undefined;
94
108
  return {
95
109
  runCommand,
96
110
  runGh: (args, options) => runGhCommand(args, options ?? {}),
111
+ epicIntegrationGh: createProductionEpicIntegrationGhRunner(),
97
112
  spawnTab: (shellCommand, context) => spawner(startTicketsDeps, detectTerminal(undefined, process.env), shellCommand, context),
98
113
  fetchImpl: globalThis.fetch,
99
114
  fs: {
@@ -130,6 +145,7 @@ export function createDefaultConductEpicDeps() {
130
145
  errorLog: (m) => console.error(m),
131
146
  resolveAccess: resolveConductorBridgeApiAccess,
132
147
  resolveLatestPublishedVersion: () => fetchLatestVersion({ fetch: globalThis.fetch }),
148
+ publishedIdentityFixture,
133
149
  resolveRepoName: resolveRequiredStartTicketsRepoName,
134
150
  };
135
151
  }
@@ -158,8 +174,14 @@ export function getConductEpicUsage() {
158
174
  " CI, review, parse, deadline, and lock state. --json is required. A missing",
159
175
  " checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
160
176
  " sub-object null and is listed in probe_errors; it never fails the command.",
161
- " `scopes` lists EVERY index scope this repository owns expired and",
162
- " reclaiming ones included so a crashed epic is visible without SQL.",
177
+ " status warns about STRANDED scopes only; run `conduct-epic scopes` below",
178
+ " for the full repository-wide inventory.",
179
+ "",
180
+ " scopes [--json]",
181
+ " Repository-scoped, and the ONE verb that takes no <EPIC> key. Lists EVERY",
182
+ " index scope this repository owns — live, expired, reclaiming, and",
183
+ " reclaimed alike — so a crashed epic is visible without SQL. Read-only:",
184
+ " it writes nothing and takes no lease.",
163
185
  "",
164
186
  " checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
165
187
  " [--checkpoint-path <p>]",
@@ -240,6 +262,10 @@ const VERB_FLAGS = {
240
262
  // the verb. Catch-up replays commits the SERVER reads through the unchanged
241
263
  // AC-10 gate; it cannot make that gate say yes.
242
264
  "catch-up": ["--scope", "--checkpoint-path", "--json"],
265
+ // BAPI-963. `--json` ONLY: this verb reads a repository-wide listing, so there
266
+ // is no checkpoint to point at and no scope to single out. The absence of
267
+ // --force/--override/--skip-guard here is the same deliberate absence as above.
268
+ scopes: ["--json"],
243
269
  };
244
270
  /**
245
271
  * Parse and fully validate argv BEFORE any I/O.
@@ -272,7 +298,8 @@ export function parseConductEpicArgs(argv) {
272
298
  argv[0] === "recover" ||
273
299
  argv[0] === "retire" ||
274
300
  argv[0] === "reclaim" ||
275
- argv[0] === "catch-up") {
301
+ argv[0] === "catch-up" ||
302
+ argv[0] === "scopes") {
276
303
  verb = argv[0];
277
304
  rest = argv.slice(1);
278
305
  }
@@ -340,12 +367,23 @@ export function parseConductEpicArgs(argv) {
340
367
  }
341
368
  }
342
369
  }
343
- if (epicKey === undefined)
344
- return parseError(`'${verbLabel(verb)}' requires an <EPIC> key.`);
345
- if (!CONDUCT_EPIC_KEY_PATTERN.test(epicKey)) {
346
- return parseError(`Invalid epic key '${epicKey}'. Expected the form PROJ-123.`);
370
+ // BAPI-963: `scopes` is repository-scoped and takes NO positional. Rejecting a
371
+ // supplied key rather than ignoring it keeps the promise `--help` makes exact
372
+ // an operator who types `conduct-epic scopes BAPI-1` learns the verb's shape
373
+ // instead of silently getting a listing that ignored their argument.
374
+ if (verb === "scopes") {
375
+ if (epicKey !== undefined) {
376
+ return parseError(`'scopes' takes no <EPIC> key; it lists every scope this repository owns.`);
377
+ }
378
+ }
379
+ else {
380
+ if (epicKey === undefined)
381
+ return parseError(`'${verbLabel(verb)}' requires an <EPIC> key.`);
382
+ if (!CONDUCT_EPIC_KEY_PATTERN.test(epicKey)) {
383
+ return parseError(`Invalid epic key '${epicKey}'. Expected the form PROJ-123.`);
384
+ }
385
+ options.epicKey = epicKey;
347
386
  }
348
- options.epicKey = epicKey;
349
387
  const missing = requiredFlagError(options);
350
388
  if (missing !== null)
351
389
  return parseError(missing);
@@ -487,6 +525,29 @@ function emitFailure(deps, json, reasons, payload = {}) {
487
525
  function epicBranchFor(epicKey) {
488
526
  return `epic/${epicKey}`;
489
527
  }
528
+ /**
529
+ * Ensure the draft epic-integration PR (BAPI-951), non-fatally. Never throws;
530
+ * returns the formatted, sanctioned-fields-only outcome for the caller's own
531
+ * advisory/announcement line.
532
+ */
533
+ async function ensureEpicIntegrationPrAdvisory(deps, input) {
534
+ const gh = deps.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
535
+ try {
536
+ const outcome = await ensureEpicIntegrationPullRequest({
537
+ epicKey: input.epicKey,
538
+ epicBranch: input.epicBranch,
539
+ baseBranch: input.baseBranch,
540
+ command: input.command,
541
+ gh,
542
+ cwd: deps.cwd,
543
+ requestReady: input.requestReady,
544
+ });
545
+ return formatEpicIntegrationPullRequestOutcome(outcome);
546
+ }
547
+ catch {
548
+ return formatEpicIntegrationPullRequestOutcome({ kind: "unavailable", reason: "probe_inconclusive" });
549
+ }
550
+ }
490
551
  /** Resolve the checkpoint path from `--checkpoint-path` or the default. */
491
552
  function resolveCheckpointPath(deps, repoName, epicKey, override) {
492
553
  if (override !== undefined)
@@ -603,6 +664,36 @@ export const PUBLISHED_IDENTITY_TIMEOUT_MS = 120_000;
603
664
  const PUBLISHED_IDENTITY_PATTERN = /^commit: ([0-9a-f]{12})(-dirty)?$/;
604
665
  /** The sentinel a build with no git metadata reports. */
605
666
  const PUBLISHED_IDENTITY_UNKNOWN = "unknown";
667
+ /**
668
+ * Opt-out for the published-identity read, for spawned integration children
669
+ * ONLY (BAPI-944).
670
+ *
671
+ * The required `mcp-integration` CI lane spawns real `conduct-epic init`
672
+ * children, and `readPublishedBuildIdentity` otherwise resolves the npm
673
+ * `latest` dist-tag and launches it through `npx` — a live registry
674
+ * dependency inside a lane that is supposed to be hermetic. Naming follows
675
+ * `INSTALL_REEXEC_SENTINEL` (`install-reexec.ts`); the strict-enabled-value
676
+ * convention follows `MCP_INTEGRATION` (`integration/harness.ts`). Kept
677
+ * distinct from `MCP_INTEGRATION` deliberately: that flag gates which test
678
+ * files run at all, this one gates a single dependency inside them, and
679
+ * conflating the two would make it impossible to run the integration suite
680
+ * against a real registry when that is exactly what is under test.
681
+ */
682
+ export const CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE_ENV = "BAPI_CONDUCTOR_PUBLISHED_IDENTITY_FIXTURE";
683
+ /**
684
+ * The static clean identity returned when the fixture seam is enabled.
685
+ *
686
+ * Non-secret and independent of the current checkout by construction — its
687
+ * commit is not expected to be present in any local history, so it exercises
688
+ * the SAME "commit unavailable locally" advisory path a real cold checkout
689
+ * would hit, rather than a synthesized pass.
690
+ */
691
+ const CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE = {
692
+ kind: "known",
693
+ version: "0.0.0-fixture",
694
+ commit: "deadbeefcafe",
695
+ dirty: false,
696
+ };
606
697
  /** Human wording for each unavailable category, for the fail-open advisory. */
607
698
  export function describePublishedIdentityReason(reason) {
608
699
  switch (reason) {
@@ -630,6 +721,13 @@ export function describePublishedIdentityReason(reason) {
630
721
  * may block a run.
631
722
  */
632
723
  export async function readPublishedBuildIdentity(deps) {
724
+ // BAPI-944: the fixture short-circuits BOTH the registry resolver and the
725
+ // `npx` probe below — checked before either is touched, so a spawned
726
+ // integration child with the sentinel set never reaches the network or the
727
+ // package manager.
728
+ if (deps.publishedIdentityFixture !== undefined) {
729
+ return deps.publishedIdentityFixture;
730
+ }
633
731
  const resolveVersion = deps.resolveLatestPublishedVersion ?? (() => fetchLatestVersion({ fetch: deps.fetchImpl }));
634
732
  let version;
635
733
  try {
@@ -752,7 +850,11 @@ export async function evaluatePublishGate(deps, expectedCommitSha) {
752
850
  failures: [
753
851
  `The published ${MCP_PACKAGE_NAME}@${identity.version} was built from ${identity.commit}, ` +
754
852
  `which does not contain ${expected} — the canonical indexed commit this epic is cut at. ` +
755
- "Publish a build containing that commit before initializing.",
853
+ "Publish a build containing that commit before initializing. Since BAPI-945 the " +
854
+ "publisher is CI, not a laptop: from a clean, current `main` checkout run " +
855
+ "`cd mcp_server && npm version patch`, then `git push origin main` and " +
856
+ '`git push origin "mcp-server/v<version>"` — that tag push triggers the ' +
857
+ "clean-room release workflow. There is deliberately no --force or --override here.",
756
858
  ],
757
859
  advisories: [],
758
860
  };
@@ -1054,16 +1156,35 @@ async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
1054
1156
  return { ok: false, failures: [`The index scope could not be seeded: ${scheduled.error}`] };
1055
1157
  }
1056
1158
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1159
+ // BAPI-963. The seed printed NOTHING for ~30 minutes during the BAPI-943 pilot:
1160
+ // no output, no checkpoint, and a frozen `epic_shadow_index.updated_at`, so a
1161
+ // healthy seed and a wedge looked identical from outside. Because the seed
1162
+ // copies the canonical index wholesale rather than re-parsing, no
1163
+ // `repository_parse_runs` row appears either, removing the last progress
1164
+ // signal. The notice and the per-poll heartbeat below are the fix.
1165
+ //
1166
+ // The MECHANISM (elapsed computation, the line shape, the window description)
1167
+ // lives at the shared `cut-protocol.ts` seam that `setup-epic` also drives, so
1168
+ // v2 can adopt the same progress observation; the RENDERING stays here, on the
1169
+ // pilot's own stderr advisory channel, which is what keeps stdout exactly one
1170
+ // JSON object under `--json`.
1171
+ const startedAtMs = deps.now().getTime();
1172
+ deps.errorLog(describeScopeBootstrapWindow(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_BOOTSTRAP_MAX_POLLS));
1057
1173
  let lastState = "unknown";
1058
1174
  for (let poll = 0; poll < SCOPE_BOOTSTRAP_MAX_POLLS; poll += 1) {
1059
1175
  await sleep(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS);
1060
1176
  const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
1177
+ const elapsedMs = deps.now().getTime() - startedAtMs;
1061
1178
  if (!status.ok) {
1062
1179
  // A transient read failure is not a verdict: keep polling and let the
1063
- // bound below be the thing that gives up.
1180
+ // bound below be the thing that gives up. The heartbeat reports the fixed
1181
+ // `unreadable` label rather than the raw error, which stays out of a line
1182
+ // that repeats every interval.
1183
+ deps.errorLog(formatScopeBootstrapHeartbeat(elapsedMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE));
1064
1184
  lastState = `unreadable (${status.error})`;
1065
1185
  continue;
1066
1186
  }
1187
+ deps.errorLog(formatScopeBootstrapHeartbeat(elapsedMs, status.value.lifecycle_state));
1067
1188
  lastState = status.value.lifecycle_state;
1068
1189
  if (status.value.lifecycle_state === "ready") {
1069
1190
  if (status.value.indexed_commit_sha !== null &&
@@ -1105,7 +1226,7 @@ async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
1105
1226
  return {
1106
1227
  ok: false,
1107
1228
  failures: [
1108
- `Index scope ${scopeId} did not become ready within the bootstrap window ` +
1229
+ `TIMED OUT: index scope ${scopeId} did not become ready within the bootstrap window ` +
1109
1230
  `(last observed state: ${lastState}). Re-run init to resume verification.`,
1110
1231
  ],
1111
1232
  };
@@ -1191,7 +1312,12 @@ export async function runConductEpicInit(deps, options) {
1191
1312
  tickets: options.tickets,
1192
1313
  checkpoint_path: checkpointPath,
1193
1314
  announcements,
1194
- }, ["Planned (dry run — nothing was pushed, cut, seeded, repointed, or written):", ...describePlan()]);
1315
+ }, [
1316
+ "Planned (dry run — nothing was pushed, cut, seeded, repointed, or written):",
1317
+ ...describePlan(),
1318
+ `would open a draft pull request ${epicBranch} → ${preflight.baseBranch} for ` +
1319
+ "conductor-ci / gate (BAPI-951; nothing opened in dry-run)",
1320
+ ]);
1195
1321
  }
1196
1322
  // The FIRST durable mutation of the whole verb, deliberately placed here: every
1197
1323
  // preflight check has passed, and nothing has been pushed, repointed, written,
@@ -1242,6 +1368,20 @@ export async function runConductEpicInit(deps, options) {
1242
1368
  });
1243
1369
  }
1244
1370
  announcements.push(`announced: index scope ${cut.scope_id} is ready at ${cut.cut_commit_sha}.`);
1371
+ // --- Draft epic-integration PR (BAPI-951) --------------------------------
1372
+ // Only when THIS invocation actually created the branch (`cutOutcome.branchCreated`)
1373
+ // — never on a reused/already-recorded cut, which `init` refuses to reach at all
1374
+ // (an existing checkpoint returns early above). Placed AFTER scope readiness and
1375
+ // BEFORE the checkpoint write, which stays the final mutation.
1376
+ if (cutOutcome.branchCreated) {
1377
+ const formatted = await ensureEpicIntegrationPrAdvisory(deps, {
1378
+ epicKey: options.epicKey,
1379
+ epicBranch,
1380
+ baseBranch: preflight.baseBranch,
1381
+ command: "conduct-epic init",
1382
+ });
1383
+ announcements.push(`integration pr: ${JSON.stringify(formatted)}`);
1384
+ }
1245
1385
  // BAPI-847: `init` used to repoint the REPOSITORY's `base_branch` at the epic
1246
1386
  // branch here, which is the defect this epic exists to remove — a
1247
1387
  // repository-wide mutation that every unrelated run then resolved through. The
@@ -1303,12 +1443,19 @@ export async function runConductEpicInit(deps, options) {
1303
1443
  * unavailable — each leaves its sub-object `null`, adds a `probe_errors` entry,
1304
1444
  * and the command still exits 0 with a complete object. The loop must be able
1305
1445
  * to see its own checkpoint during a GitHub outage.
1306
- * - **The write allowlist is exactly four fields** (`ci_last_poll`,
1307
- * `last_seen_head`, `last_state_change_at`, and a newly discovered
1308
- * `ticket.branch`) plus the normal `updated_at`. Everything else is
1309
- * observational. In particular the checkpoint stores no "expected head":
1310
- * merge identity always comes from a fresh `pr.head_sha`, and a stored
1446
+ * - **The write allowlist is exactly five fields** (`ci_last_poll`,
1447
+ * `last_seen_head`, `last_state_change_at`, a newly discovered
1448
+ * `ticket.branch`, and since BAPI-963 an observed `ticket.pr_number`)
1449
+ * plus the normal `updated_at`, alongside the scope's fencing epoch. Everything
1450
+ * else is observational. In particular the checkpoint stores no "expected
1451
+ * head": merge identity always comes from a fresh `pr.head_sha`, and a stored
1311
1452
  * expectation would be a second source of truth that goes stale.
1453
+ *
1454
+ * `ticket.pr_number` is a SELF-HEAL, not a new source of truth. `status` already
1455
+ * derives the PR from GitHub independently, so the checkpoint field was pure
1456
+ * caller obligation — and when the pilot watcher died during BAPI-943, BAPI-946's
1457
+ * checkpoint still read `pr_number: null` long after PR #1144 had merged, exactly
1458
+ * when the driver had lost track and most needed the durable record.
1312
1459
  */
1313
1460
  export async function runConductEpicStatus(deps, options) {
1314
1461
  const accessProbe = await resolveAccess(deps);
@@ -1572,6 +1719,31 @@ export async function runConductEpicStatus(deps, options) {
1572
1719
  };
1573
1720
  }
1574
1721
  }
1722
+ // --- BAPI-963: what a `pending` scope is actually waiting on ----------------
1723
+ //
1724
+ // Read ONLY when the scope is `pending`, because that is the single state where
1725
+ // "who is going to move this?" is the operator's question. A fresh, blocked, or
1726
+ // failed scope is not waiting on a dispatcher, and issuing the request anyway
1727
+ // would add a round trip to every ordinary tick.
1728
+ //
1729
+ // Fail-open like every other status probe: a failed read records a bounded
1730
+ // probe error and reports `unavailable`, which renders the pre-existing
1731
+ // headline unchanged. Absence is claimed only on positive evidence.
1732
+ let dispatcher = null;
1733
+ if (access !== null && scope !== null && scope.freshness_status === "pending") {
1734
+ const health = await getParseDispatcherHealth(access, deps.fetchImpl);
1735
+ if (health.ok) {
1736
+ dispatcher = health.value;
1737
+ }
1738
+ else {
1739
+ probeErrors.push({ probe: "parse_dispatcher", reason: health.error });
1740
+ dispatcher = {
1741
+ observation: "unavailable",
1742
+ heartbeatState: null,
1743
+ respondingSchedulerRunning: false,
1744
+ };
1745
+ }
1746
+ }
1575
1747
  // --- BAPI-846: the repository's index scopes, and this epic's heartbeat ---
1576
1748
  //
1577
1749
  // TWO distinct jobs, both belonging here rather than in a daemon:
@@ -1635,13 +1807,17 @@ export async function runConductEpicStatus(deps, options) {
1635
1807
  ? false
1636
1808
  : null,
1637
1809
  };
1638
- // --- the five permitted writes -------------------------------------------
1639
- // BAPI-846 added the fifth: the scope's fencing epoch, refreshed from the
1640
- // server's authoritative answer. It rides in the SAME atomic write as the other
1641
- // four rather than in a second one, so a tick either records everything it
1642
- // observed or nothing.
1810
+ // --- the permitted writes ------------------------------------------------
1811
+ // BAPI-846 added the scope's fencing epoch, refreshed from the server's
1812
+ // authoritative answer; BAPI-963 added the observed `ticket.pr_number`. Every
1813
+ // one of them rides in the SAME atomic write rather than in a second one, so a
1814
+ // tick either records everything it observed or nothing.
1643
1815
  let lastSeenHead = ticket?.last_seen_head ?? null;
1644
1816
  let lastStateChangeAt = ticket?.last_state_change_at ?? null;
1817
+ // BAPI-963: what the response PROJECTS for the in-flight ticket. It advances to
1818
+ // the mutated clone only after the atomic write proves durable, so the payload
1819
+ // cannot report a self-healed `pr_number` that never reached disk.
1820
+ let projectedTicket = ticket;
1645
1821
  const leaseEpochChanged = nextLeaseEpoch !== checkpoint.index_scope_lease_epoch;
1646
1822
  if (ticket !== null) {
1647
1823
  const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
@@ -1659,6 +1835,15 @@ export async function runConductEpicStatus(deps, options) {
1659
1835
  lastStateChangeAt = updatedTicket.last_state_change_at;
1660
1836
  dirty = true;
1661
1837
  }
1838
+ // BAPI-963: self-heal the PR number from the PR this tick already observed.
1839
+ // `pr.number` is the validated positive-integer-or-null the PR-state parser
1840
+ // produced, so nothing unvalidated reaches the checkpoint. No PR observed
1841
+ // means no write at all — a null observation must never erase a number a
1842
+ // previous tick durably recorded.
1843
+ if (pr !== null && pr.number !== null && pr.number !== updatedTicket.pr_number) {
1844
+ updatedTicket.pr_number = pr.number;
1845
+ dirty = true;
1846
+ }
1662
1847
  next.tickets[index] = updatedTicket;
1663
1848
  if (ci?.ci_last_poll) {
1664
1849
  next.ci_last_poll = ci.ci_last_poll;
@@ -1673,8 +1858,15 @@ export async function runConductEpicStatus(deps, options) {
1673
1858
  const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
1674
1859
  skipChmod: deps.platform === "win32",
1675
1860
  });
1676
- if (!written.ok)
1861
+ if (!written.ok) {
1677
1862
  probeErrors.push({ probe: "checkpoint_write", reason: written.error });
1863
+ }
1864
+ else {
1865
+ // Project what was actually PERSISTED. A failed write leaves the response
1866
+ // reporting the original stored value, so the payload never claims a
1867
+ // durable record that is not on disk.
1868
+ projectedTicket = updatedTicket;
1869
+ }
1678
1870
  }
1679
1871
  }
1680
1872
  const payload = {
@@ -1684,7 +1876,9 @@ export async function runConductEpicStatus(deps, options) {
1684
1876
  checkpoint_path: checkpointPath,
1685
1877
  checkpoint_exists: true,
1686
1878
  all_done: allDone,
1687
- ticket: ticket === null ? null : projectConductEpicTicketFacts(ticket, discoveredBranch),
1879
+ ticket: projectedTicket === null
1880
+ ? null
1881
+ : projectConductEpicTicketFacts(projectedTicket, discoveredBranch),
1688
1882
  worktree_path: worktreePath,
1689
1883
  worktree_exists: worktreeExists,
1690
1884
  branch_head: branchHead,
@@ -1703,6 +1897,9 @@ export async function runConductEpicStatus(deps, options) {
1703
1897
  elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
1704
1898
  },
1705
1899
  scope,
1900
+ // BAPI-963: `observed` | `absent` | `unavailable`, or null when the question
1901
+ // did not arise (no scope, no access, or a scope that is not `pending`).
1902
+ parse_dispatcher: dispatcher === null ? null : dispatcher.observation,
1706
1903
  scope_lease_epoch: nextLeaseEpoch,
1707
1904
  retention_seconds: retentionSeconds,
1708
1905
  scopes,
@@ -1711,7 +1908,7 @@ export async function runConductEpicStatus(deps, options) {
1711
1908
  probe_errors: probeErrors,
1712
1909
  };
1713
1910
  return emitSuccess(deps, options.json, payload, [
1714
- ...renderScopeFreshnessLines(scope),
1911
+ ...renderScopeFreshnessLines(scope, dispatcher),
1715
1912
  ...renderStrandedScopeLines(scopes, declaredScopeId),
1716
1913
  ]);
1717
1914
  }
@@ -1770,12 +1967,20 @@ function renderStrandedScopeLines(scopes, ownScopeId) {
1770
1967
  }
1771
1968
  return lines;
1772
1969
  }
1773
- function renderScopeFreshnessLines(scope) {
1970
+ function renderScopeFreshnessLines(scope, dispatcher = null) {
1774
1971
  if (scope === null)
1775
1972
  return [];
1973
+ // BAPI-963: `pending` gets a sharper headline when nothing is observed sweeping
1974
+ // the parse queue — "Waiting for index refresh" implies autonomous progress
1975
+ // that, with no dispatcher, is never going to happen. Only a POSITIVE `absent`
1976
+ // observation changes the wording; `observed` and `unavailable` both keep the
1977
+ // original line, because an unavailable read is not evidence of absence.
1978
+ const dispatcherAbsent = dispatcher !== null && dispatcher.observation === "absent";
1776
1979
  const headline = {
1777
1980
  fresh: "Index is fresh for this epic.",
1778
- pending: "Waiting for index refresh.",
1981
+ pending: dispatcherAbsent
1982
+ ? "Waiting for live parse dispatcher."
1983
+ : "Waiting for index refresh.",
1779
1984
  blocked: "Index refresh is BLOCKED — this advance will not be indexed.",
1780
1985
  failed: "Index generation FAILED for this scope.",
1781
1986
  unavailable: "Index freshness is unavailable — treat as not fresh.",
@@ -1799,6 +2004,9 @@ function renderScopeFreshnessLines(scope) {
1799
2004
  const actionLine = action[scope.freshness_status];
1800
2005
  if (actionLine !== undefined)
1801
2006
  lines.push(actionLine);
2007
+ if (scope.freshness_status === "pending" && dispatcherAbsent) {
2008
+ lines.push(" No parse dispatcher was observed. The scheduled refresh cannot begin until", " the `worker:` dyno is sweeping the parse queue.");
2009
+ }
1802
2010
  lines.push(` lifecycle: ${scope.lifecycle_state ?? "unknown"}`, ` Required commit: ${scope.required_commit_sha ?? "none"}`, ` Indexed commit: ${scope.indexed_commit_sha ?? "none"}`);
1803
2011
  if (scope.freshness_status === "unobserved_advance") {
1804
2012
  lines.push(" Both watermarks agree — at a commit the branch has left behind, which is", " why this is not `fresh`. Run `conduct-epic catch-up <EPIC>` to repair it.");
@@ -2427,6 +2635,18 @@ export async function runConductEpicFinish(deps, options) {
2427
2635
  deps.errorLog(`The index scope was not retired: ${scopeRetirementError}. ` +
2428
2636
  `Retry with \`conduct-epic retire ${checkpoint.epic_key}\`.`);
2429
2637
  }
2638
+ // --- Draft epic-integration PR (BAPI-951) --------------------------------
2639
+ // Wind-down: ensure the PR exists AND request it be marked ready for human
2640
+ // review. Fail-open, alongside scope retirement — a failure here never rolls
2641
+ // back retirement or fails `finish`, which has already released the lock.
2642
+ const integrationPrAdvisory = await ensureEpicIntegrationPrAdvisory(deps, {
2643
+ epicKey: checkpoint.epic_key,
2644
+ epicBranch: checkpoint.epic_branch,
2645
+ baseBranch: checkpoint.base_branch_original,
2646
+ command: "conduct-epic finish",
2647
+ requestReady: true,
2648
+ });
2649
+ deps.errorLog(`Integration PR: ${JSON.stringify(integrationPrAdvisory)}`);
2430
2650
  const summary = {
2431
2651
  ok: true,
2432
2652
  epic_key: checkpoint.epic_key,
@@ -2593,7 +2813,35 @@ export async function runConductEpicCatchUp(deps, options) {
2593
2813
  required_commit_sha: value.required_commit_sha,
2594
2814
  parse_scheduled: value.parse_scheduled,
2595
2815
  };
2596
- const lines = renderCatchUpLines(value, target.scopeId);
2816
+ // BAPI-963. Catch-up says "a re-parse is scheduled", but NOTHING in the pilot
2817
+ // dispatches it: the queue is swept by the APScheduler job in `worker.py`'s job
2818
+ // set, and the web dyno runs with `DISABLE_SCHEDULER=true`. During the BAPI-943
2819
+ // pilot the first catch-up worked only because an unrelated v2 run's server
2820
+ // happened to be sweeping; had that process stayed dead, the scope would have
2821
+ // sat at `pending` forever with no diagnostic. Read the dispatcher only when a
2822
+ // parse was actually scheduled, so an ordinary catch-up adds no request.
2823
+ let dispatcher = null;
2824
+ if (value.ok && value.parse_scheduled) {
2825
+ const health = await getParseDispatcherHealth(accessProbe.access, deps.fetchImpl);
2826
+ // Advisory: a failed read leaves the successful catch-up successful and simply
2827
+ // says nothing, rather than claiming a dispatcher is missing.
2828
+ if (health.ok)
2829
+ dispatcher = health.value;
2830
+ }
2831
+ const lines = renderCatchUpLines(value, target.scopeId, dispatcher);
2832
+ // --- Draft epic-integration PR (BAPI-951) --------------------------------
2833
+ // Retry point for deferred provisioning: only after a SUCCESSFUL reconciliation
2834
+ // (`value.ok`), and only when the checkpoint carries the epic/base branches this
2835
+ // needs (absent for an explicit `--scope` target with no local checkpoint).
2836
+ if (value.ok && target.checkpoint !== null) {
2837
+ const formatted = await ensureEpicIntegrationPrAdvisory(deps, {
2838
+ epicKey: options.epicKey,
2839
+ epicBranch: target.checkpoint.epic_branch,
2840
+ baseBranch: target.checkpoint.base_branch_original,
2841
+ command: "conduct-epic catch-up",
2842
+ });
2843
+ lines.push(`integration pr: ${JSON.stringify(formatted)}`);
2844
+ }
2597
2845
  return value.ok
2598
2846
  ? emitSuccess(deps, options.json, payload, lines)
2599
2847
  : emitFailure(deps, options.json, lines, payload);
@@ -2607,8 +2855,17 @@ export async function runConductEpicCatchUp(deps, options) {
2607
2855
  * block needs the BRANCH fixed, a history-limit refusal needs a fresh scope, and
2608
2856
  * an `unavailable` needs the provider to come back.
2609
2857
  */
2610
- function renderCatchUpLines(value, scopeId) {
2858
+ function renderCatchUpLines(value, scopeId, dispatcher = null) {
2611
2859
  const pin = ` Required commit: ${value.required_commit_sha ?? "unknown"}`;
2860
+ // BAPI-963: appended only on POSITIVE evidence that nothing is sweeping. An
2861
+ // `unavailable` observation says nothing, because an advisory that cries wolf
2862
+ // on a network blip is one an operator learns to skip.
2863
+ const waiting = dispatcher !== null && dispatcher.observation === "absent"
2864
+ ? [
2865
+ " WAITING ON: a live parse dispatcher. No dispatcher was observed, so the",
2866
+ " scheduled re-parse cannot begin until the `worker:` dyno is sweeping the parse queue.",
2867
+ ]
2868
+ : [];
2612
2869
  switch (value.outcome) {
2613
2870
  case "repaired":
2614
2871
  return [
@@ -2618,6 +2875,7 @@ function renderCatchUpLines(value, scopeId) {
2618
2875
  value.parse_scheduled
2619
2876
  ? " A re-parse is scheduled. Poll `conduct-epic status` until freshness reads `fresh`."
2620
2877
  : " No re-parse was scheduled; poll `conduct-epic status` for the scope's own state.",
2878
+ ...waiting,
2621
2879
  ];
2622
2880
  case "already_current":
2623
2881
  return [
@@ -2631,6 +2889,7 @@ function renderCatchUpLines(value, scopeId) {
2631
2889
  ? " A re-parse is scheduled. Poll `conduct-epic status` until freshness reads `fresh`."
2632
2890
  : ` No re-parse was scheduled (${value.reason ?? "the scope is not schedulable right now"}).`,
2633
2891
  pin,
2892
+ ...waiting,
2634
2893
  ];
2635
2894
  case "race_lost":
2636
2895
  return [
@@ -2780,6 +3039,63 @@ export async function runConductEpicReclaim(deps, options) {
2780
3039
  " `reclaimed`.",
2781
3040
  ]);
2782
3041
  }
3042
+ /**
3043
+ * `conduct-epic scopes` — the repository-wide index-scope inventory (BAPI-963).
3044
+ *
3045
+ * `--help` promised this listing under `status` and no invocation delivered it:
3046
+ * `conduct-epic scopes` answered `Unknown verb` and `status <EPIC> --scopes`
3047
+ * answered `Unknown flag`, so an operator asking exactly the question the help
3048
+ * advertised was sent to SQL. The capability already existed — `status` pushes a
3049
+ * stranded-scope warning to stderr — only its documented entry point did not.
3050
+ *
3051
+ * Repository-scoped by construction: it resolves access, reads the lifecycle
3052
+ * listing ONCE, and takes no epic key, no checkpoint, and no lease.
3053
+ */
3054
+ export async function runConductEpicScopes(deps, options) {
3055
+ const accessProbe = await resolveAccess(deps);
3056
+ if (!accessProbe.ok)
3057
+ return emitFailure(deps, options.json, [accessProbe.error]);
3058
+ const listing = await getIndexScopeLifecycle(accessProbe.access, deps.fetchImpl);
3059
+ if (!listing.ok) {
3060
+ return emitFailure(deps, options.json, [
3061
+ `The index-scope inventory could not be read: ${listing.error}`,
3062
+ ]);
3063
+ }
3064
+ return emitSuccess(deps, options.json, {
3065
+ ok: true,
3066
+ repo_name: accessProbe.access.repoName,
3067
+ retention_seconds: listing.value.retention_seconds,
3068
+ // The COMPLETE listing, unfiltered. `status` narrows deliberately; this verb
3069
+ // exists because that narrowing is the wrong answer to "what exists?".
3070
+ scopes: listing.value.scopes,
3071
+ }, renderIndexScopeInventoryLines(listing.value.scopes, accessProbe.access.repoName));
3072
+ }
3073
+ /**
3074
+ * Render the FULL scope inventory for `conduct-epic scopes` (BAPI-963).
3075
+ *
3076
+ * Deliberately unlike {@link renderStrandedScopeLines}, which stays narrow: that
3077
+ * one prints only actionable wreckage on a status tick an operator reads every
3078
+ * few minutes, and widening it would turn a warning into wallpaper. This one
3079
+ * answers a question the operator asked on purpose, so it hides nothing —
3080
+ * reclaimed tombstones included, since "it is already gone" is an answer.
3081
+ */
3082
+ function renderIndexScopeInventoryLines(scopes, repoName) {
3083
+ if (scopes.length === 0) {
3084
+ return [`${repoName} owns no index scopes.`];
3085
+ }
3086
+ const lines = [`${repoName} owns ${scopes.length} index scope(s):`];
3087
+ for (const scope of scopes) {
3088
+ lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
3089
+ `branch=${scope.feature_branch ?? "unknown"} ` +
3090
+ `lease=${scope.lease_valid ? "live" : "expired"} ` +
3091
+ `retention=${scope.retention_elapsed ? "elapsed" : (scope.retention_deadline ?? "none")} ` +
3092
+ `recoverable=${scope.recoverable ? "yes" : "no"}`);
3093
+ if (scope.blockers.length > 0) {
3094
+ lines.push(` blocked by: ${scope.blockers.join(", ")}`);
3095
+ }
3096
+ }
3097
+ return lines;
3098
+ }
2783
3099
  // ---------------------------------------------------------------------------
2784
3100
  // Entry point
2785
3101
  // ---------------------------------------------------------------------------
@@ -2805,6 +3121,8 @@ export async function runConductEpicCli(argv, overrides = {}) {
2805
3121
  }
2806
3122
  const options = parsed.options;
2807
3123
  switch (options.verb) {
3124
+ case "scopes":
3125
+ return runConductEpicScopes(deps, options);
2808
3126
  case "init":
2809
3127
  return runConductEpicInit(deps, options);
2810
3128
  case "status":