@bridge_gpt/mcp-server 0.2.10 → 0.2.12

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 (37) hide show
  1. package/README.md +3 -3
  2. package/build/commands.generated.js +6 -6
  3. package/build/conductor/bridge-api-client.js +2 -1
  4. package/build/conductor/cli.js +16 -16
  5. package/build/conductor/doctor.js +1 -1
  6. package/build/conductor/epic-reconcile.js +213 -16
  7. package/build/conductor/epic-runtime.js +89 -6
  8. package/build/conductor/epic-state.js +85 -11
  9. package/build/conductor/errors.js +12 -0
  10. package/build/conductor/git-ci-types.js +10 -0
  11. package/build/conductor/git-producer.js +4 -4
  12. package/build/conductor/merge-ledger.js +7 -7
  13. package/build/conductor/pr-ci-producer.js +6 -6
  14. package/build/conductor/pr-review-producer.js +2 -2
  15. package/build/conductor/producer-ledger.js +5 -5
  16. package/build/conductor/spec-review-producer.js +88 -0
  17. package/build/conductor/store.js +97 -25
  18. package/build/conductor/supervisor-ledger.js +2 -2
  19. package/build/conductor/supervisor-merge.js +5 -5
  20. package/build/conductor/supervisor-message-relay.js +1 -1
  21. package/build/conductor/supervisor-runtime.js +10 -10
  22. package/build/conductor/taxonomy.js +5 -0
  23. package/build/conductor/tools.js +5 -5
  24. package/build/conductor-bin.js +12350 -19
  25. package/build/conductor-claude-hook-bin.js +167 -17
  26. package/build/decision-page-schema.js +26 -0
  27. package/build/doctor.js +200 -0
  28. package/build/index.js +23705 -3630
  29. package/build/install-bridge.js +80 -0
  30. package/build/pipelines.generated.js +70 -48
  31. package/build/readme.generated.js +1 -1
  32. package/build/version.generated.js +1 -1
  33. package/package.json +7 -4
  34. package/pipelines/check-ci-ticket.json +2 -2
  35. package/pipelines/implement-ticket.json +2 -2
  36. package/pipelines/learn-repository.json +84 -42
  37. package/smoke-test/SMOKE-TEST.md +11 -17
@@ -336,13 +336,13 @@ export function parseEmitEventArgs(argv, deps = {}) {
336
336
  return { input, json: bools.has("--json"), help: false };
337
337
  }
338
338
  /** Run the `emit-event` command. Prints the inserted event summary. */
339
- export function runEmitEventCommand(argv, deps = {}) {
339
+ export async function runEmitEventCommand(argv, deps = {}) {
340
340
  const parsed = parseEmitEventArgs(argv, deps);
341
341
  if (parsed.help) {
342
342
  console.log(getConductorUsage());
343
343
  return 0;
344
344
  }
345
- const result = emitConductorEvent(parsed.input);
345
+ const result = await emitConductorEvent(parsed.input);
346
346
  if (parsed.json) {
347
347
  console.log(JSON.stringify(result));
348
348
  }
@@ -442,13 +442,13 @@ export function parseSendMessageArgs(argv, deps = {}) {
442
442
  * Run `send-message`. Prints compact JSON when `--json` is set; otherwise a
443
443
  * sanitized human summary (message id / status / type only — NEVER the payload).
444
444
  */
445
- export function runSendMessageCommand(argv, deps = {}) {
445
+ export async function runSendMessageCommand(argv, deps = {}) {
446
446
  const parsed = parseSendMessageArgs(argv, deps);
447
447
  if (parsed.help) {
448
448
  console.log(getConductorUsage());
449
449
  return 0;
450
450
  }
451
- const result = sendWorkerMessage(parsed.input);
451
+ const result = await sendWorkerMessage(parsed.input);
452
452
  if (parsed.json) {
453
453
  console.log(JSON.stringify(result));
454
454
  }
@@ -495,13 +495,13 @@ export function parseCheckMessagesArgs(argv) {
495
495
  * Run `check-messages`. Prints compact JSON when `--json` is set; otherwise a
496
496
  * sanitized human summary (counts + per-message id/type only — NEVER payloads).
497
497
  */
498
- export function runCheckMessagesCommand(argv) {
498
+ export async function runCheckMessagesCommand(argv) {
499
499
  const parsed = parseCheckMessagesArgs(argv);
500
500
  if (parsed.help) {
501
501
  console.log(getConductorUsage());
502
502
  return 0;
503
503
  }
504
- const result = checkWorkerMessages(parsed.input);
504
+ const result = await checkWorkerMessages(parsed.input);
505
505
  if (parsed.json) {
506
506
  console.log(JSON.stringify(result));
507
507
  return 0;
@@ -590,7 +590,7 @@ export function parseGitHookArgs(argv) {
590
590
  * warning) so a conductor producer failure never blocks the git commit/ref update
591
591
  * the hook is attached to.
592
592
  */
593
- export function runGitHookCommand(argv) {
593
+ export async function runGitHookCommand(argv) {
594
594
  let parsed;
595
595
  try {
596
596
  parsed = parseGitHookArgs(argv);
@@ -602,7 +602,7 @@ export function runGitHookCommand(argv) {
602
602
  }
603
603
  try {
604
604
  if (parsed.subcommand === "post-commit") {
605
- runPostCommitHookProducer();
605
+ await runPostCommitHookProducer();
606
606
  return 0;
607
607
  }
608
608
  // reference-transaction: read the captured updates from the stdin file.
@@ -625,7 +625,7 @@ export function runGitHookCommand(argv) {
625
625
  }
626
626
  }
627
627
  }
628
- runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
628
+ await runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
629
629
  return 0;
630
630
  }
631
631
  catch {
@@ -996,13 +996,13 @@ export async function runSuperviseCommand(argv) {
996
996
  return result.exit_code;
997
997
  }
998
998
  /** Run the explicit `purge` command. Prints deleted row counts. */
999
- export function runPurgeCommand(argv) {
999
+ export async function runPurgeCommand(argv) {
1000
1000
  const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
1001
1001
  if (bools.has("--help")) {
1002
1002
  console.log(getConductorUsage());
1003
1003
  return 0;
1004
1004
  }
1005
- const result = purgeConductorLedger();
1005
+ const result = await purgeConductorLedger();
1006
1006
  if (bools.has("--json")) {
1007
1007
  console.log(JSON.stringify(result));
1008
1008
  return 0;
@@ -1035,7 +1035,7 @@ export async function runConductorCli(argv) {
1035
1035
  try {
1036
1036
  switch (parsed.command) {
1037
1037
  case "emit-event":
1038
- return runEmitEventCommand(parsed.argv);
1038
+ return await runEmitEventCommand(parsed.argv);
1039
1039
  case "supervise":
1040
1040
  return await runSuperviseCommand(parsed.argv);
1041
1041
  case "epic-tick":
@@ -1045,17 +1045,17 @@ export async function runConductorCli(argv) {
1045
1045
  case "epic-status":
1046
1046
  return await runEpicStatusCommand(parsed.argv);
1047
1047
  case "send-message":
1048
- return runSendMessageCommand(parsed.argv);
1048
+ return await runSendMessageCommand(parsed.argv);
1049
1049
  case "check-messages":
1050
- return runCheckMessagesCommand(parsed.argv);
1050
+ return await runCheckMessagesCommand(parsed.argv);
1051
1051
  case "doctor":
1052
1052
  return await runDoctorCommand(parsed.argv);
1053
1053
  case "purge":
1054
- return runPurgeCommand(parsed.argv);
1054
+ return await runPurgeCommand(parsed.argv);
1055
1055
  case "install-git-hooks":
1056
1056
  return runInstallGitHooksCommand(parsed.argv);
1057
1057
  case "git-hook":
1058
- return runGitHookCommand(parsed.argv);
1058
+ return await runGitHookCommand(parsed.argv);
1059
1059
  default:
1060
1060
  console.error('Error: Unknown command. Run "conductor --help" for usage.');
1061
1061
  return 1;
@@ -116,7 +116,7 @@ export async function buildConductorDoctorReport(deps = {}) {
116
116
  const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
117
117
  const mcp_profile = inspectMcpProfile(deps.env ?? process.env, epicTick);
118
118
  return {
119
- ledger: doctorLedger(),
119
+ ledger: await doctorLedger(),
120
120
  git_hooks: inspectHooks(deps.hooksDeps),
121
121
  epic_tick: epicTick,
122
122
  mcp_profile,
@@ -17,7 +17,7 @@
17
17
  * All durable mutations go through injected seams so the logic is testable
18
18
  * without real network, ledger, or terminal access.
19
19
  */
20
- import { computeReadySet, decideRemediation } from "./epic-state.js";
20
+ import { computeReadySet, decideRemediation, DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS } from "./epic-state.js";
21
21
  import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
22
22
  // ---------------------------------------------------------------------------
23
23
  // reconcileEpic
@@ -84,21 +84,26 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
84
84
  const readySet = computeReadySet(plan, observed.ticket_statuses);
85
85
  // Step 3: Dispatch each ready ticket idempotently.
86
86
  //
87
- // NOTE (BAPI-442): an earlier draft performed a synchronous two-phase
88
- // planned ready_for_review ready spec re-review here it spawned
89
- // /review-ticket and then dispatched implementation in the SAME tick. That did
90
- // not actually gate implementation on the review (the verdict was never
91
- // consulted and the review run_id was discarded), and it overloaded the
92
- // BAPI-436 "ready_for_review" (awaiting-merge) state, leaving a liveness gap
93
- // if the conductor crashed between the CAS and the dispatch. That path has
94
- // been removed. `auto_rereview_enabled` is reserved until real review-gating
95
- // a distinct `reviewing` status, review-run correlation, multi-tick re-entry,
96
- // and a spec-review verdict signal is built (BAPI-445). Until then a ready
97
- // ticket dispatches implementation directly regardless of the flag.
87
+ // BAPI-445 pre-implementation spec re-review sequencing gate (Phase 1). When
88
+ // `auto_rereview_enabled` is on and the review seam is wired, a `planned` ready
89
+ // ticket must first pass a spec re-review: this loop advances it
90
+ // planned reviewing, spawns the review run under a `:review` dispatch role,
91
+ // correlates its run_id, and STOPS implementation does NOT dispatch in the
92
+ // same tick (honouring the product directive: "wait for review to complete
93
+ // before implementation begins"). The reviewing ready (or blocked) fold
94
+ // happens on a later tick once the review completes/votes; only THEN does this
95
+ // loop dispatch implementation. Gating only on `planned` (not `ready`) is
96
+ // deliberate: a `ready` ticket already passed review (reviewing ready) or the
97
+ // flag is off, so re-gating it would loop forever.
98
98
  for (const ticketKey of readySet) {
99
- if (supervisorConfig?.auto_rereview_enabled) {
100
- deps.log(`[epic-reconcile] auto_rereview_enabled is set but review-gating is not yet ` +
101
- `implemented (BAPI-445); dispatching ${ticketKey} directly`);
99
+ const currentStatus = observed.ticket_statuses.get(ticketKey) ?? "planned";
100
+ if (supervisorConfig?.auto_rereview_enabled &&
101
+ deps.dispatchReviewSeam &&
102
+ currentStatus === "planned") {
103
+ await enterSpecReview(observed, deps, result, escalate, ticketKey);
104
+ // Either the review was dispatched, or a benign race/failure was logged;
105
+ // in every case implementation must NOT dispatch this tick.
106
+ continue;
102
107
  }
103
108
  let claimResult;
104
109
  try {
@@ -155,6 +160,115 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
155
160
  result.warnings.push(`correlate-failed for ${ticketKey}: ${safeMsg}`);
156
161
  }
157
162
  }
163
+ // Step 3.2: Reviewing recovery (BAPI-445 Phase 1 re-entry). computeReadySet
164
+ // never surfaces a `reviewing` ticket (it is mid-flight, not ready), so a
165
+ // ticket whose spec-review run died before completing would be stranded
166
+ // forever without this pass. Iterate `reviewing` tickets explicitly (mirroring
167
+ // how the remediation pass iterates `blocked` tickets): if the review run is
168
+ // dead and the dedicated review-attempt cap is not yet exhausted, reclaim an
169
+ // attempt-scoped `:review` dispatch key and re-dispatch the review; once the
170
+ // cap is exhausted, escalate. This uses its OWN attempt counter — it never
171
+ // touches the BAPI-441 remediation budget.
172
+ if (supervisorConfig?.auto_rereview_enabled &&
173
+ deps.dispatchReviewSeam &&
174
+ deps.readReviewWorkerLiveness) {
175
+ const dispatchReviewSeam = deps.dispatchReviewSeam;
176
+ const readReviewWorkerLiveness = deps.readReviewWorkerLiveness;
177
+ const maxReviewAttempts = supervisorConfig.max_spec_review_attempts ?? DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS;
178
+ for (const [ticketKey, status] of observed.ticket_statuses) {
179
+ if (status !== "reviewing")
180
+ continue;
181
+ try {
182
+ const liveness = await readReviewWorkerLiveness(observed.epic_key, ticketKey);
183
+ if (liveness.alive)
184
+ continue; // review still running — wait across ticks
185
+ const priorAttempts = deps.countReviewAttempts
186
+ ? deps.countReviewAttempts(ticketKey)
187
+ : 0;
188
+ if (priorAttempts >= maxReviewAttempts) {
189
+ await escalate(observed.epic_key, `spec-review-liveness-exhausted:${ticketKey}`);
190
+ deps.log(`[epic-reconcile] spec-review liveness exhausted for ${ticketKey} ` +
191
+ `(attempts=${priorAttempts} max=${maxReviewAttempts}) → escalate`);
192
+ continue;
193
+ }
194
+ // Reclaim a fresh attempt-scoped review dispatch key. `priorAttempts`
195
+ // (>= 1 once the initial review dispatched) doubles as the next attempt
196
+ // index, so the key differs from every prior review run's key.
197
+ const attempt = priorAttempts;
198
+ let claim;
199
+ try {
200
+ claim = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version, "review", attempt);
201
+ }
202
+ catch (err) {
203
+ const safeMsg = err instanceof Error ? err.constructor.name : "claim error";
204
+ result.warnings.push(`review-reclaim-error for ${ticketKey}: ${safeMsg}`);
205
+ continue;
206
+ }
207
+ if (!claim.ok) {
208
+ result.warnings.push(`review-dispatch-key lease-held for ${ticketKey}`);
209
+ continue;
210
+ }
211
+ if (claim.kind !== "claimed") {
212
+ if (claim.kind === "already-exists" && claim.dispatch.run_id === null) {
213
+ result.warnings.push(`review-dispatch-orphan: ${ticketKey} has pending review dispatch key with no run_id`);
214
+ await escalate(observed.epic_key, `dispatch-orphan:${ticketKey}`);
215
+ }
216
+ continue;
217
+ }
218
+ const dispatchKey = claim.dispatch.dispatch_key;
219
+ let runId;
220
+ try {
221
+ runId = await dispatchReviewSeam(observed.epic_key, ticketKey, attempt);
222
+ }
223
+ catch (err) {
224
+ const safeMsg = err instanceof Error ? err.constructor.name : "dispatch error";
225
+ result.warnings.push(`review-redispatch-failed for ${ticketKey}: ${safeMsg}`);
226
+ continue;
227
+ }
228
+ try {
229
+ await deps.correlateRunId(dispatchKey, runId);
230
+ deps.log(`[epic-reconcile] re-dispatched spec re-review for ${ticketKey} ` +
231
+ `run_id=${runId} attempt=${attempt}`);
232
+ }
233
+ catch (err) {
234
+ const safeMsg = err instanceof Error ? err.constructor.name : "correlate error";
235
+ result.warnings.push(`review-correlate-failed for ${ticketKey}: ${safeMsg}`);
236
+ }
237
+ }
238
+ catch (err) {
239
+ const safeMsg = err instanceof Error ? err.constructor.name : "reviewing recovery error";
240
+ result.warnings.push(`reviewing-recovery-error for ${ticketKey}: ${safeMsg}`);
241
+ }
242
+ }
243
+ }
244
+ // Step 3.4: Spec-review verdict gate (BAPI-445 Phase 2). A ticket blocked by a
245
+ // changes-requested SPEC review is a spec problem, not a code bug: escalate it
246
+ //
247
+ // NOTE: this block is reachable only once the verdict EMISSION primitive
248
+ // (`emitSpecReviewVerdict` in spec-review-producer.ts) is wired into the
249
+ // review-ticket completion flow so a `spec_review.changes_requested` event is
250
+ // folded to `blocked` with this reason. Until that Phase 2 integration lands,
251
+ // no ticket carries the `spec_review.changes_requested` blocked reason and this
252
+ // pass is a no-op. That is FAIL-SAFE: a completed review with no verdict does
253
+ // NOT advance `reviewing → ready` (epic-state filters review-run incidental
254
+ // terminals), so implementation never proceeds without an explicit pass —
255
+ // a stranded `reviewing` ticket is escalated by the recovery branch above.
256
+ // once for operator attention and EXCLUDE it from the BAPI-441 remediation pass
257
+ // below so it never spends the implementation remediation budget (no nudge, no
258
+ // redispatch, no budget counter increment). Runs independently of the
259
+ // remediation seams so a spec rejection is always surfaced.
260
+ const specRejectedTickets = new Set();
261
+ for (const [ticketKey, status] of observed.ticket_statuses) {
262
+ if (status !== "blocked")
263
+ continue;
264
+ if (observed.ticket_blocked_reasons?.get(ticketKey) !== "spec_review.changes_requested") {
265
+ continue;
266
+ }
267
+ specRejectedTickets.add(ticketKey);
268
+ await escalate(observed.epic_key, `spec-review-rejected:${ticketKey}`);
269
+ deps.log(`[epic-reconcile] spec-review changes-requested for ${ticketKey} → escalate ` +
270
+ `(remediation budget bypassed)`);
271
+ }
158
272
  // Step 3.5: Remediation pass (BAPI-441) — re-act on blocked tickets under
159
273
  // budget. Keyed off the folded "blocked" status + per-ticket counters, NOT a
160
274
  // computeReadySet change (the ready-set still returns only planned tickets).
@@ -173,6 +287,10 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
173
287
  for (const [ticketKey, status] of observed.ticket_statuses) {
174
288
  if (status !== "blocked")
175
289
  continue;
290
+ // BAPI-445: a spec-review rejection was already escalated in Step 3.4 and
291
+ // must never enter the implementation remediation budget. Skip it here.
292
+ if (specRejectedTickets.has(ticketKey))
293
+ continue;
176
294
  // Per-ticket try/catch so a single ticket's failure never aborts the pass.
177
295
  try {
178
296
  const counters = observed.ticket_remediation_counters?.get(ticketKey) ?? {
@@ -192,7 +310,10 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
192
310
  const attemptKind = decision;
193
311
  // The folding reason frames the nudge (message type + digest). Default to
194
312
  // the review path when the ledger no longer carries the blocking event.
195
- const reason = observed.ticket_blocked_reasons?.get(ticketKey) ?? "review.changes_requested";
313
+ // spec_review.changes_requested is impossible here (skipped above) but
314
+ // narrow explicitly so the remediation seams keep their tight reason type.
315
+ const blockedReason = observed.ticket_blocked_reasons?.get(ticketKey);
316
+ const reason = blockedReason === "ci.failed" ? "ci.failed" : "review.changes_requested";
196
317
  // A nudge needs a worker to address it to. The liveness scan already
197
318
  // resolved the worker id from the same heartbeat that proved the worker
198
319
  // alive; if it is missing we cannot relay, so skip BEFORE recording an
@@ -254,3 +375,79 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
254
375
  // Step 5: Post-action waits are scheduled inline above per-dispatch
255
376
  return result;
256
377
  }
378
+ // ---------------------------------------------------------------------------
379
+ // enterSpecReview (BAPI-445 Phase 1 — planned → reviewing + spawn review run)
380
+ // ---------------------------------------------------------------------------
381
+ /**
382
+ * Advance a `planned` ready ticket into the pre-implementation spec re-review:
383
+ * CAS planned → reviewing, claim the `:review` dispatch key, spawn the review
384
+ * run, and correlate its run_id. Every failure is absorbed as a per-ticket
385
+ * warning so the rest of the reconcile pass still runs.
386
+ *
387
+ * Deliberately does NOT mutate the local `observed.ticket_statuses` map: the CAS
388
+ * is durable in Postgres (the NEXT tick's snapshot shows `reviewing`), and
389
+ * leaving the local status as `planned` keeps the same-tick reviewing-recovery
390
+ * pass from mistaking this freshly-spawned review for a stranded one and
391
+ * double-dispatching it.
392
+ */
393
+ async function enterSpecReview(observed, deps, result, escalate, ticketKey) {
394
+ const dispatchReviewSeam = deps.dispatchReviewSeam;
395
+ if (!dispatchReviewSeam)
396
+ return; // caller-guarded; defensive
397
+ // CAS planned → reviewing (durable). Idempotency: if another tick already
398
+ // advanced the ticket, the CAS conflicts and we bail benignly.
399
+ const rowVersion = observed.ticket_row_versions.get(ticketKey) ?? 0;
400
+ let cas;
401
+ try {
402
+ cas = await deps.casTicketStatus(observed.epic_key, ticketKey, rowVersion, "reviewing", observed.plan_version);
403
+ }
404
+ catch (err) {
405
+ const safeMsg = err instanceof Error ? err.constructor.name : "cas error";
406
+ result.warnings.push(`review-cas-error for ${ticketKey}: ${safeMsg}`);
407
+ return;
408
+ }
409
+ if (!cas.ok) {
410
+ result.warnings.push(`review-cas-conflict advancing ${ticketKey} → reviewing`);
411
+ return;
412
+ }
413
+ // Claim the :review dispatch key (attempt 0), then spawn + correlate.
414
+ let claim;
415
+ try {
416
+ claim = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version, "review", 0);
417
+ }
418
+ catch (err) {
419
+ const safeMsg = err instanceof Error ? err.constructor.name : "claim error";
420
+ result.warnings.push(`review-claim-error for ${ticketKey}: ${safeMsg}`);
421
+ return;
422
+ }
423
+ if (!claim.ok) {
424
+ result.warnings.push(`review-dispatch-key lease-held for ${ticketKey}`);
425
+ return;
426
+ }
427
+ if (claim.kind !== "claimed") {
428
+ if (claim.kind === "already-exists" && claim.dispatch.run_id === null) {
429
+ result.warnings.push(`review-dispatch-orphan: ${ticketKey} has pending review dispatch key with no run_id`);
430
+ await escalate(observed.epic_key, `dispatch-orphan:${ticketKey}`);
431
+ }
432
+ return;
433
+ }
434
+ const dispatchKey = claim.dispatch.dispatch_key;
435
+ let runId;
436
+ try {
437
+ runId = await dispatchReviewSeam(observed.epic_key, ticketKey, 0);
438
+ }
439
+ catch (err) {
440
+ const safeMsg = err instanceof Error ? err.constructor.name : "dispatch error";
441
+ result.warnings.push(`review-dispatch-failed for ${ticketKey}: ${safeMsg}`);
442
+ return;
443
+ }
444
+ try {
445
+ await deps.correlateRunId(dispatchKey, runId);
446
+ deps.log(`[epic-reconcile] dispatched spec re-review for ${ticketKey} ` +
447
+ `run_id=${runId} (planned → reviewing; implementation deferred)`);
448
+ }
449
+ catch (err) {
450
+ const safeMsg = err instanceof Error ? err.constructor.name : "correlate error";
451
+ result.warnings.push(`review-correlate-failed for ${ticketKey}: ${safeMsg}`);
452
+ }
453
+ }
@@ -75,6 +75,9 @@ export async function runEpicTick(options, deps = {}) {
75
75
  const errorLog = deps.errorLog ?? ((msg) => process.stderr.write(`${msg}\n`));
76
76
  const escalateOnce = deps.escalateOnce ?? defaultEscalateOnce;
77
77
  const dispatchSeam = deps.dispatchSeam ?? defaultDispatchSeam;
78
+ // BAPI-445: optional review dispatch seam. When absent the spec re-review
79
+ // sequencing gate stays off (a ready ticket dispatches implementation directly).
80
+ const dispatchReviewSeamFn = deps.dispatchReviewSeam;
78
81
  const processMergeFn = deps.processMerge ?? processGateMetMerge;
79
82
  const postActionWaitSeam = deps.postActionWaitSeam ?? defaultPostActionWaitSeam;
80
83
  const fetchLocalEvents = deps.fetchLocalEvents ??
@@ -203,7 +206,7 @@ export async function runEpicTick(options, deps = {}) {
203
206
  const dispatchedRunIds = epicRunState.dispatches
204
207
  .map((d) => d.run_id)
205
208
  .filter((rid) => typeof rid === "string" && rid.length > 0);
206
- const localEvents = fetchLocalEvents(epic_key, dispatchedRunIds);
209
+ const localEvents = await fetchLocalEvents(epic_key, dispatchedRunIds);
207
210
  const observed = rebuildObservedState(epicRunState, localEvents, nowFn());
208
211
  workerCount = [...observed.ticket_statuses.values()].filter((s) => ACTIVE_WORKER_STATUSES.has(s)).length;
209
212
  // Step 3.5: Run post-action waits (parse-after-merge)
@@ -386,19 +389,35 @@ export async function runEpicTick(options, deps = {}) {
386
389
  if (ts.dispatch_run_id)
387
390
  ticketRunIdMap.set(ts.ticket_key, ts.dispatch_run_id);
388
391
  }
392
+ // BAPI-445: split dispatch records by role. `:review`-suffixed dispatch keys
393
+ // are the pre-implementation spec re-review runs; they must be EXCLUDED from
394
+ // the implementation liveness map (so BAPI-441 remediation liveness only ever
395
+ // targets the implementation run) and tracked in a separate review map +
396
+ // per-ticket attempt counter consumed by the reviewing recovery branch.
389
397
  const latestDispatchByTicket = new Map();
398
+ const reviewLatestDispatchByTicket = new Map();
399
+ const reviewAttemptCounts = new Map();
390
400
  for (const d of epicRunState.dispatches) {
401
+ const isReview = d.dispatch_key.endsWith(":review");
402
+ if (isReview) {
403
+ reviewAttemptCounts.set(d.ticket_key, (reviewAttemptCounts.get(d.ticket_key) ?? 0) + 1);
404
+ }
391
405
  if (!d.run_id)
392
406
  continue;
393
407
  const updatedAt = new Date(d.updated_at).getTime();
394
- const prev = latestDispatchByTicket.get(d.ticket_key);
408
+ const target = isReview ? reviewLatestDispatchByTicket : latestDispatchByTicket;
409
+ const prev = target.get(d.ticket_key);
395
410
  if (!prev || updatedAt >= prev.updatedAt) {
396
- latestDispatchByTicket.set(d.ticket_key, { runId: d.run_id, updatedAt });
411
+ target.set(d.ticket_key, { runId: d.run_id, updatedAt });
397
412
  }
398
413
  }
399
414
  for (const [tk, info] of latestDispatchByTicket) {
400
415
  ticketRunIdMap.set(tk, info.runId);
401
416
  }
417
+ const reviewTicketRunIdMap = new Map();
418
+ for (const [tk, info] of reviewLatestDispatchByTicket) {
419
+ reviewTicketRunIdMap.set(tk, info.runId);
420
+ }
402
421
  const resolvePrNumber = (ticketKey) => {
403
422
  const raw = prBindings[ticketKey];
404
423
  if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
@@ -435,13 +454,30 @@ export async function runEpicTick(options, deps = {}) {
435
454
  planVersion,
436
455
  });
437
456
  },
438
- claimDispatchKey: async (ek, tk, planVersion) => recordEpicDispatch(access, {
457
+ claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => recordEpicDispatch(access, {
439
458
  epicKey: ek,
440
459
  ticketKey: tk,
441
460
  planVersion,
442
461
  leaseOwner: lease_owner,
443
462
  ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
463
+ attempt,
464
+ // BAPI-445: a review-role claim appends ":review" to the dispatch key
465
+ // so the run-id maps above can separate review runs from impl runs.
466
+ reviewRole: role === "review",
444
467
  }),
468
+ // BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
469
+ // factory provides it (gate stays off otherwise); the liveness + attempt
470
+ // accessors read the review-scoped maps built above.
471
+ dispatchReviewSeam: dispatchReviewSeamFn
472
+ ? async (ek, tk, attempt = 0) => dispatchReviewSeamFn(ek, tk, attempt)
473
+ : undefined,
474
+ readReviewWorkerLiveness: async (_ek, tk) => {
475
+ const runId = reviewTicketRunIdMap.get(tk);
476
+ if (!runId)
477
+ return { alive: false, workerId: null };
478
+ return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
479
+ },
480
+ countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
445
481
  correlateRunId: async (dispatchKey, runId) => {
446
482
  await transitionEpicDispatch(access, {
447
483
  dispatchKey,
@@ -802,7 +838,53 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
802
838
  }
803
839
  return runId;
804
840
  };
805
- const fetchLocalEvents = (_ek, runIds) => {
841
+ const dispatchReviewSeam = async (ek, tk, attempt = 0) => {
842
+ // BAPI-445: spawn the pre-implementation spec re-review as the review-tickets
843
+ // automation kind — ALWAYS a review, regardless of the ticket's own plan-node
844
+ // automation kind (which is an implementation kind). fetchPlan must have run
845
+ // first so cachedPlanVersion is populated (same guard as dispatchSeam).
846
+ if (cachedPlanVersion === 0) {
847
+ throw new Error(`dispatchReviewSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
848
+ }
849
+ const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
850
+ // Deliberately omit `dispatch_key` from the identity: the reconcile pass owns
851
+ // the idempotent :review key claim (recordEpicDispatch with reviewRole) and the
852
+ // run_spawned correlation. orchestrateReviewTickets still mints the run_id and
853
+ // injects BAPI_CONDUCTOR_RUN_ID into the review agent's environment, which is
854
+ // exactly the correlation channel the Python review orchestrator reads to stamp
855
+ // the spec_review.* verdict event with this review run's id (Phase 2).
856
+ const identity = {
857
+ epic_key: ek,
858
+ epic_run_id: ek,
859
+ plan_version: cachedPlanVersion,
860
+ };
861
+ const deps = createDefaultStartTicketsDeps();
862
+ const result = await orchestrateReviewTickets(deps, {
863
+ keys: [tk],
864
+ epic: identity,
865
+ agentName: "claude",
866
+ dryRun: dispatchDryRun,
867
+ maxParallel: 1,
868
+ auto: true,
869
+ // Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
870
+ rounds: 2,
871
+ reviewOverrides: {},
872
+ });
873
+ if (!result.ok) {
874
+ throw new Error(`spec re-review dispatch failed: ${result.error}`);
875
+ }
876
+ let runId = result.rows[0]?.runId;
877
+ if (!runId && dispatchDryRun) {
878
+ // Dry-run produced no real run_id; substitute a synthetic, attempt-scoped id
879
+ // so the reconcile correlate step still records the :review dispatch row.
880
+ runId = `dry-run:review:${ek}:${tk}:${cachedPlanVersion}:r${attempt}`;
881
+ }
882
+ if (!runId) {
883
+ throw new Error(`spec re-review dispatch returned no runId for ticket ${tk}`);
884
+ }
885
+ return runId;
886
+ };
887
+ const fetchLocalEvents = async (_ek, runIds) => {
806
888
  // Workers and the epic-tick process share the same local SQLite ledger
807
889
  // (~/.config/bridge/events.db). pollConductorEvents opens it read-only.
808
890
  //
@@ -831,7 +913,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
831
913
  // cap total iterations defensively against a non-advancing cursor.
832
914
  const MAX_PAGES = 10_000;
833
915
  for (let page = 0; page < MAX_PAGES; page += 1) {
834
- const result = pollConductorEvents({
916
+ const result = await pollConductorEvents({
835
917
  data_mode: "full",
836
918
  since_seq: sinceSeq,
837
919
  limit: POLL_LIMIT_MAX,
@@ -897,6 +979,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
897
979
  return {
898
980
  fetchPlan,
899
981
  dispatchSeam,
982
+ dispatchReviewSeam,
900
983
  fetchLocalEvents,
901
984
  escalateOnce,
902
985
  postActionWaitSeam,