@bridge_gpt/mcp-server 0.2.10 → 0.2.13

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 (54) hide show
  1. package/README.md +66 -4
  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 +79 -2
  6. package/build/conductor/epic-reconcile.js +213 -16
  7. package/build/conductor/epic-runtime.js +215 -60
  8. package/build/conductor/epic-state.js +105 -16
  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/local-merge.js +212 -0
  13. package/build/conductor/merge-ledger.js +7 -7
  14. package/build/conductor/pr-ci-producer.js +18 -8
  15. package/build/conductor/pr-review-producer.js +2 -2
  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 +100 -25
  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 +1 -1
  22. package/build/conductor/supervisor-runtime.js +10 -10
  23. package/build/conductor/taxonomy.js +9 -0
  24. package/build/conductor/tools.js +5 -5
  25. package/build/conductor-bin.js +12689 -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 +203 -0
  29. package/build/index.js +25084 -3632
  30. package/build/init.js +57 -0
  31. package/build/install-bridge.js +80 -0
  32. package/build/mcp-profile.js +33 -30
  33. package/build/pipelines.generated.js +70 -48
  34. package/build/readme.generated.js +1 -1
  35. package/build/sfcc/client.js +151 -0
  36. package/build/sfcc/config.js +39 -0
  37. package/build/sfcc/credentials.js +136 -0
  38. package/build/sfcc/ocapi-shape.js +77 -0
  39. package/build/sfcc/output.js +39 -0
  40. package/build/sfcc/permissions.js +136 -0
  41. package/build/sfcc/reads-custom-object-def.js +119 -0
  42. package/build/sfcc/reads-site-preference.js +158 -0
  43. package/build/sfcc/reads-system-object.js +162 -0
  44. package/build/sfcc/register.js +73 -0
  45. package/build/sfcc/setup-status.js +114 -0
  46. package/build/sfcc/tool-wrapper.js +70 -0
  47. package/build/start-tickets-conductor.js +9 -1
  48. package/build/start-tickets.js +47 -4
  49. package/build/version.generated.js +1 -1
  50. package/package.json +7 -4
  51. package/pipelines/check-ci-ticket.json +2 -2
  52. package/pipelines/implement-ticket.json +2 -2
  53. package/pipelines/learn-repository.json +84 -42
  54. 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;
@@ -7,6 +7,7 @@
7
7
  * failure. This module performs NO writes: no hook installation, no schema
8
8
  * migration, no event emission, and no scheduler unit creation.
9
9
  */
10
+ import { spawnSync } from "node:child_process";
10
11
  import { doctorConductorLedger } from "./store.js";
11
12
  import { inspectConductorGitHooks } from "./git-hooks.js";
12
13
  /**
@@ -105,6 +106,65 @@ export function inspectMcpProfile(env, epicTick) {
105
106
  }
106
107
  return { resolved_profile, conductor_context_detected, degraded, warnings };
107
108
  }
109
+ /**
110
+ * Read-only probe of the local-merge (F4) capability. Runs `gh --version` and
111
+ * `gh auth status` (no writes, no merge) and reports whether the host could merge
112
+ * a PR locally if `policy_json.local_merge.enabled` were set. Never throws.
113
+ */
114
+ export function inspectLocalMerge(runCommand) {
115
+ const run = runCommand ??
116
+ ((cmd, args) => {
117
+ try {
118
+ // Static ESM import (this package is "type": "module"): a lazy
119
+ // require("child_process") throws ReferenceError under ESM, which the
120
+ // catch swallowed — making `gh` always look unavailable (false negative).
121
+ //
122
+ // `gh auth status` hits api.github.com; with no timeout a network stall
123
+ // (expired token, DNS hiccup, partition) blocks the doctor process until
124
+ // the OS TCP timeout fires (30s–2min). Bound it to 10s — a hung health
125
+ // check then simply reports `gh` unavailable. GH_PROMPT_DISABLED keeps an
126
+ // interactive prompt from holding the process past the timeout.
127
+ const r = spawnSync(cmd, args, {
128
+ encoding: "utf8",
129
+ timeout: 10_000,
130
+ env: { ...process.env, GH_PROMPT_DISABLED: "1" },
131
+ });
132
+ return { status: r.status };
133
+ }
134
+ catch {
135
+ return { status: null };
136
+ }
137
+ });
138
+ let gh_available = false;
139
+ let gh_authed = false;
140
+ try {
141
+ gh_available = run("gh", ["--version"]).status === 0;
142
+ }
143
+ catch {
144
+ gh_available = false;
145
+ }
146
+ if (gh_available) {
147
+ try {
148
+ gh_authed = run("gh", ["auth", "status"]).status === 0;
149
+ }
150
+ catch {
151
+ gh_authed = false;
152
+ }
153
+ }
154
+ const warnings = [];
155
+ if (!gh_available) {
156
+ warnings.push("`gh` is not installed or not on PATH. Local merge (policy_json.local_merge.enabled) " +
157
+ "cannot run; install the GitHub CLI to enable conductor-driven merges.");
158
+ }
159
+ else if (!gh_authed) {
160
+ warnings.push("`gh` is installed but not authenticated (`gh auth status` failed). Run `gh auth login` " +
161
+ "to grant the conductor merge permission; otherwise local merge will emit merge.failed/skip.");
162
+ }
163
+ // Capability gap only — local merge is opt-in, so an unauthed host is not a hard
164
+ // failure unless the operator enabled it. Reported as degraded for visibility.
165
+ const degraded = !gh_available || !gh_authed;
166
+ return { gh_available, gh_authed, degraded, warnings };
167
+ }
108
168
  /**
109
169
  * Build the combined read-only doctor report. Composes the existing ledger
110
170
  * doctor, git hook inspection, and the epic-tick schedule enablement check.
@@ -116,10 +176,11 @@ export async function buildConductorDoctorReport(deps = {}) {
116
176
  const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
117
177
  const mcp_profile = inspectMcpProfile(deps.env ?? process.env, epicTick);
118
178
  return {
119
- ledger: doctorLedger(),
179
+ ledger: await doctorLedger(),
120
180
  git_hooks: inspectHooks(deps.hooksDeps),
121
181
  epic_tick: epicTick,
122
182
  mcp_profile,
183
+ local_merge: inspectLocalMerge(deps.runCommand),
123
184
  };
124
185
  }
125
186
  /**
@@ -128,7 +189,7 @@ export async function buildConductorDoctorReport(deps = {}) {
128
189
  * status tags consistent with the git hooks section's visual hierarchy.
129
190
  */
130
191
  export function formatConductorDoctorReport(report) {
131
- const { ledger, git_hooks, epic_tick, mcp_profile } = report;
192
+ const { ledger, git_hooks, epic_tick, mcp_profile, local_merge } = report;
132
193
  const lines = [
133
194
  "Conductor ledger doctor",
134
195
  "───────────────────────",
@@ -193,5 +254,21 @@ export function formatConductorDoctorReport(report) {
193
254
  for (const w of mcp_profile.warnings)
194
255
  lines.push(` - ${w}`);
195
256
  }
257
+ lines.push("");
258
+ lines.push("Local merge capability (optional, opt-in)");
259
+ lines.push("─────────────────────────────────────────");
260
+ const ghTag = local_merge.gh_available
261
+ ? local_merge.gh_authed
262
+ ? "[OK]"
263
+ : "[WARNING] not authenticated"
264
+ : "[WARNING] not installed";
265
+ lines.push(`gh available: ${local_merge.gh_available} ${ghTag}`);
266
+ lines.push(`gh authenticated: ${local_merge.gh_authed}`);
267
+ lines.push(`degraded: ${local_merge.degraded}`);
268
+ if (local_merge.warnings.length > 0) {
269
+ lines.push("local merge warnings:");
270
+ for (const w of local_merge.warnings)
271
+ lines.push(` - ${w}`);
272
+ }
196
273
  return lines.join("\n");
197
274
  }
@@ -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
+ }