@bridge_gpt/mcp-server 0.2.6 → 0.2.10

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.
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Pure done-gate config parsing, CI snapshot normalization, and deterministic
3
- * gate evaluation (BAPI-395).
3
+ * gate evaluation (BAPI-395, BAPI-440).
4
4
  *
5
- * v1 supports exactly one gate condition `required_ci_checks_green` and fails
6
- * CLOSED everywhere: unset, disabled, malformed, and unsupported config all yield
7
- * an inactive result that can never emit `gate.met`. A gate is met only when every
8
- * configured required check is present, complete, and green for the exact
5
+ * Supports a heterogeneous ANDed conditions list: one or more CI conditions
6
+ * (`required_ci_checks_green`) plus zero or more review-state conditions
7
+ * (`review_state`). Fails CLOSED everywhere: unset, disabled, malformed,
8
+ * unknown/duplicate condition types all yield an inactive result that can never
9
+ * emit `gate.met`. A gate is met only when ALL conditions are met for the exact
9
10
  * `repo + pr_number + head_sha` binding. This module is pure (no I/O) and never
10
11
  * throws for caller input.
11
12
  */
12
- import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
13
+ import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
13
14
  // ---------------------------------------------------------------------------
14
15
  // Config parsing
15
16
  // ---------------------------------------------------------------------------
@@ -22,7 +23,7 @@ function inactiveConfig(reason) {
22
23
  enabled: false,
23
24
  valid: false,
24
25
  reason,
25
- condition: null,
26
+ conditions: [],
26
27
  config_hash: null,
27
28
  gate_name: DEFAULT_GATE_NAME,
28
29
  };
@@ -62,21 +63,11 @@ function coerceConfigObject(value) {
62
63
  return { kind: "invalid" };
63
64
  }
64
65
  /**
65
- * Extract and validate the single v1 condition from a config object. Requires
66
- * `conditions` to be an array containing exactly one `required_ci_checks_green`
67
- * condition whose `required_checks` is a non-empty array of unique, valid,
68
- * normalized check names. Returns the normalized condition or `null`.
66
+ * Parse and validate a single `required_ci_checks_green` condition entry.
67
+ * Returns the normalized condition or `null` on any validation failure.
69
68
  */
70
- function parseSingleCondition(object) {
71
- const conditions = object.conditions;
72
- if (!Array.isArray(conditions) || conditions.length !== 1)
73
- return null;
74
- const condition = conditions[0];
75
- if (!isPlainObject(condition))
76
- return null;
77
- if (condition.type !== REQUIRED_CI_CHECKS_GREEN)
78
- return null;
79
- const rawChecks = condition.required_checks;
69
+ function parseCiChecksCondition(entry) {
70
+ const rawChecks = entry.required_checks;
80
71
  if (!Array.isArray(rawChecks) || rawChecks.length === 0)
81
72
  return null;
82
73
  const normalized = [];
@@ -84,19 +75,103 @@ function parseSingleCondition(object) {
84
75
  for (const raw of rawChecks) {
85
76
  const name = normalizeCheckName(raw);
86
77
  if (name === null)
87
- return null; // invalid name invalidates the whole config
78
+ return null;
88
79
  if (seen.has(name))
89
- return null; // duplicate after normalization
80
+ return null;
90
81
  seen.add(name);
91
82
  normalized.push(name);
92
83
  }
93
84
  return { type: REQUIRED_CI_CHECKS_GREEN, required_checks: normalized };
94
85
  }
86
+ /** Valid review source values. */
87
+ const VALID_REVIEW_SOURCES = new Set(["sticky_verdict", "native_review_decision", "min_approvals", "combination"]);
88
+ /**
89
+ * Parse and validate a single `review_state` condition entry.
90
+ * Returns the normalized condition or `null` on any validation failure.
91
+ */
92
+ function parseReviewStateCondition(entry) {
93
+ const source = entry.source;
94
+ if (typeof source !== "string" || !VALID_REVIEW_SOURCES.has(source))
95
+ return null;
96
+ const condition = { type: REVIEW_STATE, source: source };
97
+ if (entry.require_sticky_verdict !== undefined) {
98
+ if (typeof entry.require_sticky_verdict !== "boolean")
99
+ return null;
100
+ condition.require_sticky_verdict = entry.require_sticky_verdict;
101
+ }
102
+ if (entry.require_native_decision !== undefined) {
103
+ if (typeof entry.require_native_decision !== "boolean")
104
+ return null;
105
+ condition.require_native_decision = entry.require_native_decision;
106
+ }
107
+ if (entry.min_approvals !== undefined) {
108
+ if (typeof entry.min_approvals !== "number" || !Number.isInteger(entry.min_approvals) || entry.min_approvals < 0)
109
+ return null;
110
+ condition.min_approvals = entry.min_approvals;
111
+ }
112
+ if (entry.logic !== undefined) {
113
+ if (entry.logic !== "and")
114
+ return null;
115
+ condition.logic = "and";
116
+ }
117
+ // For combination source, at least one sub-source must be active; otherwise
118
+ // the condition would trivially pass with an empty failures array (fail-open).
119
+ if (condition.source === "combination") {
120
+ const hasSticky = condition.require_sticky_verdict === true;
121
+ const hasNative = condition.require_native_decision === true;
122
+ const hasMin = typeof condition.min_approvals === "number" && condition.min_approvals > 0;
123
+ if (!hasSticky && !hasNative && !hasMin)
124
+ return null;
125
+ }
126
+ return condition;
127
+ }
128
+ /**
129
+ * Parse the `conditions` array from a config object into a validated
130
+ * {@link GateCondition}[] list. Returns `null` if the array is missing, empty,
131
+ * contains any invalid/unknown/duplicate condition type, or any individual
132
+ * condition fails validation. Fail-closed.
133
+ */
134
+ function parseConditions(object) {
135
+ const raw = object.conditions;
136
+ if (!Array.isArray(raw) || raw.length === 0)
137
+ return null;
138
+ const seenTypes = new Set();
139
+ const parsed = [];
140
+ for (const entry of raw) {
141
+ if (!isPlainObject(entry))
142
+ return null;
143
+ const type = entry.type;
144
+ if (typeof type !== "string")
145
+ return null;
146
+ if (seenTypes.has(type))
147
+ return null; // duplicate type
148
+ if (type === REQUIRED_CI_CHECKS_GREEN) {
149
+ const condition = parseCiChecksCondition(entry);
150
+ if (condition === null)
151
+ return null;
152
+ seenTypes.add(type);
153
+ parsed.push(condition);
154
+ }
155
+ else if (type === REVIEW_STATE) {
156
+ const condition = parseReviewStateCondition(entry);
157
+ if (condition === null)
158
+ return null;
159
+ seenTypes.add(type);
160
+ parsed.push(condition);
161
+ }
162
+ else {
163
+ return null; // unknown condition type → fail closed
164
+ }
165
+ }
166
+ return parsed;
167
+ }
95
168
  /**
96
169
  * Parse a raw `conductor_done_gate` value into a fail-closed
97
170
  * {@link NormalizedDoneGateConfig}. Active (`enabled && valid`) only when the
98
- * value is an enabled object with `enabled === true` (strict boolean) and exactly
99
- * one valid v1 condition. Every other shape is inactive with a safe reason.
171
+ * value is an enabled object with `enabled === true` (strict boolean) and at
172
+ * least one valid condition (CI and/or review). Every other shape is inactive
173
+ * with a safe reason. Fail-closed on any unknown, duplicate, or malformed
174
+ * condition type.
100
175
  */
101
176
  export function parseDoneGateConfig(value) {
102
177
  const coerced = coerceConfigObject(value);
@@ -111,21 +186,35 @@ export function parseDoneGateConfig(value) {
111
186
  return inactiveConfig("disabled");
112
187
  return inactiveConfig("invalid: 'enabled' must be the boolean true");
113
188
  }
114
- const condition = parseSingleCondition(object);
115
- if (condition === null) {
116
- return inactiveConfig("invalid: expected exactly one valid required_ci_checks_green condition");
189
+ const conditions = parseConditions(object);
190
+ if (conditions === null) {
191
+ return inactiveConfig("invalid: conditions must be a non-empty array of valid, non-duplicate condition objects");
117
192
  }
118
193
  const gateName = DEFAULT_GATE_NAME;
119
194
  const configHash = stableJsonHash({
120
195
  gate_name: gateName,
121
- type: condition.type,
122
- required_checks: condition.required_checks,
196
+ conditions: conditions.map((c) => {
197
+ if (c.type === REQUIRED_CI_CHECKS_GREEN) {
198
+ return { type: c.type, required_checks: c.required_checks };
199
+ }
200
+ // review_state: include all non-undefined fields deterministically
201
+ const r = { type: c.type, source: c.source };
202
+ if (c.require_sticky_verdict !== undefined)
203
+ r.require_sticky_verdict = c.require_sticky_verdict;
204
+ if (c.require_native_decision !== undefined)
205
+ r.require_native_decision = c.require_native_decision;
206
+ if (c.min_approvals !== undefined)
207
+ r.min_approvals = c.min_approvals;
208
+ if (c.logic !== undefined)
209
+ r.logic = c.logic;
210
+ return r;
211
+ }),
123
212
  });
124
213
  return {
125
214
  enabled: true,
126
215
  valid: true,
127
216
  reason: "active",
128
- condition,
217
+ conditions,
129
218
  config_hash: configHash,
130
219
  gate_name: gateName,
131
220
  };
@@ -214,8 +303,16 @@ function normalizeOneCheck(name, raw) {
214
303
  export function normalizeCiSnapshot(response) {
215
304
  const checks = [];
216
305
  const byName = new Map();
217
- if (isPlainObject(response)) {
218
- const rawChecks = response.checks;
306
+ // The raw `pollCiChecksForCommit` response is an envelope
307
+ // (`{ available, reason, action, detail: { checks, unknown_checks } }`), while
308
+ // unit tests and some callers pass an already-unwrapped object
309
+ // (`{ checks, unknown_checks }`). Prefer the unwrapped top-level fields and only
310
+ // fall back to `detail.*` when the top-level field is absent, so both shapes
311
+ // normalize identically and existing unwrapped-shape behavior is preserved.
312
+ const source = isPlainObject(response) ? response : undefined;
313
+ const detail = source && isPlainObject(source.detail) ? source.detail : undefined;
314
+ if (source) {
315
+ const rawChecks = source.checks ?? detail?.checks;
219
316
  if (Array.isArray(rawChecks)) {
220
317
  for (const entry of rawChecks) {
221
318
  if (!isPlainObject(entry))
@@ -238,8 +335,9 @@ export function normalizeCiSnapshot(response) {
238
335
  }
239
336
  }
240
337
  const unknownChecks = [];
241
- if (isPlainObject(response) && Array.isArray(response.unknown_checks)) {
242
- for (const raw of response.unknown_checks) {
338
+ const rawUnknown = source ? source.unknown_checks ?? detail?.unknown_checks : undefined;
339
+ if (Array.isArray(rawUnknown)) {
340
+ for (const raw of rawUnknown) {
243
341
  const name = normalizeCheckName(raw);
244
342
  if (name !== null && !unknownChecks.includes(name))
245
343
  unknownChecks.push(name);
@@ -264,46 +362,190 @@ export function normalizeCiSnapshot(response) {
264
362
  };
265
363
  }
266
364
  // ---------------------------------------------------------------------------
365
+ // Review snapshot normalization
366
+ // ---------------------------------------------------------------------------
367
+ const REVIEW_VERDICT_APPROVED = "approved";
368
+ const REVIEW_VERDICT_CHANGES_REQUESTED = "changes_requested";
369
+ const REVIEW_VERDICT_UNKNOWN = "unknown";
370
+ /**
371
+ * Normalize a raw backend review-status response into a {@link NormalizedReviewSnapshot}.
372
+ * Returns `null` when the envelope indicates unavailability (`available: false`) or
373
+ * is missing, so callers can fail-closed without emitting false-negative events.
374
+ */
375
+ export function normalizeReviewSnapshot(raw) {
376
+ if (!isPlainObject(raw))
377
+ return null;
378
+ if (raw.available === false)
379
+ return null;
380
+ const detail = isPlainObject(raw.detail) ? raw.detail : null;
381
+ if (detail === null)
382
+ return null;
383
+ const reviewDecision = typeof detail.review_decision === "string" && detail.review_decision.length > 0
384
+ ? detail.review_decision
385
+ : null;
386
+ const approvals = typeof detail.approvals === "number" && Number.isInteger(detail.approvals) && detail.approvals >= 0
387
+ ? detail.approvals
388
+ : 0;
389
+ const rawVerdict = detail.sticky_verdict;
390
+ let stickyVerdict;
391
+ if (rawVerdict === REVIEW_VERDICT_APPROVED) {
392
+ stickyVerdict = "approved";
393
+ }
394
+ else if (rawVerdict === REVIEW_VERDICT_CHANGES_REQUESTED) {
395
+ stickyVerdict = "changes_requested";
396
+ }
397
+ else if (rawVerdict === REVIEW_VERDICT_UNKNOWN) {
398
+ stickyVerdict = "unknown";
399
+ }
400
+ else {
401
+ stickyVerdict = null;
402
+ }
403
+ const headSha = typeof detail.head_sha === "string" && detail.head_sha.trim().length > 0
404
+ ? detail.head_sha.trim()
405
+ : null;
406
+ const reviewStateHash = stableJsonHash({
407
+ review_decision: reviewDecision,
408
+ approvals,
409
+ sticky_verdict: stickyVerdict,
410
+ });
411
+ return { review_decision: reviewDecision, approvals, sticky_verdict: stickyVerdict, head_sha: headSha, review_state_hash: reviewStateHash };
412
+ }
413
+ /**
414
+ * Deterministically evaluate a review-state condition against a normalized snapshot.
415
+ * Pure and fail-closed: a null snapshot or any unknown/missing source yields
416
+ * `passed: false` and `changesRequested: false` (never a false positive).
417
+ */
418
+ export function evaluateReviewCondition(condition, snapshot) {
419
+ if (snapshot === null) {
420
+ return { passed: false, changesRequested: false, reason: "review snapshot unavailable" };
421
+ }
422
+ const source = condition.source;
423
+ if (source === "sticky_verdict") {
424
+ if (snapshot.sticky_verdict === "approved")
425
+ return { passed: true, changesRequested: false, reason: "sticky verdict approved" };
426
+ if (snapshot.sticky_verdict === "changes_requested")
427
+ return { passed: false, changesRequested: true, reason: "sticky verdict requests changes" };
428
+ return { passed: false, changesRequested: false, reason: `sticky verdict not approved: ${snapshot.sticky_verdict ?? "null"}` };
429
+ }
430
+ if (source === "native_review_decision") {
431
+ const dec = snapshot.review_decision?.toUpperCase();
432
+ if (dec === "APPROVED")
433
+ return { passed: true, changesRequested: false, reason: "native review decision approved" };
434
+ if (dec === "CHANGES_REQUESTED")
435
+ return { passed: false, changesRequested: true, reason: "native review decision requests changes" };
436
+ return { passed: false, changesRequested: false, reason: `native review decision not approved: ${snapshot.review_decision ?? "null"}` };
437
+ }
438
+ if (source === "min_approvals") {
439
+ const required = typeof condition.min_approvals === "number" ? condition.min_approvals : 1;
440
+ if (snapshot.approvals >= required)
441
+ return { passed: true, changesRequested: false, reason: `approvals ${snapshot.approvals} >= ${required}` };
442
+ return { passed: false, changesRequested: false, reason: `approvals ${snapshot.approvals} < ${required}` };
443
+ }
444
+ if (source === "combination") {
445
+ // All enabled sub-sources must pass (AND logic).
446
+ const requireSticky = condition.require_sticky_verdict === true;
447
+ const requireNative = condition.require_native_decision === true;
448
+ const minApprovals = typeof condition.min_approvals === "number" ? condition.min_approvals : 0;
449
+ const failures = [];
450
+ let changesRequested = false;
451
+ if (requireSticky) {
452
+ if (snapshot.sticky_verdict === "changes_requested")
453
+ changesRequested = true;
454
+ if (snapshot.sticky_verdict !== "approved")
455
+ failures.push(`sticky verdict not approved: ${snapshot.sticky_verdict ?? "null"}`);
456
+ }
457
+ if (requireNative) {
458
+ const dec = snapshot.review_decision?.toUpperCase();
459
+ if (dec === "CHANGES_REQUESTED")
460
+ changesRequested = true;
461
+ if (dec !== "APPROVED")
462
+ failures.push(`native decision not approved: ${snapshot.review_decision ?? "null"}`);
463
+ }
464
+ if (minApprovals > 0 && snapshot.approvals < minApprovals) {
465
+ failures.push(`approvals ${snapshot.approvals} < ${minApprovals}`);
466
+ }
467
+ if (failures.length > 0)
468
+ return { passed: false, changesRequested, reason: failures.join("; ") };
469
+ return { passed: true, changesRequested: false, reason: "all combination sources satisfied" };
470
+ }
471
+ return { passed: false, changesRequested: false, reason: `unknown review source: ${source}` };
472
+ }
473
+ // ---------------------------------------------------------------------------
267
474
  // Gate evaluation
268
475
  // ---------------------------------------------------------------------------
269
476
  function failedEvaluation(reason) {
270
477
  return { met: false, reason };
271
478
  }
272
479
  /**
273
- * Deterministically evaluate a done gate for one binding. Returns `met: true` with
274
- * canonical `gate.met` event data (allowlisted top-level keys only) when every
275
- * configured required check is present, complete, and green; otherwise `met:
276
- * false` with a fail-closed reason and no event data. Pure and deterministic:
277
- * identical inputs always produce deep-equal output.
480
+ * Deterministically evaluate a done gate for one binding against both a CI
481
+ * snapshot and an optional review snapshot. Returns `met: true` with canonical
482
+ * `gate.met` event data (allowlisted top-level keys only) only when ALL configured
483
+ * conditions are met; otherwise `met: false` with a fail-closed reason. Pure and
484
+ * deterministic: identical inputs always produce deep-equal output. Never throws.
278
485
  */
279
- export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso) {
280
- if (!config.enabled || !config.valid || config.condition === null) {
486
+ export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso, reviewSnapshot = null) {
487
+ if (!config.enabled || !config.valid || config.conditions.length === 0) {
281
488
  return failedEvaluation(`gate inactive: ${config.reason}`);
282
489
  }
283
490
  const headSha = normalizeSha(binding.head_sha);
284
491
  if (headSha === null) {
285
492
  return failedEvaluation("invalid binding: head_sha is not a valid SHA");
286
493
  }
494
+ // Per-condition evaluation accumulators
495
+ const allFailureReasons = [];
496
+ // CI check status (for the structured details payload)
497
+ let checkResults = [];
498
+ let ciConditionType;
499
+ let requiredChecks;
500
+ // Review condition result (for the structured details payload)
501
+ let reviewResult;
287
502
  const byName = new Map();
288
503
  for (const check of snapshot.checks)
289
504
  byName.set(check.name, check);
290
505
  const unknownSet = new Set(snapshot.unknown_checks);
291
- const checkResults = [];
292
- const unmet = [];
293
- for (const name of config.condition.required_checks) {
294
- const check = byName.get(name);
295
- if (!check) {
296
- checkResults.push({ name, present: false, complete: false, green: false });
297
- unmet.push(unknownSet.has(name) ? `${name} (unknown)` : `${name} (missing)`);
298
- continue;
506
+ for (const condition of config.conditions) {
507
+ if (condition.type === REQUIRED_CI_CHECKS_GREEN) {
508
+ ciConditionType = condition.type;
509
+ requiredChecks = [...condition.required_checks];
510
+ checkResults = [];
511
+ const unmet = [];
512
+ for (const name of condition.required_checks) {
513
+ const check = byName.get(name);
514
+ if (!check) {
515
+ checkResults.push({ name, present: false, complete: false, green: false });
516
+ unmet.push(unknownSet.has(name) ? `${name} (unknown)` : `${name} (missing)`);
517
+ continue;
518
+ }
519
+ checkResults.push({ name, present: true, complete: check.complete, green: check.green });
520
+ if (!check.green) {
521
+ unmet.push(check.complete ? `${name} (not green)` : `${name} (pending)`);
522
+ }
523
+ }
524
+ if (unmet.length > 0) {
525
+ allFailureReasons.push(`required checks not green: ${unmet.join(", ")}`);
526
+ }
299
527
  }
300
- checkResults.push({ name, present: true, complete: check.complete, green: check.green });
301
- if (!check.green) {
302
- unmet.push(check.complete ? `${name} (not green)` : `${name} (pending)`);
528
+ else if (condition.type === REVIEW_STATE) {
529
+ reviewResult = evaluateReviewCondition(condition, reviewSnapshot);
530
+ if (!reviewResult.passed) {
531
+ allFailureReasons.push(`review condition not met: ${reviewResult.reason}`);
532
+ }
303
533
  }
304
534
  }
305
- if (unmet.length > 0) {
306
- return failedEvaluation(`required checks not green: ${unmet.join(", ")}`);
535
+ if (allFailureReasons.length > 0) {
536
+ return failedEvaluation(allFailureReasons.join("; "));
537
+ }
538
+ // Build structured per-condition details for telemetry
539
+ const ciCheckStatus = {};
540
+ if (ciConditionType !== undefined) {
541
+ ciCheckStatus.condition_type = ciConditionType;
542
+ ciCheckStatus.required_checks = requiredChecks;
543
+ ciCheckStatus.check_results = checkResults;
544
+ }
545
+ const reviewStatus = {};
546
+ if (reviewResult !== undefined) {
547
+ reviewStatus.passed = reviewResult.passed;
548
+ reviewStatus.reason = reviewResult.reason;
307
549
  }
308
550
  const details = {
309
551
  repo: binding.repo,
@@ -311,11 +553,12 @@ export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso) {
311
553
  head_sha: headSha,
312
554
  gate_name: config.gate_name,
313
555
  config_hash: config.config_hash,
314
- condition_type: config.condition.type,
315
- required_checks: [...config.condition.required_checks],
316
- check_results: checkResults,
317
556
  evaluated_at: evaluatedAtIso,
557
+ ci_check_status: ciCheckStatus,
318
558
  };
559
+ if (reviewResult !== undefined) {
560
+ details.review_status = reviewStatus;
561
+ }
319
562
  const gateEventData = {
320
563
  summary: `Done gate "${config.gate_name}" met for ${binding.subject}`,
321
564
  status: "met",
@@ -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 } 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,24 @@ 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
+ // 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.
65
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`);
102
+ }
66
103
  let claimResult;
67
104
  try {
68
105
  claimResult = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version);
@@ -118,6 +155,86 @@ export async function reconcileEpic(access, observed, plan, deps) {
118
155
  result.warnings.push(`correlate-failed for ${ticketKey}: ${safeMsg}`);
119
156
  }
120
157
  }
158
+ // Step 3.5: Remediation pass (BAPI-441) — re-act on blocked tickets under
159
+ // budget. Keyed off the folded "blocked" status + per-ticket counters, NOT a
160
+ // computeReadySet change (the ready-set still returns only planned tickets).
161
+ // Skipped entirely unless the remediation seams + supervisorConfig are wired.
162
+ const remediationWired = supervisorConfig !== undefined &&
163
+ deps.readWorkerLiveness !== undefined &&
164
+ deps.remediateCas !== undefined &&
165
+ deps.sendNudge !== undefined &&
166
+ deps.resumeDispatch !== undefined;
167
+ if (remediationWired) {
168
+ const cfg = supervisorConfig;
169
+ const readWorkerLiveness = deps.readWorkerLiveness;
170
+ const sendNudge = deps.sendNudge;
171
+ const resumeDispatch = deps.resumeDispatch;
172
+ const remediateCas = deps.remediateCas;
173
+ for (const [ticketKey, status] of observed.ticket_statuses) {
174
+ if (status !== "blocked")
175
+ continue;
176
+ // Per-ticket try/catch so a single ticket's failure never aborts the pass.
177
+ try {
178
+ const counters = observed.ticket_remediation_counters?.get(ticketKey) ?? {
179
+ attempts: 0,
180
+ no_progress: 0,
181
+ };
182
+ const liveness = await readWorkerLiveness(observed.epic_key, ticketKey);
183
+ const decision = decideRemediation(counters.attempts, counters.no_progress, liveness.alive, cfg);
184
+ if (decision === "escalate") {
185
+ await escalate(observed.epic_key, `remediation-budget-exhausted:${ticketKey}`);
186
+ deps.log(`[epic-reconcile] remediation escalate ${ticketKey} ` +
187
+ `(attempts=${counters.attempts} no_progress=${counters.no_progress})`);
188
+ continue;
189
+ }
190
+ // The attempt being recorded is the next one (1-based).
191
+ const attempt = counters.attempts + 1;
192
+ const attemptKind = decision;
193
+ // The folding reason frames the nudge (message type + digest). Default to
194
+ // the review path when the ledger no longer carries the blocking event.
195
+ const reason = observed.ticket_blocked_reasons?.get(ticketKey) ?? "review.changes_requested";
196
+ // A nudge needs a worker to address it to. The liveness scan already
197
+ // resolved the worker id from the same heartbeat that proved the worker
198
+ // alive; if it is missing we cannot relay, so skip BEFORE recording an
199
+ // attempt — otherwise the CAS would burn a budget unit with nothing sent.
200
+ if (decision === "nudge" && !liveness.workerId) {
201
+ result.warnings.push(`remediation nudge skipped for ${ticketKey}: alive worker has no worker_id`);
202
+ continue;
203
+ }
204
+ // Record the attempt durably FIRST. The remediate endpoint builds the
205
+ // (backend-redacted) review digest for a nudge and returns it, and is
206
+ // idempotent (a 409 replay returns conflict, not throw). An unexpected
207
+ // remediate failure is absorbed as a per-ticket warning so the rest of
208
+ // the pass still runs (crash-replay safe).
209
+ let casOutcome;
210
+ try {
211
+ casOutcome = await remediateCas(observed.epic_key, ticketKey, attemptKind, reason);
212
+ }
213
+ catch (err) {
214
+ const safeMsg = err instanceof Error ? err.constructor.name : "remediate error";
215
+ result.warnings.push(`remediate-cas-failed for ${ticketKey} (${attemptKind}): ${safeMsg}`);
216
+ continue;
217
+ }
218
+ if (casOutcome.conflict) {
219
+ // Idempotency replay: the attempt was already recorded (and acted on)
220
+ // on a prior tick. Do not re-act — the crash-replay self-heals here.
221
+ result.warnings.push(`remediation replay swallowed for ${ticketKey} (${attemptKind})`);
222
+ continue;
223
+ }
224
+ if (decision === "nudge") {
225
+ await sendNudge(observed.epic_key, ticketKey, attempt, casOutcome.reviewDigest, casOutcome.truncated, reason, liveness.workerId);
226
+ }
227
+ else {
228
+ await resumeDispatch(observed.epic_key, ticketKey, attempt);
229
+ }
230
+ deps.log(`[epic-reconcile] remediation ${decision} ${ticketKey} attempt=${attempt}`);
231
+ }
232
+ catch (err) {
233
+ const safeMsg = err instanceof Error ? err.constructor.name : "remediation error";
234
+ result.warnings.push(`remediation-error for ${ticketKey}: ${safeMsg}`);
235
+ }
236
+ }
237
+ }
121
238
  // Step 4: Action approved merges via C6 delegation
122
239
  for (const event of observed.pending_merge_events) {
123
240
  const identity = extractMergeActionIdentityFromGateEvent(event);