@bridge_gpt/mcp-server 0.2.9 → 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 (43) hide show
  1. package/README.md +59 -7
  2. package/build/commands.generated.js +6 -6
  3. package/build/conductor/bridge-api-client.js +263 -35
  4. package/build/conductor/cli.js +38 -17
  5. package/build/conductor/doctor.js +35 -2
  6. package/build/conductor/done-gate.js +301 -58
  7. package/build/conductor/epic-reconcile.js +318 -4
  8. package/build/conductor/epic-runtime.js +382 -18
  9. package/build/conductor/epic-state.js +188 -15
  10. package/build/conductor/errors.js +12 -0
  11. package/build/conductor/git-ci-types.js +16 -0
  12. package/build/conductor/git-producer.js +4 -4
  13. package/build/conductor/merge-ledger.js +7 -7
  14. package/build/conductor/pr-ci-producer.js +118 -19
  15. package/build/conductor/pr-review-producer.js +116 -0
  16. package/build/conductor/producer-ledger.js +5 -5
  17. package/build/conductor/spec-review-producer.js +88 -0
  18. package/build/conductor/store.js +105 -26
  19. package/build/conductor/supervisor-ledger.js +2 -2
  20. package/build/conductor/supervisor-merge.js +5 -5
  21. package/build/conductor/supervisor-message-relay.js +32 -1
  22. package/build/conductor/supervisor-runtime.js +10 -10
  23. package/build/conductor/taxonomy.js +8 -0
  24. package/build/conductor/tools.js +7 -7
  25. package/build/conductor-bin.js +12350 -19
  26. package/build/conductor-claude-hook-bin.js +167 -17
  27. package/build/decision-page-schema.js +26 -0
  28. package/build/doctor.js +200 -0
  29. package/build/index.js +23696 -4351
  30. package/build/init.js +481 -0
  31. package/build/install-bridge.js +772 -0
  32. package/build/mcp-profile.js +43 -0
  33. package/build/pipelines.generated.js +70 -48
  34. package/build/readme.generated.js +1 -1
  35. package/build/start-tickets-conductor.js +1 -0
  36. package/build/start-tickets.js +186 -10
  37. package/build/upgrade-cli.js +154 -0
  38. package/build/version.generated.js +1 -1
  39. package/package.json +7 -4
  40. package/pipelines/check-ci-ticket.json +2 -2
  41. package/pipelines/implement-ticket.json +2 -2
  42. package/pipelines/learn-repository.json +84 -42
  43. package/smoke-test/SMOKE-TEST.md +11 -17
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Deterministic observed→desired reconciliation for the Epic Supervisor
3
- * (BAPI-408).
3
+ * (BAPI-408, BAPI-436).
4
4
  *
5
5
  * Executes five steps in order:
6
6
  * 1. Fold terminal signals into Postgres via CAS
@@ -9,10 +9,15 @@
9
9
  * 4. Action approved merges via C6 delegation
10
10
  * 5. Schedule post-action wait hooks
11
11
  *
12
+ * Dispatch is purely merge-gated (BAPI-436): dependents are dispatched only
13
+ * after their predecessor reaches "done", which requires a merge.succeeded
14
+ * signal. gate.met and run.stopped fold to the intermediate "ready_for_review"
15
+ * state — dependents do NOT dispatch on these signals.
16
+ *
12
17
  * All durable mutations go through injected seams so the logic is testable
13
18
  * without real network, ledger, or terminal access.
14
19
  */
15
- import { computeReadySet } from "./epic-state.js";
20
+ import { computeReadySet, decideRemediation, DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS } from "./epic-state.js";
16
21
  import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
17
22
  // ---------------------------------------------------------------------------
18
23
  // reconcileEpic
@@ -21,7 +26,7 @@ import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
21
26
  * Execute the deterministic observed→desired reconciliation pass. All I/O is
22
27
  * behind injected seams; the ready-set is computed by pure code (no LLM).
23
28
  */
24
- export async function reconcileEpic(access, observed, plan, deps) {
29
+ export async function reconcileEpic(access, observed, plan, deps, supervisorConfig) {
25
30
  const result = {
26
31
  signals_folded: 0,
27
32
  dispatched: 0,
@@ -43,6 +48,22 @@ export async function reconcileEpic(access, observed, plan, deps) {
43
48
  if (casResult.ok) {
44
49
  result.signals_folded += 1;
45
50
  deps.log(`[epic-reconcile] folded ${signal.signal_type} for ${signal.ticket_key} → ${signal.next_status}`);
51
+ // BAPI-442: fire teardown + Jira transition strictly after merge.succeeded
52
+ // CAS → done. Both are fail-open: errors are logged and never abort the pass.
53
+ if (signal.signal_type === "merge.succeeded") {
54
+ if (supervisorConfig?.teardown_enabled && deps.teardownSeam) {
55
+ await deps.teardownSeam(observed.epic_key, signal.ticket_key).catch((e) => {
56
+ const safeMsg = e instanceof Error ? e.constructor.name : "teardown error";
57
+ deps.log(`[epic-reconcile] teardown error for ${signal.ticket_key}: ${safeMsg}`);
58
+ });
59
+ }
60
+ if (deps.jiraTransitionSeam) {
61
+ await deps.jiraTransitionSeam(observed.epic_key, signal.ticket_key).catch((e) => {
62
+ const safeMsg = e instanceof Error ? e.constructor.name : "jira error";
63
+ deps.log(`[epic-reconcile] jira-transition error for ${signal.ticket_key}: ${safeMsg}`);
64
+ });
65
+ }
66
+ }
46
67
  }
47
68
  else {
48
69
  // CAS conflict: another tick already advanced this ticket — non-fatal
@@ -61,8 +82,29 @@ export async function reconcileEpic(access, observed, plan, deps) {
61
82
  }
62
83
  // Step 2: Compute the ready-set (pure — never calls LLM)
63
84
  const readySet = computeReadySet(plan, observed.ticket_statuses);
64
- // Step 3: Dispatch each ready ticket idempotently
85
+ // Step 3: Dispatch each ready ticket idempotently.
86
+ //
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.
65
98
  for (const ticketKey of readySet) {
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;
107
+ }
66
108
  let claimResult;
67
109
  try {
68
110
  claimResult = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version);
@@ -118,6 +160,202 @@ export async function reconcileEpic(access, observed, plan, deps) {
118
160
  result.warnings.push(`correlate-failed for ${ticketKey}: ${safeMsg}`);
119
161
  }
120
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
+ }
272
+ // Step 3.5: Remediation pass (BAPI-441) — re-act on blocked tickets under
273
+ // budget. Keyed off the folded "blocked" status + per-ticket counters, NOT a
274
+ // computeReadySet change (the ready-set still returns only planned tickets).
275
+ // Skipped entirely unless the remediation seams + supervisorConfig are wired.
276
+ const remediationWired = supervisorConfig !== undefined &&
277
+ deps.readWorkerLiveness !== undefined &&
278
+ deps.remediateCas !== undefined &&
279
+ deps.sendNudge !== undefined &&
280
+ deps.resumeDispatch !== undefined;
281
+ if (remediationWired) {
282
+ const cfg = supervisorConfig;
283
+ const readWorkerLiveness = deps.readWorkerLiveness;
284
+ const sendNudge = deps.sendNudge;
285
+ const resumeDispatch = deps.resumeDispatch;
286
+ const remediateCas = deps.remediateCas;
287
+ for (const [ticketKey, status] of observed.ticket_statuses) {
288
+ if (status !== "blocked")
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;
294
+ // Per-ticket try/catch so a single ticket's failure never aborts the pass.
295
+ try {
296
+ const counters = observed.ticket_remediation_counters?.get(ticketKey) ?? {
297
+ attempts: 0,
298
+ no_progress: 0,
299
+ };
300
+ const liveness = await readWorkerLiveness(observed.epic_key, ticketKey);
301
+ const decision = decideRemediation(counters.attempts, counters.no_progress, liveness.alive, cfg);
302
+ if (decision === "escalate") {
303
+ await escalate(observed.epic_key, `remediation-budget-exhausted:${ticketKey}`);
304
+ deps.log(`[epic-reconcile] remediation escalate ${ticketKey} ` +
305
+ `(attempts=${counters.attempts} no_progress=${counters.no_progress})`);
306
+ continue;
307
+ }
308
+ // The attempt being recorded is the next one (1-based).
309
+ const attempt = counters.attempts + 1;
310
+ const attemptKind = decision;
311
+ // The folding reason frames the nudge (message type + digest). Default to
312
+ // the review path when the ledger no longer carries the blocking event.
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";
317
+ // A nudge needs a worker to address it to. The liveness scan already
318
+ // resolved the worker id from the same heartbeat that proved the worker
319
+ // alive; if it is missing we cannot relay, so skip BEFORE recording an
320
+ // attempt — otherwise the CAS would burn a budget unit with nothing sent.
321
+ if (decision === "nudge" && !liveness.workerId) {
322
+ result.warnings.push(`remediation nudge skipped for ${ticketKey}: alive worker has no worker_id`);
323
+ continue;
324
+ }
325
+ // Record the attempt durably FIRST. The remediate endpoint builds the
326
+ // (backend-redacted) review digest for a nudge and returns it, and is
327
+ // idempotent (a 409 replay returns conflict, not throw). An unexpected
328
+ // remediate failure is absorbed as a per-ticket warning so the rest of
329
+ // the pass still runs (crash-replay safe).
330
+ let casOutcome;
331
+ try {
332
+ casOutcome = await remediateCas(observed.epic_key, ticketKey, attemptKind, reason);
333
+ }
334
+ catch (err) {
335
+ const safeMsg = err instanceof Error ? err.constructor.name : "remediate error";
336
+ result.warnings.push(`remediate-cas-failed for ${ticketKey} (${attemptKind}): ${safeMsg}`);
337
+ continue;
338
+ }
339
+ if (casOutcome.conflict) {
340
+ // Idempotency replay: the attempt was already recorded (and acted on)
341
+ // on a prior tick. Do not re-act — the crash-replay self-heals here.
342
+ result.warnings.push(`remediation replay swallowed for ${ticketKey} (${attemptKind})`);
343
+ continue;
344
+ }
345
+ if (decision === "nudge") {
346
+ await sendNudge(observed.epic_key, ticketKey, attempt, casOutcome.reviewDigest, casOutcome.truncated, reason, liveness.workerId);
347
+ }
348
+ else {
349
+ await resumeDispatch(observed.epic_key, ticketKey, attempt);
350
+ }
351
+ deps.log(`[epic-reconcile] remediation ${decision} ${ticketKey} attempt=${attempt}`);
352
+ }
353
+ catch (err) {
354
+ const safeMsg = err instanceof Error ? err.constructor.name : "remediation error";
355
+ result.warnings.push(`remediation-error for ${ticketKey}: ${safeMsg}`);
356
+ }
357
+ }
358
+ }
121
359
  // Step 4: Action approved merges via C6 delegation
122
360
  for (const event of observed.pending_merge_events) {
123
361
  const identity = extractMergeActionIdentityFromGateEvent(event);
@@ -137,3 +375,79 @@ export async function reconcileEpic(access, observed, plan, deps) {
137
375
  // Step 5: Post-action waits are scheduled inline above per-dispatch
138
376
  return result;
139
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
+ }