@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
@@ -86,6 +86,25 @@ export async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
86
86
  };
87
87
  }
88
88
  }
89
+ /**
90
+ * Inspect the BRIDGE_MCP_PROFILE environment variable and warn when a
91
+ * conductor/epic context is detected but the profile is not "conductor".
92
+ * Strictly read-only, never throws.
93
+ */
94
+ export function inspectMcpProfile(env, epicTick) {
95
+ const resolved_profile = env.BRIDGE_MCP_PROFILE || "core";
96
+ const conductor_context_detected = env.BAPI_CONDUCTOR_ENABLED === "1" ||
97
+ env.BAPI_CONDUCTOR_ENABLED === "true" ||
98
+ epicTick.registered;
99
+ const degraded = conductor_context_detected && resolved_profile !== "conductor";
100
+ const warnings = [];
101
+ if (degraded) {
102
+ warnings.push(`BRIDGE_MCP_PROFILE is "${resolved_profile}" but a conductor context is active. ` +
103
+ `Expected "conductor" — conductor/event/supervisor tools may be missing. ` +
104
+ `Ensure the spawner injects BRIDGE_MCP_PROFILE=conductor into the worker shell environment.`);
105
+ }
106
+ return { resolved_profile, conductor_context_detected, degraded, warnings };
107
+ }
89
108
  /**
90
109
  * Build the combined read-only doctor report. Composes the existing ledger
91
110
  * doctor, git hook inspection, and the epic-tick schedule enablement check.
@@ -95,10 +114,12 @@ export async function buildConductorDoctorReport(deps = {}) {
95
114
  const doctorLedger = deps.doctorLedger ?? doctorConductorLedger;
96
115
  const inspectHooks = deps.inspectHooks ?? inspectConductorGitHooks;
97
116
  const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
117
+ const mcp_profile = inspectMcpProfile(deps.env ?? process.env, epicTick);
98
118
  return {
99
- ledger: doctorLedger(),
119
+ ledger: await doctorLedger(),
100
120
  git_hooks: inspectHooks(deps.hooksDeps),
101
121
  epic_tick: epicTick,
122
+ mcp_profile,
102
123
  };
103
124
  }
104
125
  /**
@@ -107,7 +128,7 @@ export async function buildConductorDoctorReport(deps = {}) {
107
128
  * status tags consistent with the git hooks section's visual hierarchy.
108
129
  */
109
130
  export function formatConductorDoctorReport(report) {
110
- const { ledger, git_hooks, epic_tick } = report;
131
+ const { ledger, git_hooks, epic_tick, mcp_profile } = report;
111
132
  const lines = [
112
133
  "Conductor ledger doctor",
113
134
  "───────────────────────",
@@ -160,5 +181,17 @@ export function formatConductorDoctorReport(report) {
160
181
  for (const w of epic_tick.warnings)
161
182
  lines.push(` - ${w}`);
162
183
  }
184
+ lines.push("");
185
+ lines.push("MCP Profile (optional, local)");
186
+ lines.push("─────────────────────────────");
187
+ const profileTag = mcp_profile.degraded ? "[WARNING] degraded" : "[OK]";
188
+ lines.push(`resolved profile: ${mcp_profile.resolved_profile} ${profileTag}`);
189
+ lines.push(`conductor context: ${mcp_profile.conductor_context_detected}`);
190
+ lines.push(`degraded: ${mcp_profile.degraded}`);
191
+ if (mcp_profile.warnings.length > 0) {
192
+ lines.push("mcp profile warnings:");
193
+ for (const w of mcp_profile.warnings)
194
+ lines.push(` - ${w}`);
195
+ }
163
196
  return lines.join("\n");
164
197
  }
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Pure done-gate config parsing, CI snapshot normalization, and deterministic
3
- * gate evaluation (BAPI-395).
3
+ * gate evaluation (BAPI-395, BAPI-440).
4
4
  *
5
- * v1 supports exactly one gate condition `required_ci_checks_green` and fails
6
- * CLOSED everywhere: unset, disabled, malformed, and unsupported config all yield
7
- * an inactive result that can never emit `gate.met`. A gate is met only when every
8
- * configured required check is present, complete, and green for the exact
5
+ * Supports a heterogeneous ANDed conditions list: one or more CI conditions
6
+ * (`required_ci_checks_green`) plus zero or more review-state conditions
7
+ * (`review_state`). Fails CLOSED everywhere: unset, disabled, malformed,
8
+ * unknown/duplicate condition types all yield an inactive result that can never
9
+ * emit `gate.met`. A gate is met only when ALL conditions are met for the exact
9
10
  * `repo + pr_number + head_sha` binding. This module is pure (no I/O) and never
10
11
  * throws for caller input.
11
12
  */
12
- import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
13
+ import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
13
14
  // ---------------------------------------------------------------------------
14
15
  // Config parsing
15
16
  // ---------------------------------------------------------------------------
@@ -22,7 +23,7 @@ function inactiveConfig(reason) {
22
23
  enabled: false,
23
24
  valid: false,
24
25
  reason,
25
- condition: null,
26
+ conditions: [],
26
27
  config_hash: null,
27
28
  gate_name: DEFAULT_GATE_NAME,
28
29
  };
@@ -62,21 +63,11 @@ function coerceConfigObject(value) {
62
63
  return { kind: "invalid" };
63
64
  }
64
65
  /**
65
- * Extract and validate the single v1 condition from a config object. Requires
66
- * `conditions` to be an array containing exactly one `required_ci_checks_green`
67
- * condition whose `required_checks` is a non-empty array of unique, valid,
68
- * normalized check names. Returns the normalized condition or `null`.
66
+ * Parse and validate a single `required_ci_checks_green` condition entry.
67
+ * Returns the normalized condition or `null` on any validation failure.
69
68
  */
70
- function parseSingleCondition(object) {
71
- const conditions = object.conditions;
72
- if (!Array.isArray(conditions) || conditions.length !== 1)
73
- return null;
74
- const condition = conditions[0];
75
- if (!isPlainObject(condition))
76
- return null;
77
- if (condition.type !== REQUIRED_CI_CHECKS_GREEN)
78
- return null;
79
- const rawChecks = condition.required_checks;
69
+ function parseCiChecksCondition(entry) {
70
+ const rawChecks = entry.required_checks;
80
71
  if (!Array.isArray(rawChecks) || rawChecks.length === 0)
81
72
  return null;
82
73
  const normalized = [];
@@ -84,19 +75,103 @@ function parseSingleCondition(object) {
84
75
  for (const raw of rawChecks) {
85
76
  const name = normalizeCheckName(raw);
86
77
  if (name === null)
87
- return null; // invalid name invalidates the whole config
78
+ return null;
88
79
  if (seen.has(name))
89
- return null; // duplicate after normalization
80
+ return null;
90
81
  seen.add(name);
91
82
  normalized.push(name);
92
83
  }
93
84
  return { type: REQUIRED_CI_CHECKS_GREEN, required_checks: normalized };
94
85
  }
86
+ /** Valid review source values. */
87
+ const VALID_REVIEW_SOURCES = new Set(["sticky_verdict", "native_review_decision", "min_approvals", "combination"]);
88
+ /**
89
+ * Parse and validate a single `review_state` condition entry.
90
+ * Returns the normalized condition or `null` on any validation failure.
91
+ */
92
+ function parseReviewStateCondition(entry) {
93
+ const source = entry.source;
94
+ if (typeof source !== "string" || !VALID_REVIEW_SOURCES.has(source))
95
+ return null;
96
+ const condition = { type: REVIEW_STATE, source: source };
97
+ if (entry.require_sticky_verdict !== undefined) {
98
+ if (typeof entry.require_sticky_verdict !== "boolean")
99
+ return null;
100
+ condition.require_sticky_verdict = entry.require_sticky_verdict;
101
+ }
102
+ if (entry.require_native_decision !== undefined) {
103
+ if (typeof entry.require_native_decision !== "boolean")
104
+ return null;
105
+ condition.require_native_decision = entry.require_native_decision;
106
+ }
107
+ if (entry.min_approvals !== undefined) {
108
+ if (typeof entry.min_approvals !== "number" || !Number.isInteger(entry.min_approvals) || entry.min_approvals < 0)
109
+ return null;
110
+ condition.min_approvals = entry.min_approvals;
111
+ }
112
+ if (entry.logic !== undefined) {
113
+ if (entry.logic !== "and")
114
+ return null;
115
+ condition.logic = "and";
116
+ }
117
+ // For combination source, at least one sub-source must be active; otherwise
118
+ // the condition would trivially pass with an empty failures array (fail-open).
119
+ if (condition.source === "combination") {
120
+ const hasSticky = condition.require_sticky_verdict === true;
121
+ const hasNative = condition.require_native_decision === true;
122
+ const hasMin = typeof condition.min_approvals === "number" && condition.min_approvals > 0;
123
+ if (!hasSticky && !hasNative && !hasMin)
124
+ return null;
125
+ }
126
+ return condition;
127
+ }
128
+ /**
129
+ * Parse the `conditions` array from a config object into a validated
130
+ * {@link GateCondition}[] list. Returns `null` if the array is missing, empty,
131
+ * contains any invalid/unknown/duplicate condition type, or any individual
132
+ * condition fails validation. Fail-closed.
133
+ */
134
+ function parseConditions(object) {
135
+ const raw = object.conditions;
136
+ if (!Array.isArray(raw) || raw.length === 0)
137
+ return null;
138
+ const seenTypes = new Set();
139
+ const parsed = [];
140
+ for (const entry of raw) {
141
+ if (!isPlainObject(entry))
142
+ return null;
143
+ const type = entry.type;
144
+ if (typeof type !== "string")
145
+ return null;
146
+ if (seenTypes.has(type))
147
+ return null; // duplicate type
148
+ if (type === REQUIRED_CI_CHECKS_GREEN) {
149
+ const condition = parseCiChecksCondition(entry);
150
+ if (condition === null)
151
+ return null;
152
+ seenTypes.add(type);
153
+ parsed.push(condition);
154
+ }
155
+ else if (type === REVIEW_STATE) {
156
+ const condition = parseReviewStateCondition(entry);
157
+ if (condition === null)
158
+ return null;
159
+ seenTypes.add(type);
160
+ parsed.push(condition);
161
+ }
162
+ else {
163
+ return null; // unknown condition type → fail closed
164
+ }
165
+ }
166
+ return parsed;
167
+ }
95
168
  /**
96
169
  * Parse a raw `conductor_done_gate` value into a fail-closed
97
170
  * {@link NormalizedDoneGateConfig}. Active (`enabled && valid`) only when the
98
- * value is an enabled object with `enabled === true` (strict boolean) and exactly
99
- * one valid v1 condition. Every other shape is inactive with a safe reason.
171
+ * value is an enabled object with `enabled === true` (strict boolean) and at
172
+ * least one valid condition (CI and/or review). Every other shape is inactive
173
+ * with a safe reason. Fail-closed on any unknown, duplicate, or malformed
174
+ * condition type.
100
175
  */
101
176
  export function parseDoneGateConfig(value) {
102
177
  const coerced = coerceConfigObject(value);
@@ -111,21 +186,35 @@ export function parseDoneGateConfig(value) {
111
186
  return inactiveConfig("disabled");
112
187
  return inactiveConfig("invalid: 'enabled' must be the boolean true");
113
188
  }
114
- const condition = parseSingleCondition(object);
115
- if (condition === null) {
116
- return inactiveConfig("invalid: expected exactly one valid required_ci_checks_green condition");
189
+ const conditions = parseConditions(object);
190
+ if (conditions === null) {
191
+ return inactiveConfig("invalid: conditions must be a non-empty array of valid, non-duplicate condition objects");
117
192
  }
118
193
  const gateName = DEFAULT_GATE_NAME;
119
194
  const configHash = stableJsonHash({
120
195
  gate_name: gateName,
121
- type: condition.type,
122
- required_checks: condition.required_checks,
196
+ conditions: conditions.map((c) => {
197
+ if (c.type === REQUIRED_CI_CHECKS_GREEN) {
198
+ return { type: c.type, required_checks: c.required_checks };
199
+ }
200
+ // review_state: include all non-undefined fields deterministically
201
+ const r = { type: c.type, source: c.source };
202
+ if (c.require_sticky_verdict !== undefined)
203
+ r.require_sticky_verdict = c.require_sticky_verdict;
204
+ if (c.require_native_decision !== undefined)
205
+ r.require_native_decision = c.require_native_decision;
206
+ if (c.min_approvals !== undefined)
207
+ r.min_approvals = c.min_approvals;
208
+ if (c.logic !== undefined)
209
+ r.logic = c.logic;
210
+ return r;
211
+ }),
123
212
  });
124
213
  return {
125
214
  enabled: true,
126
215
  valid: true,
127
216
  reason: "active",
128
- condition,
217
+ conditions,
129
218
  config_hash: configHash,
130
219
  gate_name: gateName,
131
220
  };
@@ -214,8 +303,16 @@ function normalizeOneCheck(name, raw) {
214
303
  export function normalizeCiSnapshot(response) {
215
304
  const checks = [];
216
305
  const byName = new Map();
217
- if (isPlainObject(response)) {
218
- const rawChecks = response.checks;
306
+ // The raw `pollCiChecksForCommit` response is an envelope
307
+ // (`{ available, reason, action, detail: { checks, unknown_checks } }`), while
308
+ // unit tests and some callers pass an already-unwrapped object
309
+ // (`{ checks, unknown_checks }`). Prefer the unwrapped top-level fields and only
310
+ // fall back to `detail.*` when the top-level field is absent, so both shapes
311
+ // normalize identically and existing unwrapped-shape behavior is preserved.
312
+ const source = isPlainObject(response) ? response : undefined;
313
+ const detail = source && isPlainObject(source.detail) ? source.detail : undefined;
314
+ if (source) {
315
+ const rawChecks = source.checks ?? detail?.checks;
219
316
  if (Array.isArray(rawChecks)) {
220
317
  for (const entry of rawChecks) {
221
318
  if (!isPlainObject(entry))
@@ -238,8 +335,9 @@ export function normalizeCiSnapshot(response) {
238
335
  }
239
336
  }
240
337
  const unknownChecks = [];
241
- if (isPlainObject(response) && Array.isArray(response.unknown_checks)) {
242
- for (const raw of response.unknown_checks) {
338
+ const rawUnknown = source ? source.unknown_checks ?? detail?.unknown_checks : undefined;
339
+ if (Array.isArray(rawUnknown)) {
340
+ for (const raw of rawUnknown) {
243
341
  const name = normalizeCheckName(raw);
244
342
  if (name !== null && !unknownChecks.includes(name))
245
343
  unknownChecks.push(name);
@@ -264,46 +362,190 @@ export function normalizeCiSnapshot(response) {
264
362
  };
265
363
  }
266
364
  // ---------------------------------------------------------------------------
365
+ // Review snapshot normalization
366
+ // ---------------------------------------------------------------------------
367
+ const REVIEW_VERDICT_APPROVED = "approved";
368
+ const REVIEW_VERDICT_CHANGES_REQUESTED = "changes_requested";
369
+ const REVIEW_VERDICT_UNKNOWN = "unknown";
370
+ /**
371
+ * Normalize a raw backend review-status response into a {@link NormalizedReviewSnapshot}.
372
+ * Returns `null` when the envelope indicates unavailability (`available: false`) or
373
+ * is missing, so callers can fail-closed without emitting false-negative events.
374
+ */
375
+ export function normalizeReviewSnapshot(raw) {
376
+ if (!isPlainObject(raw))
377
+ return null;
378
+ if (raw.available === false)
379
+ return null;
380
+ const detail = isPlainObject(raw.detail) ? raw.detail : null;
381
+ if (detail === null)
382
+ return null;
383
+ const reviewDecision = typeof detail.review_decision === "string" && detail.review_decision.length > 0
384
+ ? detail.review_decision
385
+ : null;
386
+ const approvals = typeof detail.approvals === "number" && Number.isInteger(detail.approvals) && detail.approvals >= 0
387
+ ? detail.approvals
388
+ : 0;
389
+ const rawVerdict = detail.sticky_verdict;
390
+ let stickyVerdict;
391
+ if (rawVerdict === REVIEW_VERDICT_APPROVED) {
392
+ stickyVerdict = "approved";
393
+ }
394
+ else if (rawVerdict === REVIEW_VERDICT_CHANGES_REQUESTED) {
395
+ stickyVerdict = "changes_requested";
396
+ }
397
+ else if (rawVerdict === REVIEW_VERDICT_UNKNOWN) {
398
+ stickyVerdict = "unknown";
399
+ }
400
+ else {
401
+ stickyVerdict = null;
402
+ }
403
+ const headSha = typeof detail.head_sha === "string" && detail.head_sha.trim().length > 0
404
+ ? detail.head_sha.trim()
405
+ : null;
406
+ const reviewStateHash = stableJsonHash({
407
+ review_decision: reviewDecision,
408
+ approvals,
409
+ sticky_verdict: stickyVerdict,
410
+ });
411
+ return { review_decision: reviewDecision, approvals, sticky_verdict: stickyVerdict, head_sha: headSha, review_state_hash: reviewStateHash };
412
+ }
413
+ /**
414
+ * Deterministically evaluate a review-state condition against a normalized snapshot.
415
+ * Pure and fail-closed: a null snapshot or any unknown/missing source yields
416
+ * `passed: false` and `changesRequested: false` (never a false positive).
417
+ */
418
+ export function evaluateReviewCondition(condition, snapshot) {
419
+ if (snapshot === null) {
420
+ return { passed: false, changesRequested: false, reason: "review snapshot unavailable" };
421
+ }
422
+ const source = condition.source;
423
+ if (source === "sticky_verdict") {
424
+ if (snapshot.sticky_verdict === "approved")
425
+ return { passed: true, changesRequested: false, reason: "sticky verdict approved" };
426
+ if (snapshot.sticky_verdict === "changes_requested")
427
+ return { passed: false, changesRequested: true, reason: "sticky verdict requests changes" };
428
+ return { passed: false, changesRequested: false, reason: `sticky verdict not approved: ${snapshot.sticky_verdict ?? "null"}` };
429
+ }
430
+ if (source === "native_review_decision") {
431
+ const dec = snapshot.review_decision?.toUpperCase();
432
+ if (dec === "APPROVED")
433
+ return { passed: true, changesRequested: false, reason: "native review decision approved" };
434
+ if (dec === "CHANGES_REQUESTED")
435
+ return { passed: false, changesRequested: true, reason: "native review decision requests changes" };
436
+ return { passed: false, changesRequested: false, reason: `native review decision not approved: ${snapshot.review_decision ?? "null"}` };
437
+ }
438
+ if (source === "min_approvals") {
439
+ const required = typeof condition.min_approvals === "number" ? condition.min_approvals : 1;
440
+ if (snapshot.approvals >= required)
441
+ return { passed: true, changesRequested: false, reason: `approvals ${snapshot.approvals} >= ${required}` };
442
+ return { passed: false, changesRequested: false, reason: `approvals ${snapshot.approvals} < ${required}` };
443
+ }
444
+ if (source === "combination") {
445
+ // All enabled sub-sources must pass (AND logic).
446
+ const requireSticky = condition.require_sticky_verdict === true;
447
+ const requireNative = condition.require_native_decision === true;
448
+ const minApprovals = typeof condition.min_approvals === "number" ? condition.min_approvals : 0;
449
+ const failures = [];
450
+ let changesRequested = false;
451
+ if (requireSticky) {
452
+ if (snapshot.sticky_verdict === "changes_requested")
453
+ changesRequested = true;
454
+ if (snapshot.sticky_verdict !== "approved")
455
+ failures.push(`sticky verdict not approved: ${snapshot.sticky_verdict ?? "null"}`);
456
+ }
457
+ if (requireNative) {
458
+ const dec = snapshot.review_decision?.toUpperCase();
459
+ if (dec === "CHANGES_REQUESTED")
460
+ changesRequested = true;
461
+ if (dec !== "APPROVED")
462
+ failures.push(`native decision not approved: ${snapshot.review_decision ?? "null"}`);
463
+ }
464
+ if (minApprovals > 0 && snapshot.approvals < minApprovals) {
465
+ failures.push(`approvals ${snapshot.approvals} < ${minApprovals}`);
466
+ }
467
+ if (failures.length > 0)
468
+ return { passed: false, changesRequested, reason: failures.join("; ") };
469
+ return { passed: true, changesRequested: false, reason: "all combination sources satisfied" };
470
+ }
471
+ return { passed: false, changesRequested: false, reason: `unknown review source: ${source}` };
472
+ }
473
+ // ---------------------------------------------------------------------------
267
474
  // Gate evaluation
268
475
  // ---------------------------------------------------------------------------
269
476
  function failedEvaluation(reason) {
270
477
  return { met: false, reason };
271
478
  }
272
479
  /**
273
- * Deterministically evaluate a done gate for one binding. Returns `met: true` with
274
- * canonical `gate.met` event data (allowlisted top-level keys only) when every
275
- * configured required check is present, complete, and green; otherwise `met:
276
- * false` with a fail-closed reason and no event data. Pure and deterministic:
277
- * identical inputs always produce deep-equal output.
480
+ * Deterministically evaluate a done gate for one binding against both a CI
481
+ * snapshot and an optional review snapshot. Returns `met: true` with canonical
482
+ * `gate.met` event data (allowlisted top-level keys only) only when ALL configured
483
+ * conditions are met; otherwise `met: false` with a fail-closed reason. Pure and
484
+ * deterministic: identical inputs always produce deep-equal output. Never throws.
278
485
  */
279
- export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso) {
280
- if (!config.enabled || !config.valid || config.condition === null) {
486
+ export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso, reviewSnapshot = null) {
487
+ if (!config.enabled || !config.valid || config.conditions.length === 0) {
281
488
  return failedEvaluation(`gate inactive: ${config.reason}`);
282
489
  }
283
490
  const headSha = normalizeSha(binding.head_sha);
284
491
  if (headSha === null) {
285
492
  return failedEvaluation("invalid binding: head_sha is not a valid SHA");
286
493
  }
494
+ // Per-condition evaluation accumulators
495
+ const allFailureReasons = [];
496
+ // CI check status (for the structured details payload)
497
+ let checkResults = [];
498
+ let ciConditionType;
499
+ let requiredChecks;
500
+ // Review condition result (for the structured details payload)
501
+ let reviewResult;
287
502
  const byName = new Map();
288
503
  for (const check of snapshot.checks)
289
504
  byName.set(check.name, check);
290
505
  const unknownSet = new Set(snapshot.unknown_checks);
291
- const checkResults = [];
292
- const unmet = [];
293
- for (const name of config.condition.required_checks) {
294
- const check = byName.get(name);
295
- if (!check) {
296
- checkResults.push({ name, present: false, complete: false, green: false });
297
- unmet.push(unknownSet.has(name) ? `${name} (unknown)` : `${name} (missing)`);
298
- continue;
506
+ for (const condition of config.conditions) {
507
+ if (condition.type === REQUIRED_CI_CHECKS_GREEN) {
508
+ ciConditionType = condition.type;
509
+ requiredChecks = [...condition.required_checks];
510
+ checkResults = [];
511
+ const unmet = [];
512
+ for (const name of condition.required_checks) {
513
+ const check = byName.get(name);
514
+ if (!check) {
515
+ checkResults.push({ name, present: false, complete: false, green: false });
516
+ unmet.push(unknownSet.has(name) ? `${name} (unknown)` : `${name} (missing)`);
517
+ continue;
518
+ }
519
+ checkResults.push({ name, present: true, complete: check.complete, green: check.green });
520
+ if (!check.green) {
521
+ unmet.push(check.complete ? `${name} (not green)` : `${name} (pending)`);
522
+ }
523
+ }
524
+ if (unmet.length > 0) {
525
+ allFailureReasons.push(`required checks not green: ${unmet.join(", ")}`);
526
+ }
299
527
  }
300
- checkResults.push({ name, present: true, complete: check.complete, green: check.green });
301
- if (!check.green) {
302
- unmet.push(check.complete ? `${name} (not green)` : `${name} (pending)`);
528
+ else if (condition.type === REVIEW_STATE) {
529
+ reviewResult = evaluateReviewCondition(condition, reviewSnapshot);
530
+ if (!reviewResult.passed) {
531
+ allFailureReasons.push(`review condition not met: ${reviewResult.reason}`);
532
+ }
303
533
  }
304
534
  }
305
- if (unmet.length > 0) {
306
- return failedEvaluation(`required checks not green: ${unmet.join(", ")}`);
535
+ if (allFailureReasons.length > 0) {
536
+ return failedEvaluation(allFailureReasons.join("; "));
537
+ }
538
+ // Build structured per-condition details for telemetry
539
+ const ciCheckStatus = {};
540
+ if (ciConditionType !== undefined) {
541
+ ciCheckStatus.condition_type = ciConditionType;
542
+ ciCheckStatus.required_checks = requiredChecks;
543
+ ciCheckStatus.check_results = checkResults;
544
+ }
545
+ const reviewStatus = {};
546
+ if (reviewResult !== undefined) {
547
+ reviewStatus.passed = reviewResult.passed;
548
+ reviewStatus.reason = reviewResult.reason;
307
549
  }
308
550
  const details = {
309
551
  repo: binding.repo,
@@ -311,11 +553,12 @@ export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso) {
311
553
  head_sha: headSha,
312
554
  gate_name: config.gate_name,
313
555
  config_hash: config.config_hash,
314
- condition_type: config.condition.type,
315
- required_checks: [...config.condition.required_checks],
316
- check_results: checkResults,
317
556
  evaluated_at: evaluatedAtIso,
557
+ ci_check_status: ciCheckStatus,
318
558
  };
559
+ if (reviewResult !== undefined) {
560
+ details.review_status = reviewStatus;
561
+ }
319
562
  const gateEventData = {
320
563
  summary: `Done gate "${config.gate_name}" met for ${binding.subject}`,
321
564
  status: "met",