@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
@@ -11,7 +11,16 @@
11
11
  */
12
12
  import { GIT_CI_PRODUCER, } from "./git-ci-types.js";
13
13
  import { evaluateDoneGate, normalizeCiSnapshot, parseDoneGateConfig } from "./done-gate.js";
14
- import { fetchDoneGateConfigField, pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
14
+ import { observeReviewWithResolved } from "./pr-review-producer.js";
15
+ import { fetchEffectiveSupervisorSetup, fetchActiveEpicRuns, fetchEpicRunState, pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
16
+ /** Default gate-config fetcher: reads done_gate_config from the supervisor setup. */
17
+ async function _fetchGateConfigDefault(access) {
18
+ const setup = await fetchEffectiveSupervisorSetup(access);
19
+ // Fail closed when no row exists (source="none")
20
+ if (setup.source === "none")
21
+ return undefined;
22
+ return setup.done_gate_config ?? undefined;
23
+ }
15
24
  import { resolvePrHeadBinding } from "./pr-discovery.js";
16
25
  import { emitConductorEventIfNew } from "./producer-ledger.js";
17
26
  const PRODUCER_OBSERVED_VIA = "pr-ci-producer";
@@ -24,7 +33,7 @@ export const WAIT_FOR_GATE_POLL_INTERVAL_MIN_MS = 500;
24
33
  // Event builders
25
34
  // ---------------------------------------------------------------------------
26
35
  /** Build a `git.pr_opened` event bound to repo + PR number + head SHA. */
27
- export function buildPrOpenedEventInput(binding) {
36
+ export function buildPrOpenedEventInput(binding, runId = null, workerId = null) {
28
37
  const details = {
29
38
  repo: binding.repo,
30
39
  pr_number: binding.pr_number,
@@ -43,6 +52,8 @@ export function buildPrOpenedEventInput(binding) {
43
52
  source: "git",
44
53
  type: "git.pr_opened",
45
54
  subject: binding.subject,
55
+ run_id: runId,
56
+ worker_id: workerId,
46
57
  producer: GIT_CI_PRODUCER,
47
58
  observed_via: PRODUCER_OBSERVED_VIA,
48
59
  data,
@@ -54,7 +65,7 @@ export function buildPrOpenedEventInput(binding) {
54
65
  * `ci.passed` requires every polled check complete and green; `ci.failed` requires
55
66
  * every polled check complete with at least one not green.
56
67
  */
57
- export function buildCiObservationEventInput(binding, snapshot) {
68
+ export function buildCiObservationEventInput(binding, snapshot, runId = null, workerId = null) {
58
69
  if (snapshot.checks.length === 0)
59
70
  return null;
60
71
  const allComplete = snapshot.checks.every((c) => c.complete);
@@ -74,6 +85,8 @@ export function buildCiObservationEventInput(binding, snapshot) {
74
85
  source: "ci",
75
86
  type,
76
87
  subject: binding.subject,
88
+ run_id: runId,
89
+ worker_id: workerId,
77
90
  producer: GIT_CI_PRODUCER,
78
91
  observed_via: PRODUCER_OBSERVED_VIA,
79
92
  data: {
@@ -87,13 +100,15 @@ export function buildCiObservationEventInput(binding, snapshot) {
87
100
  * Build a canonical `gate.met` event from a successful {@link GateEvaluationResult}.
88
101
  * Returns `null` when the gate was not met (no event data to emit).
89
102
  */
90
- export function buildGateMetEventInput(binding, evaluation) {
103
+ export function buildGateMetEventInput(binding, evaluation, runId = null, workerId = null) {
91
104
  if (!evaluation.met || !evaluation.gateEventData)
92
105
  return null;
93
106
  return {
94
107
  source: "conductor",
95
108
  type: "gate.met",
96
109
  subject: binding.subject,
110
+ run_id: runId,
111
+ worker_id: workerId,
97
112
  producer: GIT_CI_PRODUCER,
98
113
  observed_via: PRODUCER_OBSERVED_VIA,
99
114
  data: { ...evaluation.gateEventData },
@@ -112,6 +127,18 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
112
127
  const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
113
128
  const pollCi = deps.pollCi ?? pollCiChecksForCommit;
114
129
  const now = deps.now ?? (() => new Date().toISOString());
130
+ // Resolve run_id/worker_id for attribution. Env takes precedence; fall back
131
+ // to the injected resolution seam when the env identity is absent.
132
+ let run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
133
+ const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
134
+ if (run_id === null && deps.resolveRunId) {
135
+ try {
136
+ run_id = await deps.resolveRunId(access, binding) ?? null;
137
+ }
138
+ catch {
139
+ run_id = null;
140
+ }
141
+ }
115
142
  const result = {
116
143
  binding,
117
144
  pr_opened_emitted: false,
@@ -122,7 +149,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
122
149
  reason: "observed",
123
150
  };
124
151
  // 1. PR opened telemetry (independent of CI / gate).
125
- const prDecision = emitIfNew(buildPrOpenedEventInput(binding), {
152
+ const prDecision = await emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
126
153
  event_type: "git.pr_opened",
127
154
  repo: binding.repo,
128
155
  pr_number: binding.pr_number,
@@ -141,13 +168,13 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
141
168
  }
142
169
  const snapshot = normalizeCiSnapshot(rawPoll);
143
170
  // 3. CI observation telemetry (only for terminal states).
144
- const ciEvent = buildCiObservationEventInput(binding, snapshot);
171
+ const ciEvent = buildCiObservationEventInput(binding, snapshot, run_id, worker_id);
145
172
  if (ciEvent === null) {
146
173
  result.ci_status = "pending";
147
174
  }
148
175
  else {
149
176
  result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
150
- const ciDecision = emitIfNew(ciEvent, {
177
+ const ciDecision = await emitIfNew(ciEvent, {
151
178
  event_type: ciEvent.type,
152
179
  repo: binding.repo,
153
180
  pr_number: binding.pr_number,
@@ -161,15 +188,29 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
161
188
  result.reason = `gate inactive: ${gateConfig.reason}`;
162
189
  return result;
163
190
  }
164
- const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now());
191
+ // 4a. Observe review state (alongside CI) for composite gate evaluation.
192
+ // A review poll failure must not block the CI observation — fail open here.
193
+ let reviewSnapshot = null;
194
+ try {
195
+ const reviewObservation = await observeReviewWithResolved(binding, access, gateConfig, {
196
+ emitIfNew: deps.emitIfNew ?? emitConductorEventIfNew,
197
+ env: deps.env,
198
+ });
199
+ reviewSnapshot = reviewObservation.snapshot;
200
+ }
201
+ catch {
202
+ // fail open: a review observation error does not block CI/gate evaluation
203
+ reviewSnapshot = null;
204
+ }
205
+ const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now(), reviewSnapshot);
165
206
  if (!evaluation.met) {
166
207
  result.reason = evaluation.reason;
167
208
  return result;
168
209
  }
169
210
  result.gate_met = true;
170
- const gateEvent = buildGateMetEventInput(binding, evaluation);
211
+ const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
171
212
  if (gateEvent !== null) {
172
- const gateDecision = emitIfNew(gateEvent, {
213
+ const gateDecision = await emitIfNew(gateEvent, {
173
214
  event_type: "gate.met",
174
215
  repo: binding.repo,
175
216
  pr_number: binding.pr_number,
@@ -190,7 +231,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
190
231
  export async function observePrCiOnce(params = {}, deps = {}) {
191
232
  const resolveBinding = deps.resolveBinding ?? resolvePrHeadBinding;
192
233
  const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: deps.cwd }));
193
- const fetchGateConfig = deps.fetchGateConfig ?? fetchDoneGateConfigField;
234
+ const fetchGateConfig = deps.fetchGateConfig ?? _fetchGateConfigDefault;
194
235
  const bindingResult = resolveBinding({ repoName: params.repoName, prNumber: params.prNumber, headSha: params.headSha, cwd: params.cwd ?? deps.cwd, env: deps.env }, deps.bindingDeps ?? {});
195
236
  if (!bindingResult.ok) {
196
237
  return {
@@ -239,7 +280,7 @@ function clampInt(value, fallback, min, max) {
239
280
  export async function waitForDoneGate(params = {}, deps = {}) {
240
281
  const resolveBinding = deps.resolveBinding ?? resolvePrHeadBinding;
241
282
  const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: params.worktreePath ?? deps.cwd }));
242
- const fetchGateConfig = deps.fetchGateConfig ?? fetchDoneGateConfigField;
283
+ const fetchGateConfig = deps.fetchGateConfig ?? _fetchGateConfigDefault;
243
284
  const sleep = deps.sleep ?? defaultSleep;
244
285
  const now = deps.now ?? (() => new Date().toISOString());
245
286
  const timeoutMs = clampInt(params.timeoutMs, WAIT_FOR_GATE_TIMEOUT_DEFAULT_MS, 0, WAIT_FOR_GATE_TIMEOUT_MAX_MS);
@@ -355,6 +396,10 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
355
396
  reason: "commit ref does not match PR head",
356
397
  };
357
398
  }
399
+ // Env-stamp identity; fall back to the resolution seam when env is absent.
400
+ // Access resolution for the seam is attempted inline within the gate block.
401
+ let run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
402
+ const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
358
403
  const result = {
359
404
  binding,
360
405
  pr_opened_emitted: false,
@@ -364,7 +409,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
364
409
  gate_emitted: false,
365
410
  reason: "observed",
366
411
  };
367
- const prDecision = emitIfNew(buildPrOpenedEventInput(binding), {
412
+ const prDecision = await emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
368
413
  event_type: "git.pr_opened",
369
414
  repo: binding.repo,
370
415
  pr_number: binding.pr_number,
@@ -372,13 +417,13 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
372
417
  });
373
418
  result.pr_opened_emitted = prDecision.emitted;
374
419
  const snapshot = normalizeCiSnapshot(pollResponse);
375
- const ciEvent = buildCiObservationEventInput(binding, snapshot);
420
+ const ciEvent = buildCiObservationEventInput(binding, snapshot, run_id, worker_id);
376
421
  if (ciEvent === null) {
377
422
  result.ci_status = "pending";
378
423
  return result;
379
424
  }
380
425
  result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
381
- const ciDecision = emitIfNew(ciEvent, {
426
+ const ciDecision = await emitIfNew(ciEvent, {
382
427
  event_type: ciEvent.type,
383
428
  repo: binding.repo,
384
429
  pr_number: binding.pr_number,
@@ -388,13 +433,23 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
388
433
  result.ci_emitted = ciDecision.emitted;
389
434
  // Best-effort gate evaluation when access + config are available.
390
435
  const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: deps.cwd }));
391
- const fetchGateConfig = deps.fetchGateConfig ?? fetchDoneGateConfigField;
436
+ const fetchGateConfig = deps.fetchGateConfig ?? _fetchGateConfigDefault;
392
437
  try {
393
438
  const accessResult = await resolveAccess();
394
439
  if (accessResult.ok) {
440
+ const access = accessResult.access;
441
+ // If run_id is still null, try the resolution seam now that we have access.
442
+ if (run_id === null && deps.resolveRunId) {
443
+ try {
444
+ run_id = await deps.resolveRunId(access, binding) ?? null;
445
+ }
446
+ catch {
447
+ run_id = null;
448
+ }
449
+ }
395
450
  let rawConfig;
396
451
  try {
397
- rawConfig = await fetchGateConfig(accessResult.access);
452
+ rawConfig = await fetchGateConfig(access);
398
453
  }
399
454
  catch {
400
455
  rawConfig = undefined;
@@ -404,9 +459,9 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
404
459
  const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now());
405
460
  if (evaluation.met) {
406
461
  result.gate_met = true;
407
- const gateEvent = buildGateMetEventInput(binding, evaluation);
462
+ const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
408
463
  if (gateEvent !== null) {
409
- const gateDecision = emitIfNew(gateEvent, {
464
+ const gateDecision = await emitIfNew(gateEvent, {
410
465
  event_type: "gate.met",
411
466
  repo: binding.repo,
412
467
  pr_number: binding.pr_number,
@@ -425,3 +480,47 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
425
480
  }
426
481
  return result;
427
482
  }
483
+ // ---------------------------------------------------------------------------
484
+ // Dispatch run_id resolution seam (Approach B: binding → ticket → dispatch)
485
+ // ---------------------------------------------------------------------------
486
+ /** Extract a Jira ticket key from a git branch name, e.g. `feature/BAPI-437-slug` → `BAPI-437`. */
487
+ function extractTicketKeyFromRef(headRef) {
488
+ if (!headRef)
489
+ return null;
490
+ const match = /([A-Z][A-Z0-9]+-\d+)/i.exec(headRef);
491
+ return match ? match[1].toUpperCase() : null;
492
+ }
493
+ /**
494
+ * Resolve the dispatch `run_id` for a PR binding by extracting the ticket key
495
+ * from `binding.head_ref`, listing active epic runs, and searching each epic's
496
+ * dispatch rows for a match. Returns `null` when the lookup yields no result or
497
+ * any step fails — the producer never crashes on a resolution failure.
498
+ *
499
+ * Intended to be injected as `PrCiProducerDeps.resolveRunId` at call sites
500
+ * that lack `BAPI_CONDUCTOR_RUN_ID` in the environment (git-hook / CI-poll
501
+ * contexts). A production `fetchImpl` is optional; defaults to the global fetch.
502
+ */
503
+ export async function resolveDispatchRunIdForBinding(access, binding, fetchImpl = fetch) {
504
+ const ticketKey = extractTicketKeyFromRef(binding.head_ref);
505
+ if (!ticketKey)
506
+ return null;
507
+ let activeRuns;
508
+ try {
509
+ activeRuns = await fetchActiveEpicRuns(access, fetchImpl);
510
+ }
511
+ catch {
512
+ return null;
513
+ }
514
+ for (const run of activeRuns) {
515
+ try {
516
+ const state = await fetchEpicRunState(access, run.epic_key, fetchImpl);
517
+ const dispatch = state.dispatches.find((d) => d.ticket_key === ticketKey && d.run_id !== null);
518
+ if (dispatch?.run_id)
519
+ return dispatch.run_id;
520
+ }
521
+ catch {
522
+ // Skip this epic on any error; try the next one.
523
+ }
524
+ }
525
+ return null;
526
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * PR review event production (BAPI-440).
3
+ *
4
+ * Polls the backend-owned review-status endpoint and emits `review.passed` /
5
+ * `review.changes_requested` exactly once per stable review state (deduped by
6
+ * review_state_hash). A poll failure never emits `review.changes_requested`
7
+ * (mirroring the CI rule that a failed poll is never a failed check).
8
+ *
9
+ * This module is pure of VCS credentials — the backend endpoint owns them.
10
+ */
11
+ import { GIT_CI_PRODUCER, REVIEW_CHANGES_REQUESTED, REVIEW_PASSED, } from "./git-ci-types.js";
12
+ import { evaluateReviewCondition, normalizeReviewSnapshot } from "./done-gate.js";
13
+ import { fetchPrReviewStatus } from "./bridge-api-client.js";
14
+ import { emitConductorEventIfNew } from "./producer-ledger.js";
15
+ export const REVIEW_PRODUCER_OBSERVED_VIA = "pr-review-producer";
16
+ // ---------------------------------------------------------------------------
17
+ // Event builders
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Build a `review.passed` or `review.changes_requested` event input from a
21
+ * normalized snapshot, or `null` when neither outcome is determinable.
22
+ */
23
+ export function buildReviewObservationEventInput(binding, snapshot, eventType, reason, runId = null, workerId = null) {
24
+ return {
25
+ source: "review",
26
+ type: eventType,
27
+ subject: binding.subject,
28
+ run_id: runId,
29
+ worker_id: workerId,
30
+ producer: GIT_CI_PRODUCER,
31
+ observed_via: REVIEW_PRODUCER_OBSERVED_VIA,
32
+ data: {
33
+ summary: eventType === REVIEW_PASSED
34
+ ? `Review passed for ${binding.subject}`
35
+ : `Review changes requested for ${binding.subject}`,
36
+ status: eventType === REVIEW_PASSED ? "passed" : "changes_requested",
37
+ details: {
38
+ repo: binding.repo,
39
+ pr_number: binding.pr_number,
40
+ head_sha: binding.head_sha,
41
+ review_decision: snapshot.review_decision,
42
+ approvals: snapshot.approvals,
43
+ sticky_verdict: snapshot.sticky_verdict,
44
+ review_state_hash: snapshot.review_state_hash,
45
+ reason,
46
+ },
47
+ },
48
+ };
49
+ }
50
+ /**
51
+ * Observe the PR review state for an already-resolved binding/access/config.
52
+ * Polls the backend endpoint, normalizes the snapshot, evaluates the review
53
+ * condition, and emits the appropriate event through the dedupe layer.
54
+ *
55
+ * A poll failure or unavailable envelope returns `null` snapshot and emits
56
+ * nothing — it is never a `review.changes_requested`.
57
+ */
58
+ export async function observeReviewWithResolved(binding, access, gateConfig, deps = {}) {
59
+ const fetchStatus = deps.fetchReviewStatus ?? fetchPrReviewStatus;
60
+ const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
61
+ const run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
62
+ const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
63
+ const result = {
64
+ snapshot: null,
65
+ review_passed_emitted: false,
66
+ review_changes_requested_emitted: false,
67
+ reason: "observed",
68
+ };
69
+ // Find the review_state condition from the gate config (if present).
70
+ const reviewCondition = gateConfig.conditions.find((c) => c.type === "review_state") ?? null;
71
+ // CI-only gate: no review condition, skip the backend round-trip entirely.
72
+ if (reviewCondition === null) {
73
+ result.reason = "no-review-condition";
74
+ return result;
75
+ }
76
+ // Poll the backend endpoint. A thrown error means unavailable — fail open,
77
+ // emit nothing (mirroring CI producer rule: "a failed poll is never a failure").
78
+ let rawStatus;
79
+ try {
80
+ rawStatus = await fetchStatus(access, binding.pr_number);
81
+ }
82
+ catch {
83
+ result.reason = "review-poll-failed";
84
+ return result;
85
+ }
86
+ const snapshot = normalizeReviewSnapshot(rawStatus);
87
+ result.snapshot = snapshot;
88
+ if (snapshot === null) {
89
+ result.reason = "review-snapshot-unavailable";
90
+ return result;
91
+ }
92
+ // Evaluate the review condition (reviewCondition is non-null — null is handled above).
93
+ const evalResult = evaluateReviewCondition(reviewCondition, snapshot);
94
+ const baseDimensions = {
95
+ repo: binding.repo,
96
+ pr_number: binding.pr_number,
97
+ head_sha: binding.head_sha,
98
+ review_state_hash: snapshot.review_state_hash,
99
+ };
100
+ if (evalResult.changesRequested) {
101
+ const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_CHANGES_REQUESTED, evalResult.reason, run_id, worker_id);
102
+ const decision = await emitIfNew(event, { event_type: REVIEW_CHANGES_REQUESTED, ...baseDimensions });
103
+ result.review_changes_requested_emitted = decision.emitted;
104
+ result.reason = "review changes requested";
105
+ }
106
+ else if (evalResult.passed) {
107
+ const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_PASSED, evalResult.reason, run_id, worker_id);
108
+ const decision = await emitIfNew(event, { event_type: REVIEW_PASSED, ...baseDimensions });
109
+ result.review_passed_emitted = decision.emitted;
110
+ result.reason = "review passed";
111
+ }
112
+ else {
113
+ result.reason = `review not yet passed: ${evalResult.reason}`;
114
+ }
115
+ return result;
116
+ }
@@ -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
+ }