@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
@@ -30,6 +30,12 @@ const NON_TERMINAL_STATUSES = new Set([
30
30
  "running",
31
31
  "blocked",
32
32
  "ready_for_review",
33
+ // BAPI-445: mid-flight pre-implementation spec re-review. Non-terminal so a
34
+ // review verdict / completion signal can still advance it on a later tick, but
35
+ // deliberately NOT a status computeReadySet returns for normal dispatch — a
36
+ // reviewing ticket is mid-flight, not ready. Stranded reviewing tickets are
37
+ // re-driven by reconcile's dedicated reviewing re-entry branch.
38
+ "reviewing",
33
39
  ]);
34
40
  const TERMINAL_SIGNAL_TYPES = new Set([
35
41
  "gate.met",
@@ -37,18 +43,42 @@ const TERMINAL_SIGNAL_TYPES = new Set([
37
43
  "ci.failed",
38
44
  "run.stopped",
39
45
  "review.changes_requested",
46
+ // BAPI-445: pre-implementation spec re-review verdicts, scoped to review runs.
47
+ "spec_review.passed",
48
+ "spec_review.changes_requested",
40
49
  ]);
41
50
  function isNonTerminal(status) {
42
51
  return NON_TERMINAL_STATUSES.has(status);
43
52
  }
44
- export function signalToNextStatus(signalType) {
53
+ /**
54
+ * Map a terminal ledger signal to the next ticket status.
55
+ *
56
+ * `isReviewRun` marks signals correlated to a `:review`-suffixed dispatch
57
+ * (BAPI-445 Obstacle 2). The spec re-review verdicts fold explicitly:
58
+ * - `spec_review.passed` → `ready` (verdict: proceed to implementation)
59
+ * - `spec_review.changes_requested` → `blocked` (verdict: reject; reconcile escalates)
60
+ *
61
+ * FAIL-SAFE (BAPI-445): a review run's INCIDENTAL terminals (`run.stopped`,
62
+ * `gate.met`, …) are filtered out of folding entirely by `rebuildObservedState`
63
+ * BEFORE this function is called — a review session merely *ending* must never
64
+ * advance a `reviewing` ticket, or implementation would proceed regardless of
65
+ * the review's outcome (fail-open). So for a review run this is only ever called
66
+ * with a `spec_review.*` verdict; `isReviewRun` is retained for caller clarity.
67
+ * For implementation runs the BAPI-436 mapping is unchanged.
68
+ */
69
+ export function signalToNextStatus(signalType, isReviewRun = false) {
70
+ void isReviewRun; // review-run incidental terminals are filtered before this call
71
+ if (signalType === "spec_review.passed")
72
+ return "ready";
73
+ if (signalType === "spec_review.changes_requested")
74
+ return "blocked";
45
75
  if (signalType === "ci.failed")
46
76
  return "blocked";
47
77
  if (signalType === "review.changes_requested")
48
78
  return "blocked";
49
79
  if (signalType === "merge.succeeded")
50
80
  return "done";
51
- return "ready_for_review"; // gate.met and run.stopped: awaiting merge
81
+ return "ready_for_review"; // gate.met and (implementation) run.stopped: awaiting merge
52
82
  }
53
83
  // ---------------------------------------------------------------------------
54
84
  // computeReadySet
@@ -74,6 +104,13 @@ export function computeReadySet(plan, ticketStatuses) {
74
104
  }
75
105
  return ready;
76
106
  }
107
+ /**
108
+ * Default ceiling for spec re-review dispatch attempts on a stranded `reviewing`
109
+ * ticket (BAPI-445). Counts the initial review dispatch plus liveness-recovery
110
+ * re-dispatches; once reached, the reviewing recovery branch escalates instead
111
+ * of re-dispatching. Deliberately independent of the BAPI-441 remediation budget.
112
+ */
113
+ export const DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS = 3;
77
114
  /**
78
115
  * Pure, deterministic remediation decision (no I/O, no clock). Given the current
79
116
  * budget counters, the ledger-derived worker liveness, and the configured
@@ -124,11 +161,20 @@ export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
124
161
  */
125
162
  export function rebuildObservedState(postgresState, events, _now) {
126
163
  const { epic_run, ticket_statuses, dispatches } = postgresState;
127
- // Build run_id → ticket_key lookup from dispatch records
164
+ // Build run_id → {ticket_key, isReview} lookup from dispatch records.
165
+ // BAPI-445 (Obstacle 2): a review run's dispatch_key carries a ":review"
166
+ // suffix, so the same run_id map can distinguish a pre-implementation
167
+ // spec-review run from the implementation run. This is correctness-critical:
168
+ // it lets the fold below route a review run's run.stopped to "ready" (review
169
+ // complete → proceed) instead of mis-folding it as implementation completion
170
+ // ("ready_for_review", awaiting merge), which would skip implementation.
128
171
  const runIdToTicketKey = new Map();
129
172
  for (const dispatch of dispatches) {
130
173
  if (dispatch.run_id) {
131
- runIdToTicketKey.set(dispatch.run_id, dispatch.ticket_key);
174
+ runIdToTicketKey.set(dispatch.run_id, {
175
+ ticketKey: dispatch.ticket_key,
176
+ isReview: dispatch.dispatch_key.endsWith(":review"),
177
+ });
132
178
  }
133
179
  }
134
180
  // Populate base maps from Postgres
@@ -154,16 +200,36 @@ export function rebuildObservedState(postgresState, events, _now) {
154
200
  for (const event of events) {
155
201
  if (!TERMINAL_SIGNAL_TYPES.has(event.type))
156
202
  continue;
157
- // Map event → ticket via run_id
203
+ // Map event → ticket via run_id, resolving the dispatch role (review vs.
204
+ // implementation) so the fold can treat review-run terminals distinctly.
158
205
  const runId = typeof event.run_id === "string" ? event.run_id : null;
159
- const ticketKey = runId ? runIdToTicketKey.get(runId) : undefined;
160
- if (!ticketKey)
206
+ const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
207
+ if (!mapped)
208
+ continue;
209
+ const ticketKey = mapped.ticketKey;
210
+ const isReview = mapped.isReview;
211
+ // BAPI-445 FAIL-SAFE: a review run's INCIDENTAL terminals (run.stopped,
212
+ // gate.met, …) must never advance a `reviewing` ticket — ONLY an explicit
213
+ // spec_review.* verdict does. A review session merely ending must not fold
214
+ // reviewing → ready and dispatch implementation regardless of whether the
215
+ // review requested changes (that would be fail-OPEN). When the verdict is
216
+ // not (yet) emitted, the ticket stays in `reviewing` and the reconcile
217
+ // liveness-recovery / escalation path surfaces it for operator attention —
218
+ // implementation never silently proceeds without a pass verdict.
219
+ const isSpecVerdict = event.type === "spec_review.passed" ||
220
+ event.type === "spec_review.changes_requested";
221
+ if (isReview && !isSpecVerdict)
161
222
  continue;
162
223
  // Record the blocking reason (latest wins; events are seq-ordered) regardless
163
224
  // of fold state, so a ticket blocked on a prior tick still resolves a reason.
164
225
  if (event.type === "ci.failed" || event.type === "review.changes_requested") {
165
226
  ticketBlockedReasons.set(ticketKey, event.type);
166
227
  }
228
+ else if (event.type === "spec_review.changes_requested") {
229
+ // BAPI-445: spec-review rejection. Tagged distinctly so reconcile escalates
230
+ // it without spending the BAPI-441 implementation remediation budget.
231
+ ticketBlockedReasons.set(ticketKey, "spec_review.changes_requested");
232
+ }
167
233
  const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
168
234
  if (!isNonTerminal(postgresStatus))
169
235
  continue;
@@ -175,16 +241,24 @@ export function rebuildObservedState(postgresState, events, _now) {
175
241
  pendingMergeEvents.push(event);
176
242
  }
177
243
  const signalType = event.type;
178
- const nextStatus = signalToNextStatus(signalType);
244
+ const nextStatus = signalToNextStatus(signalType, isReview);
179
245
  if (foldedTicketKeys.has(ticketKey)) {
180
- // Allow a same-tick upgrade from ready_for_review done when
181
- // merge.succeeded arrives after gate.met in the same ledger batch.
246
+ // Allow a bounded same-tick upgrade of an already-folded ticket:
247
+ // 1. ready_for_review → done when merge.succeeded arrives after gate.met
248
+ // in the same ledger batch.
249
+ // 2. (BAPI-445) <any review fold> → blocked when a review run's
250
+ // spec_review.changes_requested arrives after its run.stopped in the
251
+ // same batch. A spec-review rejection must dominate plain completion
252
+ // so a changes-requested verdict is never lost to event ordering.
182
253
  // First-signal-wins for everything else: if ci.failed arrived first,
183
254
  // currentLocalStatus is "blocked" (not "ready_for_review"), so the
184
255
  // upgrade guard below is false and merge.succeeded is intentionally
185
256
  // dropped — a failed-CI ticket must not be silently advanced to done.
186
257
  const currentLocalStatus = ticketStatusMap.get(ticketKey);
187
- if (currentLocalStatus === "ready_for_review" && nextStatus === "done") {
258
+ const mergeUpgrade = currentLocalStatus === "ready_for_review" && nextStatus === "done";
259
+ const specRejectUpgrade = signalType === "spec_review.changes_requested" &&
260
+ currentLocalStatus !== "blocked";
261
+ if (mergeUpgrade || specRejectUpgrade) {
188
262
  const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
189
263
  if (existingIdx >= 0) {
190
264
  unfoldedSignals[existingIdx] = {
@@ -64,6 +64,18 @@ function isSqliteBusyError(error) {
64
64
  * the raw error text, stack, and any secret material are discarded.
65
65
  */
66
66
  export function toConductorErrorEnvelope(error) {
67
+ // Optional-native-binding degradation (BAPI-451): the conductor `store.ts`
68
+ // throws ConductorPersistenceUnavailableError when `better-sqlite3` could not be
69
+ // loaded. Detected by name (not instanceof) to avoid an import cycle with
70
+ // store.ts. Surfaced as a retryable-shaped 503 with a fixed, secret-free message
71
+ // so the caller learns persistence is unavailable instead of getting an opaque 500.
72
+ if (error instanceof Error && error.name === "ConductorPersistenceUnavailableError") {
73
+ return {
74
+ error: "PERSISTENCE_UNAVAILABLE",
75
+ status: 503,
76
+ message: "Conductor persistence is unavailable (the optional better-sqlite3 native module is not loaded). Conductor coordination features are disabled; core tools are unaffected.",
77
+ };
78
+ }
67
79
  if (error instanceof ConductorValidationError) {
68
80
  // Validation messages are conductor-authored and name the offending field,
69
81
  // never its value — but redact defensively so an adversarial/secret-bearing
@@ -33,6 +33,16 @@ export const DEFAULT_GATE_NAME = "done";
33
33
  export const REVIEW_PASSED = "review.passed";
34
34
  /** Event type emitted when a reviewer requests changes. */
35
35
  export const REVIEW_CHANGES_REQUESTED = "review.changes_requested";
36
+ /**
37
+ * BAPI-445 pre-implementation SPEC re-review verdict event types. Emitted by the
38
+ * spec re-review run (``/review-ticket --auto``), correlated to that review run,
39
+ * and kept distinct from the PR-review {@link REVIEW_PASSED} /
40
+ * {@link REVIEW_CHANGES_REQUESTED} verdicts so a spec-review outcome is never
41
+ * folded as the implementation PR's review state.
42
+ */
43
+ export const SPEC_REVIEW_PASSED = "spec_review.passed";
44
+ /** Event type emitted when a spec re-review requests changes. */
45
+ export const SPEC_REVIEW_CHANGES_REQUESTED = "spec_review.changes_requested";
36
46
  /** Matches ASCII control characters (C0 range plus DEL). */
37
47
  const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
38
48
  /** Matches a 40- or 64-character hex string (git SHA-1 / SHA-256 object ids). */
@@ -79,7 +79,7 @@ export function buildWorktreeChangedEventInput(context, updates, phase) {
79
79
  * dedupe by `repo + commit_sha`, and emit best-effort. Returns a
80
80
  * success-compatible result for every failure mode so the commit is never blocked.
81
81
  */
82
- export function runPostCommitHookProducer(deps = {}) {
82
+ export async function runPostCommitHookProducer(deps = {}) {
83
83
  const getContext = deps.getContext ?? getGitWorktreeContext;
84
84
  const readMetadata = deps.readMetadata ?? readHeadCommitMetadata;
85
85
  const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
@@ -90,7 +90,7 @@ export function runPostCommitHookProducer(deps = {}) {
90
90
  return { ok: true, emitted: false, reason: "no-head-commit" };
91
91
  }
92
92
  const input = buildCommitCreatedEventInput(context, metadata);
93
- const decision = emitIfNew(input, {
93
+ const decision = await emitIfNew(input, {
94
94
  event_type: "git.commit_created",
95
95
  repo: context.repo,
96
96
  commit_sha: metadata.sha,
@@ -109,7 +109,7 @@ export function runPostCommitHookProducer(deps = {}) {
109
109
  * success-compatible result for every failure mode so the ref update is never
110
110
  * blocked.
111
111
  */
112
- export function runReferenceTransactionHookProducer(args, deps = {}) {
112
+ export async function runReferenceTransactionHookProducer(args, deps = {}) {
113
113
  const getContext = deps.getContext ?? getGitWorktreeContext;
114
114
  const parseUpdates = deps.parseUpdates ?? parseReferenceTransactionUpdates;
115
115
  const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
@@ -124,7 +124,7 @@ export function runReferenceTransactionHookProducer(args, deps = {}) {
124
124
  const context = getContext({ cwd: deps.cwd, env: deps.env });
125
125
  const input = buildWorktreeChangedEventInput(context, updates, args.phase);
126
126
  const refUpdatesHash = stableJsonHash({ phase: args.phase, updates });
127
- const decision = emitIfNew(input, {
127
+ const decision = await emitIfNew(input, {
128
128
  event_type: "worktree.changed",
129
129
  repo: context.repo,
130
130
  ref_updates_hash: refUpdatesHash,
@@ -103,9 +103,9 @@ export function makeMergeEventId(eventType, actionKey) {
103
103
  const h = createHash("sha256").update(`${eventType}:${actionKey}`).digest("hex");
104
104
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
105
105
  }
106
- function lookupMergeEventByActionKey(eventType, actionKey, deps) {
106
+ async function lookupMergeEventByActionKey(eventType, actionKey, deps) {
107
107
  const openDb = deps.openDb ?? (() => openReadonlyConductorDatabaseIfExists());
108
- const db = openDb();
108
+ const db = await openDb();
109
109
  if (!db)
110
110
  return false;
111
111
  try {
@@ -126,7 +126,7 @@ function lookupMergeEventByActionKey(eventType, actionKey, deps) {
126
126
  * the action key. Only `merge.succeeded` is terminal — `merge.failed` and
127
127
  * `merge.dry_run` are never terminal.
128
128
  */
129
- export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
129
+ export async function hasTerminalMergeSucceeded(actionKey, deps = {}) {
130
130
  return lookupMergeEventByActionKey("merge.succeeded", actionKey, deps);
131
131
  }
132
132
  /**
@@ -134,7 +134,7 @@ export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
134
134
  * Dry-run is audit/hygiene only and is NEVER treated as terminal — a later real
135
135
  * merge for the same PR/head/gate may still proceed if the repo flag is enabled.
136
136
  */
137
- export function hasMergeDryRun(actionKey, deps = {}) {
137
+ export async function hasMergeDryRun(actionKey, deps = {}) {
138
138
  return lookupMergeEventByActionKey("merge.dry_run", actionKey, deps);
139
139
  }
140
140
  /**
@@ -142,7 +142,7 @@ export function hasMergeDryRun(actionKey, deps = {}) {
142
142
  * action key. Pending approval is NEVER terminal — the supervisor must re-dispatch
143
143
  * until the backend reports approval (returning `merge.attempted` + `merge.succeeded`).
144
144
  */
145
- export function hasMergePendingApproval(actionKey, deps = {}) {
145
+ export async function hasMergePendingApproval(actionKey, deps = {}) {
146
146
  return lookupMergeEventByActionKey("merge.pending_approval", actionKey, deps);
147
147
  }
148
148
  /** Heuristically detect a SQLite duplicate-id / UNIQUE constraint failure. */
@@ -167,7 +167,7 @@ function isDuplicateConstraintError(error) {
167
167
  * outcome fields live under `details`). A duplicate UNIQUE-constraint failure is
168
168
  * classified as `{ emitted: false, reason: "duplicate" }` rather than thrown.
169
169
  */
170
- export function emitMergeLedgerEventIfNew(input, deps = {}) {
170
+ export async function emitMergeLedgerEventIfNew(input, deps = {}) {
171
171
  const emitEvent = deps.emitEvent ?? emitConductorEvent;
172
172
  const eventId = makeMergeEventId(input.type, input.action_key);
173
173
  const event = {
@@ -186,7 +186,7 @@ export function emitMergeLedgerEventIfNew(input, deps = {}) {
186
186
  },
187
187
  };
188
188
  try {
189
- const result = emitEvent(event);
189
+ const result = await emitEvent(event);
190
190
  return { emitted: true, event_id: eventId, event: result.event };
191
191
  }
192
192
  catch (error) {
@@ -149,7 +149,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
149
149
  reason: "observed",
150
150
  };
151
151
  // 1. PR opened telemetry (independent of CI / gate).
152
- const prDecision = emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
152
+ const prDecision = await emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
153
153
  event_type: "git.pr_opened",
154
154
  repo: binding.repo,
155
155
  pr_number: binding.pr_number,
@@ -174,7 +174,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
174
174
  }
175
175
  else {
176
176
  result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
177
- const ciDecision = emitIfNew(ciEvent, {
177
+ const ciDecision = await emitIfNew(ciEvent, {
178
178
  event_type: ciEvent.type,
179
179
  repo: binding.repo,
180
180
  pr_number: binding.pr_number,
@@ -210,7 +210,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
210
210
  result.gate_met = true;
211
211
  const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
212
212
  if (gateEvent !== null) {
213
- const gateDecision = emitIfNew(gateEvent, {
213
+ const gateDecision = await emitIfNew(gateEvent, {
214
214
  event_type: "gate.met",
215
215
  repo: binding.repo,
216
216
  pr_number: binding.pr_number,
@@ -409,7 +409,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
409
409
  gate_emitted: false,
410
410
  reason: "observed",
411
411
  };
412
- const prDecision = emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
412
+ const prDecision = await emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
413
413
  event_type: "git.pr_opened",
414
414
  repo: binding.repo,
415
415
  pr_number: binding.pr_number,
@@ -423,7 +423,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
423
423
  return result;
424
424
  }
425
425
  result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
426
- const ciDecision = emitIfNew(ciEvent, {
426
+ const ciDecision = await emitIfNew(ciEvent, {
427
427
  event_type: ciEvent.type,
428
428
  repo: binding.repo,
429
429
  pr_number: binding.pr_number,
@@ -461,7 +461,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
461
461
  result.gate_met = true;
462
462
  const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
463
463
  if (gateEvent !== null) {
464
- const gateDecision = emitIfNew(gateEvent, {
464
+ const gateDecision = await emitIfNew(gateEvent, {
465
465
  event_type: "gate.met",
466
466
  repo: binding.repo,
467
467
  pr_number: binding.pr_number,
@@ -99,13 +99,13 @@ export async function observeReviewWithResolved(binding, access, gateConfig, dep
99
99
  };
100
100
  if (evalResult.changesRequested) {
101
101
  const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_CHANGES_REQUESTED, evalResult.reason, run_id, worker_id);
102
- const decision = emitIfNew(event, { event_type: REVIEW_CHANGES_REQUESTED, ...baseDimensions });
102
+ const decision = await emitIfNew(event, { event_type: REVIEW_CHANGES_REQUESTED, ...baseDimensions });
103
103
  result.review_changes_requested_emitted = decision.emitted;
104
104
  result.reason = "review changes requested";
105
105
  }
106
106
  else if (evalResult.passed) {
107
107
  const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_PASSED, evalResult.reason, run_id, worker_id);
108
- const decision = emitIfNew(event, { event_type: REVIEW_PASSED, ...baseDimensions });
108
+ const decision = await emitIfNew(event, { event_type: REVIEW_PASSED, ...baseDimensions });
109
109
  result.review_passed_emitted = decision.emitted;
110
110
  result.reason = "review passed";
111
111
  }
@@ -59,13 +59,13 @@ const LEDGER_SCAN_MAX_PAGES = 200;
59
59
  * full details object is visible. Malformed/non-matching events are skipped; the
60
60
  * scan never throws.
61
61
  */
62
- export function eventAlreadyExists(dedupeKey, deps = {}) {
62
+ export async function eventAlreadyExists(dedupeKey, deps = {}) {
63
63
  const pollEvents = deps.pollEvents ?? ((options) => pollConductorEvents(options));
64
64
  let sinceSeq = 1;
65
65
  for (let page = 0; page < LEDGER_SCAN_MAX_PAGES; page += 1) {
66
66
  let result;
67
67
  try {
68
- result = pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
68
+ result = await pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
69
69
  }
70
70
  catch {
71
71
  return false;
@@ -95,10 +95,10 @@ export function eventAlreadyExists(dedupeKey, deps = {}) {
95
95
  * under `data.details`, then emits. A duplicate-id constraint thrown by a racing
96
96
  * producer is classified as a duplicate rather than surfaced as a failure.
97
97
  */
98
- export function emitConductorEventIfNew(input, dimensions, deps = {}) {
98
+ export async function emitConductorEventIfNew(input, dimensions, deps = {}) {
99
99
  const emitEvent = deps.emitEvent ?? emitConductorEvent;
100
100
  const dedupeKey = makeProducerDedupeKey(dimensions);
101
- if (eventAlreadyExists(dedupeKey, deps)) {
101
+ if (await eventAlreadyExists(dedupeKey, deps)) {
102
102
  return { emitted: false, reason: "duplicate" };
103
103
  }
104
104
  const eventId = makeStableProducerEventId(dedupeKey);
@@ -113,7 +113,7 @@ export function emitConductorEventIfNew(input, dimensions, deps = {}) {
113
113
  details: { ...existingDetails, dedupe_key: dedupeKey },
114
114
  };
115
115
  try {
116
- emitEvent({ ...input, id: eventId, data });
116
+ await emitEvent({ ...input, id: eventId, data });
117
117
  return { emitted: true, event_id: eventId };
118
118
  }
119
119
  catch (error) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Pre-implementation SPEC re-review verdict production (BAPI-445 Phase 2).
3
+ *
4
+ * Emits the spec re-review verdict (`spec_review.passed` /
5
+ * `spec_review.changes_requested`) into the LOCAL conductor ledger, correlated
6
+ * to the spec-review run via `BAPI_CONDUCTOR_RUN_ID`. The Epic Supervisor's
7
+ * reconcile pass folds these signals: `passed` → `reviewing → ready` (proceed to
8
+ * implementation), `changes_requested` → `blocked` (escalate; do NOT implement).
9
+ *
10
+ * Why this is a LOCAL producer, not a backend webhook change: the conductor
11
+ * ledger is a per-worker SQLite file (`~/.config/bridge/events.db`). The
12
+ * Heroku-hosted Bridge API backend has no access to it. So — exactly like the
13
+ * PR-review producer ({@link ./pr-review-producer}) — the verdict is emitted by
14
+ * the LOCAL spec-review run, whose environment carries `BAPI_CONDUCTOR_RUN_ID`
15
+ * (injected at dispatch time by {@link ../review-tickets}.orchestrateReviewTickets
16
+ * when an epic identity is present). This corrects the original BAPI-445 plan,
17
+ * which proposed emitting from the backend review webhook
18
+ * (`code_writer_jira.py`) — that path cannot reach the worker-local ledger.
19
+ *
20
+ * The verdict family is kept DISTINCT from the PR-review `review.passed` /
21
+ * `review.changes_requested` verdicts (BAPI-440) so a spec-review outcome is
22
+ * never mis-folded as the implementation PR's review state (Obstacle 2).
23
+ */
24
+ // TODO(BAPI-445 Phase 2 integration): `emitSpecReviewVerdict` is the verdict
25
+ // EMISSION primitive but currently has no caller. Until the review-ticket
26
+ // completion flow derives a pass/changes-requested decision from the critique
27
+ // and calls this function, no `spec_review.*` event is ever written.
28
+ //
29
+ // This is FAIL-SAFE, not fail-open: epic-state.ts `rebuildObservedState` filters
30
+ // a review run's incidental terminals (run.stopped, …) out of folding, so a
31
+ // completed review with NO verdict leaves the ticket in `reviewing` rather than
32
+ // advancing it to `ready` and dispatching implementation. The reconcile
33
+ // liveness-recovery / escalation path then surfaces the stranded ticket for
34
+ // operator attention. Only an explicit `spec_review.passed` proceeds and only
35
+ // `spec_review.changes_requested` blocks — so wiring this producer in is what
36
+ // makes the gate ACTIVE; without it, opted-in tickets hold safely instead of
37
+ // silently implementing against the review's wishes.
38
+ import { SPEC_REVIEW_CHANGES_REQUESTED, SPEC_REVIEW_PASSED } from "./git-ci-types.js";
39
+ import { emitConductorEventIfNew } from "./producer-ledger.js";
40
+ /** Producer + observed-via identifiers stamped onto emitted spec-review events. */
41
+ export const SPEC_REVIEW_PRODUCER = "spec-review-producer";
42
+ /**
43
+ * Build a `spec_review.passed` / `spec_review.changes_requested` event input for
44
+ * a ticket's spec re-review verdict, correlated to the review run via `runId`.
45
+ */
46
+ export function buildSpecReviewVerdictEventInput(ticketKey, verdict, reason, runId = null, workerId = null) {
47
+ const eventType = verdict === "passed" ? SPEC_REVIEW_PASSED : SPEC_REVIEW_CHANGES_REQUESTED;
48
+ return {
49
+ source: "review",
50
+ type: eventType,
51
+ subject: ticketKey,
52
+ run_id: runId,
53
+ worker_id: workerId,
54
+ producer: SPEC_REVIEW_PRODUCER,
55
+ observed_via: SPEC_REVIEW_PRODUCER,
56
+ data: {
57
+ summary: verdict === "passed"
58
+ ? `Spec re-review passed for ${ticketKey}`
59
+ : `Spec re-review requested changes for ${ticketKey}`,
60
+ status: verdict,
61
+ details: { ticket_key: ticketKey, reason },
62
+ },
63
+ };
64
+ }
65
+ /**
66
+ * Emit a spec re-review verdict for `ticketKey` into the local conductor ledger,
67
+ * correlated to the spec-review run via `BAPI_CONDUCTOR_RUN_ID` (read from the
68
+ * injected env). Idempotent: deduped by `event_type` + `run_id`, so re-emitting
69
+ * the same run's verdict is a no-op. A missing run id still emits (uncorrelated)
70
+ * but is logged in the result so callers can detect the misconfiguration.
71
+ */
72
+ export async function emitSpecReviewVerdict(ticketKey, verdict, reason, deps = {}) {
73
+ const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
74
+ const run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
75
+ const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
76
+ const eventType = verdict === "passed" ? SPEC_REVIEW_PASSED : SPEC_REVIEW_CHANGES_REQUESTED;
77
+ const event = buildSpecReviewVerdictEventInput(ticketKey, verdict, reason, run_id, worker_id);
78
+ const decision = await emitIfNew(event, {
79
+ event_type: eventType,
80
+ run_id: run_id ?? "",
81
+ });
82
+ return {
83
+ emitted: decision.emitted,
84
+ event_type: eventType,
85
+ run_id,
86
+ reason: run_id ? reason : `${reason} (warning: no BAPI_CONDUCTOR_RUN_ID — verdict uncorrelated)`,
87
+ };
88
+ }