@bridge_gpt/mcp-server 0.2.53 → 0.2.55

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 (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -0,0 +1,392 @@
1
+ /**
2
+ * The shared EXACT-CUT protocol and scope-readiness poll (BAPI-843; BAPI-850).
3
+ *
4
+ * Extracted from `implement-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 `implement-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
+ * The fixed lifecycle label a heartbeat uses when the status READ itself failed
282
+ * (BAPI-963).
283
+ *
284
+ * A read failure is not a lifecycle state, and the raw error is deliberately not
285
+ * interpolated into a heartbeat: a per-poll line repeated for twenty minutes is
286
+ * the worst possible place to smuggle unbounded server text.
287
+ */
288
+ export const SCOPE_BOOTSTRAP_UNREADABLE_STATE = "unreadable";
289
+ /**
290
+ * Format one bootstrap heartbeat line (BAPI-963).
291
+ *
292
+ * Shared with `setup-epic` at this seam so both conductors compute progress the
293
+ * same way; RENDERING stays with each caller, because the pilot writes to its own
294
+ * stderr advisory channel and v2 reports progress server-side.
295
+ *
296
+ * The shape is fixed and grep-friendly — elapsed first, state second — because a
297
+ * ~30-minute seed that printed nothing was externally indistinguishable from a
298
+ * hang (sleeping process, 0% CPU, a frozen `updated_at`). Elapsed seconds are
299
+ * clamped at zero so a clock adjustment cannot render a negative age.
300
+ */
301
+ export function formatScopeBootstrapHeartbeat(elapsedMs, state) {
302
+ const seconds = Math.max(0, Math.floor(elapsedMs / 1000));
303
+ // `lifecycle_state` is a required non-empty string on the wire, not a closed
304
+ // set. Bounding it to the known labels (plus the fixed `unreadable` and a
305
+ // catch-all) keeps an unvalidated server string out of a line that repeats
306
+ // every interval for up to twenty minutes.
307
+ return `Seeding scope: elapsed=${seconds}s state=${boundScopeLifecycleLabel(state)}`;
308
+ }
309
+ /** The label an unrecognized lifecycle state collapses to. Never `null`, never raw. */
310
+ export const SCOPE_BOOTSTRAP_UNKNOWN_STATE = "unknown";
311
+ /**
312
+ * Collapse any lifecycle state to a bounded, safe label (BAPI-1054).
313
+ *
314
+ * `lifecycle_state` is a required non-empty string on the wire, not a closed set,
315
+ * so an unrecognized value is possible and an error-bearing one
316
+ * (`unreadable (<server error>)`) is routine. Extracted from
317
+ * {@link formatScopeBootstrapHeartbeat}, which has always applied this rule, so
318
+ * every surface that renders a state applies the SAME one — a second spelling of
319
+ * "bounded" is how raw server text eventually reaches a terminal.
320
+ */
321
+ export function boundScopeLifecycleLabel(state) {
322
+ return state === SCOPE_BOOTSTRAP_UNREADABLE_STATE || state in SCOPE_LIFECYCLE_LABELS
323
+ ? state
324
+ : SCOPE_BOOTSTRAP_UNKNOWN_STATE;
325
+ }
326
+ /**
327
+ * Describe the nominal polling window up front (BAPI-963).
328
+ *
329
+ * Computed from the interval and cap rather than hard-coded, so a change to
330
+ * either constant cannot leave the operator-facing duration claim stale. The
331
+ * window is NOMINAL: the observed pilot seed outran even this bound, which is
332
+ * why the wording promises a poll cadence rather than a completion time.
333
+ */
334
+ export function describeScopeBootstrapWindow(intervalMs = SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, maxPolls = SCOPE_BOOTSTRAP_MAX_POLLS) {
335
+ const intervalSeconds = Math.max(1, Math.round(intervalMs / 1000));
336
+ const windowMinutes = Math.max(1, Math.round((intervalMs * maxPolls) / 60_000));
337
+ return (`Seeding the epic's index scope. This copies the repository's whole parse cache and ` +
338
+ `verifies it, and commonly takes many minutes. Progress is reported every ` +
339
+ `${intervalSeconds}s; the poll gives up after about ${windowMinutes} minutes.`);
340
+ }
341
+ /**
342
+ * Poll a scope's lifecycle until it is `ready`, `failed`, or the bounded wait
343
+ * elapses, reporting each NEWLY observed lifecycle transition exactly once, in
344
+ * order, through `onTransition`.
345
+ *
346
+ * Readiness is a server-side fact this poll observes rather than concludes from
347
+ * any request of its own: `ready` is accepted ONLY when the status also proves
348
+ * `indexed_commit_sha == cut_commit_sha`. A `ready` whose watermark disagrees is
349
+ * reported as `ready_mismatch` — the control plane contradicting itself, which
350
+ * is worth refusing rather than proceeding on.
351
+ *
352
+ * A transient read failure is not a verdict: the poll keeps going and lets the
353
+ * bound be the thing that gives up. The interval and cap are the shared
354
+ * {@link SCOPE_BOOTSTRAP_POLL_INTERVAL_MS} / {@link SCOPE_BOOTSTRAP_MAX_POLLS}.
355
+ */
356
+ export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {}) {
357
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
358
+ const maxPolls = options.maxPolls ?? SCOPE_BOOTSTRAP_MAX_POLLS;
359
+ const intervalMs = options.intervalMs ?? SCOPE_BOOTSTRAP_POLL_INTERVAL_MS;
360
+ const now = options.now ?? (() => new Date());
361
+ const startedAtMs = now().getTime();
362
+ let lastState = "unknown";
363
+ let lastStatus = null;
364
+ let lastReportedState = null;
365
+ for (let poll = 0; poll < maxPolls; poll += 1) {
366
+ await sleep(intervalMs);
367
+ const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
368
+ if (!status.ok) {
369
+ lastState = `unreadable (${status.error})`;
370
+ options.onPoll?.(now().getTime() - startedAtMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE);
371
+ continue;
372
+ }
373
+ lastStatus = status.value;
374
+ lastState = status.value.lifecycle_state;
375
+ options.onPoll?.(now().getTime() - startedAtMs, lastState);
376
+ if (lastState !== lastReportedState) {
377
+ lastReportedState = lastState;
378
+ options.onTransition?.(lastState, status.value);
379
+ }
380
+ if (lastState === "ready") {
381
+ if (status.value.indexed_commit_sha !== null &&
382
+ status.value.indexed_commit_sha === status.value.cut_commit_sha) {
383
+ return { kind: "ready", status: status.value };
384
+ }
385
+ return { kind: "ready_mismatch", status: status.value };
386
+ }
387
+ if (lastState === "failed") {
388
+ return { kind: "failed", status: status.value, reason: status.value.last_error ?? "unknown" };
389
+ }
390
+ }
391
+ return { kind: "timeout", lastState, lastStatus };
392
+ }