@bridge_gpt/mcp-server 0.2.2 → 0.2.3

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 (113) hide show
  1. package/README.md +97 -15
  2. package/build/agent-config-credential-migration.js +272 -0
  3. package/build/agents.generated.js +1 -1
  4. package/build/chain-orchestrator.js +16 -1
  5. package/build/commands.generated.js +9 -7
  6. package/build/conductor/bridge-api-client.js +625 -0
  7. package/build/conductor/claude-hook.js +251 -0
  8. package/build/conductor/cli.js +1048 -0
  9. package/build/conductor/data-normalization.js +114 -0
  10. package/build/conductor/doctor.js +164 -0
  11. package/build/conductor/done-gate.js +325 -0
  12. package/build/conductor/epic-reconcile.js +139 -0
  13. package/build/conductor/epic-runtime.js +611 -0
  14. package/build/conductor/epic-state.js +125 -0
  15. package/build/conductor/errors.js +85 -0
  16. package/build/conductor/git-ci-types.js +129 -0
  17. package/build/conductor/git-hooks.js +218 -0
  18. package/build/conductor/git-inspection.js +185 -0
  19. package/build/conductor/git-producer.js +137 -0
  20. package/build/conductor/merge-ledger.js +198 -0
  21. package/build/conductor/paths.js +224 -0
  22. package/build/conductor/plan.js +77 -0
  23. package/build/conductor/pr-ci-producer.js +427 -0
  24. package/build/conductor/pr-discovery.js +135 -0
  25. package/build/conductor/producer-ledger.js +125 -0
  26. package/build/conductor/redaction.js +112 -0
  27. package/build/conductor/store.js +1156 -0
  28. package/build/conductor/supervisor-config.js +150 -0
  29. package/build/conductor/supervisor-escalation.js +244 -0
  30. package/build/conductor/supervisor-judgment-python.js +141 -0
  31. package/build/conductor/supervisor-judgment.js +215 -0
  32. package/build/conductor/supervisor-ledger.js +119 -0
  33. package/build/conductor/supervisor-merge.js +127 -0
  34. package/build/conductor/supervisor-message-relay.js +61 -0
  35. package/build/conductor/supervisor-notification.js +39 -0
  36. package/build/conductor/supervisor-runtime.js +351 -0
  37. package/build/conductor/supervisor-state.js +572 -0
  38. package/build/conductor/supervisor-types.js +16 -0
  39. package/build/conductor/taxonomy.js +58 -0
  40. package/build/conductor/tools.js +367 -0
  41. package/build/conductor/types.js +9 -0
  42. package/build/conductor-bin.js +21 -0
  43. package/build/conductor-claude-hook-bin.js +21 -0
  44. package/build/credential-store.js +175 -4
  45. package/build/credentials-cli.js +223 -0
  46. package/build/decision-page-schema.js +60 -0
  47. package/build/decision-page-template.js +262 -10
  48. package/build/doctor.js +5 -1
  49. package/build/index.js +468 -59
  50. package/build/pipeline-orchestrator.js +5 -1
  51. package/build/pipeline-utils.js +45 -5
  52. package/build/pipelines.generated.js +37 -9
  53. package/build/readme.generated.js +1 -1
  54. package/build/review-tickets.js +596 -0
  55. package/build/scheduled-prompt.js +16 -10
  56. package/build/start-tickets-conductor.js +496 -0
  57. package/build/start-tickets-prereqs.js +32 -23
  58. package/build/start-tickets-repo.js +49 -0
  59. package/build/start-tickets.js +682 -81
  60. package/build/version.generated.js +1 -1
  61. package/design-assets/favicon/android-chrome-192x192.png +0 -0
  62. package/design-assets/favicon/android-chrome-512x512.png +0 -0
  63. package/design-assets/favicon/apple-touch-icon.png +0 -0
  64. package/design-assets/favicon/favicon-16x16.png +0 -0
  65. package/design-assets/favicon/favicon-32x32.png +0 -0
  66. package/design-assets/favicon/favicon.ico +0 -0
  67. package/design-assets/favicon/site.webmanifest +1 -0
  68. package/design-assets/just-logo-rough-draft.png +0 -0
  69. package/package.json +17 -5
  70. package/pipelines/idea-to-ticket.json +5 -0
  71. package/pipelines/plan-epic.json +16 -1
  72. package/pipelines/review-ticket.json +2 -1
  73. package/public/css/main.min.css +2 -0
  74. package/public/css/main.min.css.map +1 -0
  75. package/public/fonts/OFL.txt +93 -0
  76. package/public/fonts/SourceSansPro-Black.ttf +0 -0
  77. package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  78. package/public/fonts/SourceSansPro-Bold.ttf +0 -0
  79. package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  80. package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  81. package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  82. package/public/fonts/SourceSansPro-Italic.ttf +0 -0
  83. package/public/fonts/SourceSansPro-Light.ttf +0 -0
  84. package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
  85. package/public/fonts/SourceSansPro-Regular.ttf +0 -0
  86. package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
  87. package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  88. package/public/img/bridge-logo-160x51.webp +0 -0
  89. package/public/img/bridge-logo-300x92.webp +0 -0
  90. package/public/img/favicon/android-chrome-192x192.png +0 -0
  91. package/public/img/favicon/android-chrome-512x512.png +0 -0
  92. package/public/img/favicon/apple-touch-icon.png +0 -0
  93. package/public/img/favicon/favicon-16x16.png +0 -0
  94. package/public/img/favicon/favicon-32x32.png +0 -0
  95. package/public/img/favicon/favicon.ico +0 -0
  96. package/public/img/favicon/site.webmanifest +1 -0
  97. package/public/img/installation/bitbucket/app-password-1.png +0 -0
  98. package/public/img/installation/bitbucket/app-password-2.png +0 -0
  99. package/public/img/installation/bitbucket/create-token-1.png +0 -0
  100. package/public/img/installation/bitbucket/create-token-2.png +0 -0
  101. package/public/img/installation/bitbucket/webhook-1.png +0 -0
  102. package/public/img/installation/github/github-review-webhook.png +0 -0
  103. package/public/img/installation/jira/credentials/api-key.png +0 -0
  104. package/public/img/installation/jira/webhook/create-rule.png +0 -0
  105. package/public/img/installation/jira/webhook/project-settings.png +0 -0
  106. package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
  107. package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
  108. package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
  109. package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
  110. package/public/img/installation/pinecone/pinecone-index.png +0 -0
  111. package/public/js/main.min.js +2 -0
  112. package/public/js/main.min.js.map +1 -0
  113. package/smoke-test/SMOKE-TEST.md +16 -8
@@ -0,0 +1,427 @@
1
+ /**
2
+ * PR/CI event production and bounded done-gate waiting (BAPI-395).
3
+ *
4
+ * Observes PR/CI state for an immutable `repo + pr_number + head_sha` binding and
5
+ * emits `git.pr_opened`, `ci.passed`, `ci.failed`, and — when the configured
6
+ * required checks are green — `gate.met` exactly once per binding + effective gate
7
+ * config. PR/CI telemetry is INDEPENDENT of gate emission: a disabled/unset/
8
+ * malformed gate suppresses only `gate.met`. A failed CI poll is never a failed CI
9
+ * check. All event types come from the existing conductor taxonomy and use only
10
+ * allowlisted top-level data keys.
11
+ */
12
+ import { GIT_CI_PRODUCER, } from "./git-ci-types.js";
13
+ import { evaluateDoneGate, normalizeCiSnapshot, parseDoneGateConfig } from "./done-gate.js";
14
+ import { fetchDoneGateConfigField, pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
15
+ import { resolvePrHeadBinding } from "./pr-discovery.js";
16
+ import { emitConductorEventIfNew } from "./producer-ledger.js";
17
+ const PRODUCER_OBSERVED_VIA = "pr-ci-producer";
18
+ /** Bounded wait constants, consistent with the existing wait_for_event pattern. */
19
+ export const WAIT_FOR_GATE_TIMEOUT_MAX_MS = 120_000;
20
+ export const WAIT_FOR_GATE_TIMEOUT_DEFAULT_MS = 120_000;
21
+ export const WAIT_FOR_GATE_POLL_INTERVAL_DEFAULT_MS = 5_000;
22
+ export const WAIT_FOR_GATE_POLL_INTERVAL_MIN_MS = 500;
23
+ // ---------------------------------------------------------------------------
24
+ // Event builders
25
+ // ---------------------------------------------------------------------------
26
+ /** Build a `git.pr_opened` event bound to repo + PR number + head SHA. */
27
+ export function buildPrOpenedEventInput(binding) {
28
+ const details = {
29
+ repo: binding.repo,
30
+ pr_number: binding.pr_number,
31
+ head_sha: binding.head_sha,
32
+ };
33
+ if (binding.head_ref !== undefined)
34
+ details.head_ref = binding.head_ref;
35
+ const data = {
36
+ summary: `PR ${binding.subject} observed`,
37
+ status: "open",
38
+ details,
39
+ };
40
+ if (binding.url !== undefined)
41
+ data.references = { url: binding.url };
42
+ return {
43
+ source: "git",
44
+ type: "git.pr_opened",
45
+ subject: binding.subject,
46
+ producer: GIT_CI_PRODUCER,
47
+ observed_via: PRODUCER_OBSERVED_VIA,
48
+ data,
49
+ };
50
+ }
51
+ /**
52
+ * Build a `ci.passed`/`ci.failed` event from a normalized snapshot, or `null` when
53
+ * there is nothing terminal to report (no checks, or any check still pending).
54
+ * `ci.passed` requires every polled check complete and green; `ci.failed` requires
55
+ * every polled check complete with at least one not green.
56
+ */
57
+ export function buildCiObservationEventInput(binding, snapshot) {
58
+ if (snapshot.checks.length === 0)
59
+ return null;
60
+ const allComplete = snapshot.checks.every((c) => c.complete);
61
+ if (!allComplete)
62
+ return null;
63
+ const allGreen = snapshot.checks.every((c) => c.green);
64
+ const type = allGreen ? "ci.passed" : "ci.failed";
65
+ const details = {
66
+ repo: binding.repo,
67
+ pr_number: binding.pr_number,
68
+ head_sha: binding.head_sha,
69
+ checks: snapshot.checks,
70
+ unknown_checks: snapshot.unknown_checks,
71
+ check_state_hash: snapshot.check_state_hash,
72
+ };
73
+ return {
74
+ source: "ci",
75
+ type,
76
+ subject: binding.subject,
77
+ producer: GIT_CI_PRODUCER,
78
+ observed_via: PRODUCER_OBSERVED_VIA,
79
+ data: {
80
+ summary: allGreen ? `CI passed for ${binding.subject}` : `CI failed for ${binding.subject}`,
81
+ status: allGreen ? "passed" : "failed",
82
+ details,
83
+ },
84
+ };
85
+ }
86
+ /**
87
+ * Build a canonical `gate.met` event from a successful {@link GateEvaluationResult}.
88
+ * Returns `null` when the gate was not met (no event data to emit).
89
+ */
90
+ export function buildGateMetEventInput(binding, evaluation) {
91
+ if (!evaluation.met || !evaluation.gateEventData)
92
+ return null;
93
+ return {
94
+ source: "conductor",
95
+ type: "gate.met",
96
+ subject: binding.subject,
97
+ producer: GIT_CI_PRODUCER,
98
+ observed_via: PRODUCER_OBSERVED_VIA,
99
+ data: { ...evaluation.gateEventData },
100
+ };
101
+ }
102
+ function defaultSleep(ms) {
103
+ return new Promise((resolve) => setTimeout(resolve, ms));
104
+ }
105
+ /**
106
+ * Observe PR/CI state once against an already-resolved binding/access/config and
107
+ * emit telemetry + (when met) the gate event. Shared by {@link observePrCiOnce}
108
+ * and {@link waitForDoneGate} so the gate config / binding / access are each
109
+ * resolved exactly once by the caller.
110
+ */
111
+ async function observeWithResolved(binding, access, gateConfig, deps) {
112
+ const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
113
+ const pollCi = deps.pollCi ?? pollCiChecksForCommit;
114
+ const now = deps.now ?? (() => new Date().toISOString());
115
+ const result = {
116
+ binding,
117
+ pr_opened_emitted: false,
118
+ ci_status: null,
119
+ ci_emitted: false,
120
+ gate_met: false,
121
+ gate_emitted: false,
122
+ reason: "observed",
123
+ };
124
+ // 1. PR opened telemetry (independent of CI / gate).
125
+ const prDecision = emitIfNew(buildPrOpenedEventInput(binding), {
126
+ event_type: "git.pr_opened",
127
+ repo: binding.repo,
128
+ pr_number: binding.pr_number,
129
+ head_sha: binding.head_sha,
130
+ });
131
+ result.pr_opened_emitted = prDecision.emitted;
132
+ // 2. Poll CI for the bound head SHA. A poll failure is NOT a CI failure event.
133
+ let rawPoll;
134
+ try {
135
+ rawPoll = await pollCi(access, binding.head_sha);
136
+ }
137
+ catch {
138
+ result.ci_status = "unavailable";
139
+ result.reason = "ci-poll-failed";
140
+ return result;
141
+ }
142
+ const snapshot = normalizeCiSnapshot(rawPoll);
143
+ // 3. CI observation telemetry (only for terminal states).
144
+ const ciEvent = buildCiObservationEventInput(binding, snapshot);
145
+ if (ciEvent === null) {
146
+ result.ci_status = "pending";
147
+ }
148
+ else {
149
+ result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
150
+ const ciDecision = emitIfNew(ciEvent, {
151
+ event_type: ciEvent.type,
152
+ repo: binding.repo,
153
+ pr_number: binding.pr_number,
154
+ head_sha: binding.head_sha,
155
+ ci_check_hash: snapshot.check_state_hash,
156
+ });
157
+ result.ci_emitted = ciDecision.emitted;
158
+ }
159
+ // 4. Gate evaluation — suppressed entirely when config is inactive.
160
+ if (!gateConfig.enabled || !gateConfig.valid) {
161
+ result.reason = `gate inactive: ${gateConfig.reason}`;
162
+ return result;
163
+ }
164
+ const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now());
165
+ if (!evaluation.met) {
166
+ result.reason = evaluation.reason;
167
+ return result;
168
+ }
169
+ result.gate_met = true;
170
+ const gateEvent = buildGateMetEventInput(binding, evaluation);
171
+ if (gateEvent !== null) {
172
+ const gateDecision = emitIfNew(gateEvent, {
173
+ event_type: "gate.met",
174
+ repo: binding.repo,
175
+ pr_number: binding.pr_number,
176
+ head_sha: binding.head_sha,
177
+ config_hash: gateConfig.config_hash ?? undefined,
178
+ });
179
+ result.gate_emitted = gateDecision.emitted;
180
+ result.gate_event_summary = gateEvent.data?.summary;
181
+ }
182
+ result.reason = "gate met";
183
+ return result;
184
+ }
185
+ /**
186
+ * One-shot PR/CI observation: resolve the binding, resolve Bridge API access,
187
+ * fetch + parse the done-gate config, then observe once. Returns a structured
188
+ * result. Emits no events and polls nothing when there is no PR/head binding.
189
+ */
190
+ export async function observePrCiOnce(params = {}, deps = {}) {
191
+ const resolveBinding = deps.resolveBinding ?? resolvePrHeadBinding;
192
+ const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: deps.cwd }));
193
+ const fetchGateConfig = deps.fetchGateConfig ?? fetchDoneGateConfigField;
194
+ const bindingResult = resolveBinding({ repoName: params.repoName, prNumber: params.prNumber, headSha: params.headSha, cwd: params.cwd ?? deps.cwd, env: deps.env }, deps.bindingDeps ?? {});
195
+ if (!bindingResult.ok) {
196
+ return {
197
+ binding: null,
198
+ pr_opened_emitted: false,
199
+ ci_status: null,
200
+ ci_emitted: false,
201
+ gate_met: false,
202
+ gate_emitted: false,
203
+ reason: `no binding: ${bindingResult.reason}`,
204
+ };
205
+ }
206
+ const accessResult = await resolveAccess();
207
+ if (!accessResult.ok) {
208
+ return {
209
+ binding: bindingResult.binding,
210
+ pr_opened_emitted: false,
211
+ ci_status: "unavailable",
212
+ ci_emitted: false,
213
+ gate_met: false,
214
+ gate_emitted: false,
215
+ reason: `access unavailable: ${accessResult.error}`,
216
+ };
217
+ }
218
+ let rawConfig;
219
+ try {
220
+ rawConfig = await fetchGateConfig(accessResult.access);
221
+ }
222
+ catch {
223
+ rawConfig = undefined; // fail closed
224
+ }
225
+ const gateConfig = parseDoneGateConfig(rawConfig);
226
+ return observeWithResolved(bindingResult.binding, accessResult.access, gateConfig, deps);
227
+ }
228
+ function clampInt(value, fallback, min, max) {
229
+ if (typeof value !== "number" || !Number.isFinite(value))
230
+ return fallback;
231
+ return Math.min(max, Math.max(min, Math.floor(value)));
232
+ }
233
+ /**
234
+ * Bounded, synchronous (no background daemon) wait for the done gate. Resolves the
235
+ * immutable PR/head binding ONCE and fetches the gate config ONCE at loop start,
236
+ * then polls CI on a clamped interval until the gate is met or the clamped timeout
237
+ * elapses. All CI polls and emitted events use the SHA captured at loop start.
238
+ */
239
+ export async function waitForDoneGate(params = {}, deps = {}) {
240
+ const resolveBinding = deps.resolveBinding ?? resolvePrHeadBinding;
241
+ const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: params.worktreePath ?? deps.cwd }));
242
+ const fetchGateConfig = deps.fetchGateConfig ?? fetchDoneGateConfigField;
243
+ const sleep = deps.sleep ?? defaultSleep;
244
+ const now = deps.now ?? (() => new Date().toISOString());
245
+ const timeoutMs = clampInt(params.timeoutMs, WAIT_FOR_GATE_TIMEOUT_DEFAULT_MS, 0, WAIT_FOR_GATE_TIMEOUT_MAX_MS);
246
+ const pollIntervalMs = clampInt(params.pollIntervalMs, WAIT_FOR_GATE_POLL_INTERVAL_DEFAULT_MS, WAIT_FOR_GATE_POLL_INTERVAL_MIN_MS, WAIT_FOR_GATE_TIMEOUT_MAX_MS);
247
+ // Bind ONCE — never re-resolve a mutable branch mid-loop.
248
+ const bindingResult = resolveBinding({ repoName: params.repoName, prNumber: params.prNumber, headSha: params.headSha, cwd: params.worktreePath ?? deps.cwd, env: deps.env }, deps.bindingDeps ?? {});
249
+ if (!bindingResult.ok) {
250
+ return {
251
+ gate_met: false,
252
+ timed_out: false,
253
+ reason: `no binding: ${bindingResult.reason}`,
254
+ repo: null,
255
+ pr_number: null,
256
+ head_sha: null,
257
+ };
258
+ }
259
+ const binding = bindingResult.binding;
260
+ const accessResult = await resolveAccess();
261
+ if (!accessResult.ok) {
262
+ return {
263
+ gate_met: false,
264
+ timed_out: false,
265
+ reason: `access unavailable: ${accessResult.error}`,
266
+ repo: binding.repo,
267
+ pr_number: binding.pr_number,
268
+ head_sha: binding.head_sha,
269
+ };
270
+ }
271
+ // Fetch + parse config ONCE.
272
+ let rawConfig;
273
+ try {
274
+ rawConfig = await fetchGateConfig(accessResult.access);
275
+ }
276
+ catch {
277
+ rawConfig = undefined;
278
+ }
279
+ const gateConfig = parseDoneGateConfig(rawConfig);
280
+ if (!gateConfig.enabled || !gateConfig.valid) {
281
+ return {
282
+ gate_met: false,
283
+ timed_out: false,
284
+ reason: `gate inactive: ${gateConfig.reason}`,
285
+ repo: binding.repo,
286
+ pr_number: binding.pr_number,
287
+ head_sha: binding.head_sha,
288
+ };
289
+ }
290
+ const deadline = Date.now() + timeoutMs;
291
+ // Use the SAME `now`/sleep deps for every iteration so emitted gate timestamps
292
+ // and the loop are fully controllable in tests.
293
+ const loopDeps = { ...deps, now };
294
+ // Poll at least once; re-poll until met or the deadline passes.
295
+ // eslint-disable-next-line no-constant-condition
296
+ while (true) {
297
+ const observation = await observeWithResolved(binding, accessResult.access, gateConfig, loopDeps);
298
+ if (observation.gate_met) {
299
+ return {
300
+ gate_met: true,
301
+ timed_out: false,
302
+ reason: observation.reason,
303
+ repo: binding.repo,
304
+ pr_number: binding.pr_number,
305
+ head_sha: binding.head_sha,
306
+ gate_event_summary: observation.gate_event_summary,
307
+ };
308
+ }
309
+ if (Date.now() >= deadline) {
310
+ return {
311
+ gate_met: false,
312
+ timed_out: true,
313
+ reason: observation.reason,
314
+ repo: binding.repo,
315
+ pr_number: binding.pr_number,
316
+ head_sha: binding.head_sha,
317
+ };
318
+ }
319
+ const remaining = deadline - Date.now();
320
+ await sleep(Math.min(pollIntervalMs, Math.max(1, remaining)));
321
+ }
322
+ }
323
+ /**
324
+ * Step-11 opportunistic helper: emit PR/CI/gate events from an ALREADY-FETCHED
325
+ * `poll_ci_checks` response, but only after validating that `commitRef` binds to
326
+ * the resolved PR head SHA. Never changes any caller's response; emits nothing
327
+ * when there is no binding or the commit ref does not match the PR head.
328
+ */
329
+ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps = {}) {
330
+ const resolveBinding = deps.resolveBinding ?? resolvePrHeadBinding;
331
+ const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
332
+ const now = deps.now ?? (() => new Date().toISOString());
333
+ const bindingResult = resolveBinding({ cwd: deps.cwd, env: deps.env }, deps.bindingDeps ?? {});
334
+ if (!bindingResult.ok) {
335
+ return {
336
+ binding: null,
337
+ pr_opened_emitted: false,
338
+ ci_status: null,
339
+ ci_emitted: false,
340
+ gate_met: false,
341
+ gate_emitted: false,
342
+ reason: `no binding: ${bindingResult.reason}`,
343
+ };
344
+ }
345
+ const binding = bindingResult.binding;
346
+ // The poll response must correspond to the bound head SHA.
347
+ if (commitRef.trim().toLowerCase() !== binding.head_sha) {
348
+ return {
349
+ binding,
350
+ pr_opened_emitted: false,
351
+ ci_status: null,
352
+ ci_emitted: false,
353
+ gate_met: false,
354
+ gate_emitted: false,
355
+ reason: "commit ref does not match PR head",
356
+ };
357
+ }
358
+ const result = {
359
+ binding,
360
+ pr_opened_emitted: false,
361
+ ci_status: null,
362
+ ci_emitted: false,
363
+ gate_met: false,
364
+ gate_emitted: false,
365
+ reason: "observed",
366
+ };
367
+ const prDecision = emitIfNew(buildPrOpenedEventInput(binding), {
368
+ event_type: "git.pr_opened",
369
+ repo: binding.repo,
370
+ pr_number: binding.pr_number,
371
+ head_sha: binding.head_sha,
372
+ });
373
+ result.pr_opened_emitted = prDecision.emitted;
374
+ const snapshot = normalizeCiSnapshot(pollResponse);
375
+ const ciEvent = buildCiObservationEventInput(binding, snapshot);
376
+ if (ciEvent === null) {
377
+ result.ci_status = "pending";
378
+ return result;
379
+ }
380
+ result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
381
+ const ciDecision = emitIfNew(ciEvent, {
382
+ event_type: ciEvent.type,
383
+ repo: binding.repo,
384
+ pr_number: binding.pr_number,
385
+ head_sha: binding.head_sha,
386
+ ci_check_hash: snapshot.check_state_hash,
387
+ });
388
+ result.ci_emitted = ciDecision.emitted;
389
+ // Best-effort gate evaluation when access + config are available.
390
+ const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: deps.cwd }));
391
+ const fetchGateConfig = deps.fetchGateConfig ?? fetchDoneGateConfigField;
392
+ try {
393
+ const accessResult = await resolveAccess();
394
+ if (accessResult.ok) {
395
+ let rawConfig;
396
+ try {
397
+ rawConfig = await fetchGateConfig(accessResult.access);
398
+ }
399
+ catch {
400
+ rawConfig = undefined;
401
+ }
402
+ const gateConfig = parseDoneGateConfig(rawConfig);
403
+ if (gateConfig.enabled && gateConfig.valid) {
404
+ const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now());
405
+ if (evaluation.met) {
406
+ result.gate_met = true;
407
+ const gateEvent = buildGateMetEventInput(binding, evaluation);
408
+ if (gateEvent !== null) {
409
+ const gateDecision = emitIfNew(gateEvent, {
410
+ event_type: "gate.met",
411
+ repo: binding.repo,
412
+ pr_number: binding.pr_number,
413
+ head_sha: binding.head_sha,
414
+ config_hash: gateConfig.config_hash ?? undefined,
415
+ });
416
+ result.gate_emitted = gateDecision.emitted;
417
+ result.gate_event_summary = gateEvent.data?.summary;
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }
423
+ catch {
424
+ /* best-effort gate; telemetry already emitted */
425
+ }
426
+ return result;
427
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * PR number + immutable head SHA binding discovery (BAPI-395).
3
+ *
4
+ * `poll_ci_checks` is SHA-keyed only, so the producer resolves the PR number and
5
+ * head SHA INDEPENDENTLY here. v1 does one opportunistic `gh pr view` lookup (no
6
+ * continuous GitHub polling) and binds only when the discovered PR is open and its
7
+ * head matches the local HEAD. Every failure mode — gh unavailable, no open PR,
8
+ * head mismatch, invalid identifiers — returns a structured no-binding result so
9
+ * the producer never evaluates a mutable branch or a stale SHA. Provider-native gh
10
+ * fields never leak into the binding beyond safe references.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { normalizePrNumber, normalizeRepoName, normalizeSha } from "./git-ci-types.js";
14
+ import { getGitWorktreeContext } from "./git-inspection.js";
15
+ /** Bounded timeout for the one-shot `gh` lookup. */
16
+ export const GH_COMMAND_TIMEOUT_MS = 5_000;
17
+ /** Run `gh <args>` with list args, a bounded timeout, and NO shell. */
18
+ export function runGhCommand(args, options = {}) {
19
+ try {
20
+ const stdout = execFileSync("gh", args, {
21
+ cwd: options.cwd,
22
+ timeout: GH_COMMAND_TIMEOUT_MS,
23
+ encoding: "utf-8",
24
+ maxBuffer: 4 * 1024 * 1024,
25
+ stdio: ["ignore", "pipe", "ignore"],
26
+ });
27
+ return { ok: true, stdout: typeof stdout === "string" ? stdout : "" };
28
+ }
29
+ catch {
30
+ return { ok: false, stdout: "" };
31
+ }
32
+ }
33
+ const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number,headRefOid,headRefName,url,state"];
34
+ /**
35
+ * Perform a one-shot `gh pr view` lookup for the current branch's PR. Returns a
36
+ * normalized {@link DiscoveredPr} or `null` when gh is unavailable, there is no
37
+ * PR, or the output cannot be parsed. Never throws; never leaks raw gh output.
38
+ */
39
+ export function discoverPrWithGhCli(options = {}, deps = {}) {
40
+ const runGh = deps.runGh ?? runGhCommand;
41
+ const result = runGh(GH_PR_VIEW_ARGS, { cwd: options.cwd });
42
+ if (!result.ok)
43
+ return null;
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(result.stdout);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
52
+ return null;
53
+ const record = parsed;
54
+ const number = typeof record.number === "number" ? record.number : null;
55
+ const state = typeof record.state === "string" ? record.state : "";
56
+ if (number === null || state.length === 0)
57
+ return null;
58
+ const discovered = {
59
+ number,
60
+ head_sha: normalizeSha(record.headRefOid),
61
+ state,
62
+ };
63
+ if (typeof record.headRefName === "string" && record.headRefName.trim().length > 0) {
64
+ discovered.head_ref = record.headRefName.trim();
65
+ }
66
+ if (typeof record.url === "string" && record.url.trim().length > 0) {
67
+ discovered.url = record.url.trim();
68
+ }
69
+ return discovered;
70
+ }
71
+ function makeBinding(repo, prNumber, headSha, extra = {}) {
72
+ const binding = {
73
+ repo,
74
+ pr_number: prNumber,
75
+ head_sha: headSha,
76
+ subject: `${repo}#${prNumber}`,
77
+ };
78
+ if (extra.url !== undefined)
79
+ binding.url = extra.url;
80
+ if (extra.head_ref !== undefined)
81
+ binding.head_ref = extra.head_ref;
82
+ return binding;
83
+ }
84
+ /**
85
+ * Resolve an immutable PR/head binding. When both `prNumber` and `headSha` are
86
+ * supplied they are validated and used directly (no git/gh discovery). Otherwise
87
+ * the local git context + HEAD SHA are read and a single `gh pr view` lookup must
88
+ * return an OPEN PR whose head matches local HEAD. Any inconsistency yields a
89
+ * structured no-binding result — never a branch-only fallback.
90
+ */
91
+ export function resolvePrHeadBinding(input = {}, deps = {}) {
92
+ const explicitRepo = input.repoName !== undefined ? normalizeRepoName(input.repoName) : null;
93
+ // Explicit binding path: validate and return without any discovery.
94
+ if (input.prNumber !== undefined || input.headSha !== undefined) {
95
+ const prNumber = normalizePrNumber(input.prNumber);
96
+ const headSha = normalizeSha(input.headSha);
97
+ if (prNumber === null || headSha === null) {
98
+ return { ok: false, reason: "invalid explicit pr_number or head_sha" };
99
+ }
100
+ if (input.repoName !== undefined && explicitRepo === null) {
101
+ return { ok: false, reason: "invalid explicit repo_name" };
102
+ }
103
+ const repo = explicitRepo ?? normalizeRepoName(deps.getContext?.({ cwd: input.cwd, env: input.env })?.repo);
104
+ if (repo === null) {
105
+ return { ok: false, reason: "could not resolve repo name" };
106
+ }
107
+ return { ok: true, binding: makeBinding(repo, prNumber, headSha) };
108
+ }
109
+ // Discovery path: local context + one-shot gh lookup.
110
+ const getContext = deps.getContext ?? getGitWorktreeContext;
111
+ const context = getContext({ cwd: input.cwd, env: input.env });
112
+ const repo = explicitRepo ?? normalizeRepoName(context.repo);
113
+ const localSha = normalizeSha(context.head_sha ?? "");
114
+ if (repo === null || localSha === null) {
115
+ return { ok: false, reason: "no local repo/HEAD to bind" };
116
+ }
117
+ const pr = discoverPrWithGhCli({ cwd: input.cwd }, deps);
118
+ if (pr === null) {
119
+ return { ok: false, reason: "gh unavailable or no PR for current branch" };
120
+ }
121
+ if (pr.state.toUpperCase() !== "OPEN") {
122
+ return { ok: false, reason: `PR is not open (state: ${pr.state})` };
123
+ }
124
+ const prNumber = normalizePrNumber(pr.number);
125
+ if (prNumber === null) {
126
+ return { ok: false, reason: "discovered PR number is invalid" };
127
+ }
128
+ if (pr.head_sha !== null && pr.head_sha !== localSha) {
129
+ return { ok: false, reason: "PR head SHA does not match local HEAD" };
130
+ }
131
+ return {
132
+ ok: true,
133
+ binding: makeBinding(repo, prNumber, localSha, { url: pr.url, head_ref: pr.head_ref }),
134
+ };
135
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Producer-level deduplication over the generic append-only conductor ledger
3
+ * (BAPI-395).
4
+ *
5
+ * The store stays tool-agnostic and append-only; this layer adds deterministic
6
+ * dedupe keys / event ids so noisy producers (git hooks, PR/CI observers) never
7
+ * append the same logical event twice. A stable `dedupe_key` is embedded under
8
+ * `data.details.dedupe_key` and a deterministic event id is derived from it, so a
9
+ * pre-check ledger scan AND the `events.id` UNIQUE constraint both guard against
10
+ * duplicates. Raw provider payloads never enter the dedupe key — only stable
11
+ * normalized hashes do.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { emitConductorEvent, pollConductorEvents } from "./store.js";
15
+ import { stableJsonHash } from "./git-ci-types.js";
16
+ /**
17
+ * Build a stable dedupe key from normalized event dimensions. Undefined
18
+ * dimensions are dropped, key order is irrelevant (canonical hashing), and no raw
19
+ * payload material is included — two inputs with identical normalized hashes but
20
+ * different `raw` objects yield the same key.
21
+ */
22
+ export function makeProducerDedupeKey(dimensions) {
23
+ const canonical = {};
24
+ for (const [key, value] of Object.entries(dimensions)) {
25
+ if (value !== undefined && value !== null)
26
+ canonical[key] = value;
27
+ }
28
+ return stableJsonHash(canonical);
29
+ }
30
+ /**
31
+ * Derive a deterministic, UUID-shaped event id from a dedupe key. Repeated calls
32
+ * with the same key always return the same id, so a retrying producer collides on
33
+ * the `events.id` UNIQUE constraint instead of appending a duplicate.
34
+ */
35
+ export function makeStableProducerEventId(dedupeKey) {
36
+ const h = createHash("sha256").update(`conductor-producer:${dedupeKey}`).digest("hex");
37
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
38
+ }
39
+ /** Heuristically detect a SQLite duplicate-id / UNIQUE constraint failure. */
40
+ function isDuplicateConstraintError(error) {
41
+ if (!error || typeof error !== "object")
42
+ return false;
43
+ const code = error.code;
44
+ if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT"))
45
+ return true;
46
+ const message = error.message;
47
+ if (typeof message === "string") {
48
+ const lowered = message.toLowerCase();
49
+ if (lowered.includes("unique constraint") || lowered.includes("constraint failed"))
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ const LEDGER_SCAN_PAGE_LIMIT = 500;
55
+ const LEDGER_SCAN_MAX_PAGES = 200;
56
+ /**
57
+ * Return `true` when an event carrying `dedupe_key` under `data.details` already
58
+ * exists in the ledger. Pages through the ledger with `data_mode: "full"` so the
59
+ * full details object is visible. Malformed/non-matching events are skipped; the
60
+ * scan never throws.
61
+ */
62
+ export function eventAlreadyExists(dedupeKey, deps = {}) {
63
+ const pollEvents = deps.pollEvents ?? ((options) => pollConductorEvents(options));
64
+ let sinceSeq = 1;
65
+ for (let page = 0; page < LEDGER_SCAN_MAX_PAGES; page += 1) {
66
+ let result;
67
+ try {
68
+ result = pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ for (const event of result.events) {
74
+ if (!event || typeof event !== "object")
75
+ continue;
76
+ const data = event.data;
77
+ if (data && typeof data === "object") {
78
+ const details = data.details;
79
+ if (details &&
80
+ typeof details === "object" &&
81
+ details.dedupe_key === dedupeKey) {
82
+ return true;
83
+ }
84
+ }
85
+ }
86
+ if (result.count === 0 || result.next_seq <= sinceSeq)
87
+ break;
88
+ sinceSeq = result.next_seq;
89
+ }
90
+ return false;
91
+ }
92
+ /**
93
+ * Emit a producer event only if no event with the same dedupe key already exists.
94
+ * Pre-checks the ledger, assigns a deterministic event id, embeds `dedupe_key`
95
+ * under `data.details`, then emits. A duplicate-id constraint thrown by a racing
96
+ * producer is classified as a duplicate rather than surfaced as a failure.
97
+ */
98
+ export function emitConductorEventIfNew(input, dimensions, deps = {}) {
99
+ const emitEvent = deps.emitEvent ?? emitConductorEvent;
100
+ const dedupeKey = makeProducerDedupeKey(dimensions);
101
+ if (eventAlreadyExists(dedupeKey, deps)) {
102
+ return { emitted: false, reason: "duplicate" };
103
+ }
104
+ const eventId = makeStableProducerEventId(dedupeKey);
105
+ // Embed the dedupe key under data.details so a later scan can find it, without
106
+ // disturbing any other normalized details the caller supplied.
107
+ const existingData = input.data ?? {};
108
+ const existingDetails = existingData.details && typeof existingData.details === "object" && !Array.isArray(existingData.details)
109
+ ? existingData.details
110
+ : {};
111
+ const data = {
112
+ ...existingData,
113
+ details: { ...existingDetails, dedupe_key: dedupeKey },
114
+ };
115
+ try {
116
+ emitEvent({ ...input, id: eventId, data });
117
+ return { emitted: true, event_id: eventId };
118
+ }
119
+ catch (error) {
120
+ if (isDuplicateConstraintError(error)) {
121
+ return { emitted: false, reason: "duplicate" };
122
+ }
123
+ throw error;
124
+ }
125
+ }