@bridge_gpt/mcp-server 0.2.2 → 0.2.4

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 +554 -66
  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 +17 -9
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Event `data` normalization and the tool-native-field boundary.
3
+ *
4
+ * Conductor events carry a small, semantically-normalized `data` object. Only an
5
+ * allowlisted set of top-level keys is accepted; any tool-native field (branch,
6
+ * commitSha, jiraTicket, …) must be nested under `data.raw`. Oversized inline
7
+ * payloads are rejected BEFORE any SQLite transaction so large diffs/transcripts
8
+ * are passed by reference instead. {@link prepareDataJsonForStorage} ties the
9
+ * pipeline together: normalize -> validate size -> redact -> compact JSON, with
10
+ * redaction guaranteed to run before the JSON can be inserted.
11
+ */
12
+ import { ConductorValidationError } from "./errors.js";
13
+ import { redactSecrets } from "./redaction.js";
14
+ /** Allowlisted top-level `data` keys. Everything else must go under `raw`. */
15
+ export const ALLOWED_DATA_KEYS = [
16
+ "summary",
17
+ "status",
18
+ "message",
19
+ "details",
20
+ "reason",
21
+ "metrics",
22
+ "labels",
23
+ "references",
24
+ "payload_ref",
25
+ "raw",
26
+ ];
27
+ const ALLOWED_DATA_KEY_SET = new Set(ALLOWED_DATA_KEYS);
28
+ /** Max characters for any single inline string anywhere in `data`. */
29
+ export const MAX_INLINE_STRING_CHARS = 16_384;
30
+ /** Max characters for the whole serialized `data` object. */
31
+ export const MAX_TOTAL_DATA_CHARS = 65_536;
32
+ function isPlainObject(value) {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+ /**
36
+ * Validate and return event `data` as a JSON object. Missing data defaults to
37
+ * `{}`. Non-object data and non-allowlisted top-level keys are rejected with an
38
+ * actionable {@link ConductorValidationError}. Error messages name offending
39
+ * keys only — never their (possibly secret) values.
40
+ */
41
+ export function normalizeEventData(data) {
42
+ if (data === undefined || data === null) {
43
+ return {};
44
+ }
45
+ if (!isPlainObject(data)) {
46
+ throw new ConductorValidationError("Event data must be a JSON object (received an array or primitive).");
47
+ }
48
+ const offending = Object.keys(data).filter((key) => !ALLOWED_DATA_KEY_SET.has(key));
49
+ if (offending.length > 0) {
50
+ throw new ConductorValidationError(`Event data has non-allowlisted top-level field(s): ${offending.join(", ")}. ` +
51
+ `Place tool-native fields under "data.raw" instead. Allowed top-level keys: ${ALLOWED_DATA_KEYS.join(", ")}.`);
52
+ }
53
+ return data;
54
+ }
55
+ /** Recursively find the longest inline string length within a value. */
56
+ function maxInlineStringLength(value) {
57
+ if (typeof value === "string") {
58
+ return value.length;
59
+ }
60
+ if (Array.isArray(value)) {
61
+ let max = 0;
62
+ for (const item of value) {
63
+ max = Math.max(max, maxInlineStringLength(item));
64
+ }
65
+ return max;
66
+ }
67
+ if (isPlainObject(value)) {
68
+ let max = 0;
69
+ for (const val of Object.values(value)) {
70
+ max = Math.max(max, maxInlineStringLength(val));
71
+ }
72
+ return max;
73
+ }
74
+ return 0;
75
+ }
76
+ /**
77
+ * Reject oversized inline payloads BEFORE serialization/storage. Callers must
78
+ * store large diffs/transcripts externally and pass a reference via
79
+ * `data.references` or `data.payload_ref`.
80
+ */
81
+ export function validateLargePayloadReferences(data) {
82
+ const longest = maxInlineStringLength(data);
83
+ if (longest > MAX_INLINE_STRING_CHARS) {
84
+ throw new ConductorValidationError(`Event data contains an inline string of ${longest} characters, exceeding the ` +
85
+ `${MAX_INLINE_STRING_CHARS}-character limit. Store large diffs/transcripts externally and ` +
86
+ `pass a reference via "data.references" or "data.payload_ref".`);
87
+ }
88
+ let serializedLength = 0;
89
+ try {
90
+ serializedLength = JSON.stringify(data)?.length ?? 0;
91
+ }
92
+ catch {
93
+ throw new ConductorValidationError("Event data is not JSON-serializable.");
94
+ }
95
+ if (serializedLength > MAX_TOTAL_DATA_CHARS) {
96
+ throw new ConductorValidationError(`Serialized event data is ${serializedLength} characters, exceeding the ` +
97
+ `${MAX_TOTAL_DATA_CHARS}-character limit. Pass large content by reference via ` +
98
+ `"data.references" or "data.payload_ref".`);
99
+ }
100
+ }
101
+ /**
102
+ * Full storage-preparation pipeline for the `data_json` column:
103
+ * 1. normalize (allowlist + object check),
104
+ * 2. validate inline payload size (outside any SQLite transaction),
105
+ * 3. redact secrets recursively,
106
+ * 4. serialize to compact JSON.
107
+ * Redaction is guaranteed to happen before the returned JSON can be inserted.
108
+ */
109
+ export function prepareDataJsonForStorage(data) {
110
+ const normalized = normalizeEventData(data);
111
+ validateLargePayloadReferences(normalized);
112
+ const redacted = redactSecrets(normalized);
113
+ return JSON.stringify(redacted);
114
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Combined, strictly read-only conductor doctor report (BAPI-395, BAPI-418).
3
+ *
4
+ * Extends the existing SQLite ledger health report with local git hook health
5
+ * and Epic Supervisor schedule enablement status. Missing hooks and non-worktree
6
+ * directories are reported as a degraded OPTIONAL capability — never a fatal
7
+ * failure. This module performs NO writes: no hook installation, no schema
8
+ * migration, no event emission, and no scheduler unit creation.
9
+ */
10
+ import { doctorConductorLedger } from "./store.js";
11
+ import { inspectConductorGitHooks } from "./git-hooks.js";
12
+ /**
13
+ * Inspect the local schedule metadata store for a registered epic-tick schedule.
14
+ * Strictly read-only: composes orchestrateScheduleList (read-only list path) and
15
+ * never creates, updates, or deletes any unit. A missing or erroring schedule is
16
+ * mapped to a degraded state, never a thrown exception.
17
+ *
18
+ * Schedule-run is loaded lazily via dynamic import to avoid eagerly pulling its
19
+ * node:fs/promises dependency into the module graph when doctor.ts is imported.
20
+ */
21
+ export async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
22
+ try {
23
+ // Lazily import schedule-run to avoid pulling node:fs/promises into the graph
24
+ // when doctor.ts is first imported (which would break node:fs mocks in tests).
25
+ const schedRun = orchestrateListOverride
26
+ ? null
27
+ : await import("../schedule-run.js");
28
+ const doList = orchestrateListOverride
29
+ ? orchestrateListOverride
30
+ : (d) => schedRun.orchestrateScheduleList({ json: false }, d);
31
+ const resolvedDeps = orchestrateListOverride
32
+ ? deps
33
+ : (deps ?? schedRun.createDefaultScheduleRunDeps());
34
+ const commandLabel = schedRun
35
+ ? schedRun.scheduleCommandLabel
36
+ : (m) => m.command ?? (m.idea_file ? "full-automation" : "(unknown)");
37
+ const runStatus = schedRun
38
+ ? schedRun.latestRunStatus
39
+ : (m) => {
40
+ const h = m.run_history;
41
+ return h && h.length > 0 ? h[h.length - 1].status : "";
42
+ };
43
+ const report = await doList(resolvedDeps);
44
+ const entry = report.entries.find((e) => commandLabel(e.metadata) === "epic-tick");
45
+ if (!entry) {
46
+ return {
47
+ registered: false,
48
+ backend: null,
49
+ next_fire_iso: null,
50
+ latest_run_status: null,
51
+ degraded: true,
52
+ warnings: [
53
+ "No epic-tick schedule is registered. Run: " +
54
+ "npx -y @bridge_gpt/mcp-server schedule-run create --in 1m --command epic-tick -- --epic-key <EPIC-KEY>",
55
+ ],
56
+ };
57
+ }
58
+ const m = entry.metadata;
59
+ const latest = runStatus(m);
60
+ const degraded = entry.status !== "active";
61
+ const warnings = [];
62
+ if (degraded) {
63
+ const msg = entry.status === "backend-unavailable"
64
+ ? `epic-tick schedule status is "backend-unavailable": the OS scheduler backend is unreachable. Check that the scheduler daemon is running.`
65
+ : `epic-tick schedule status is "${entry.status}" (expected "active"); re-register if stale.`;
66
+ warnings.push(msg);
67
+ }
68
+ return {
69
+ registered: true,
70
+ backend: m.backend ?? null,
71
+ next_fire_iso: m.run_at_iso ?? null,
72
+ latest_run_status: latest || null,
73
+ degraded,
74
+ warnings,
75
+ };
76
+ }
77
+ catch (err) {
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ return {
80
+ registered: false,
81
+ backend: null,
82
+ next_fire_iso: null,
83
+ latest_run_status: null,
84
+ degraded: true,
85
+ warnings: [`Failed to inspect epic-tick schedule: ${msg}`],
86
+ };
87
+ }
88
+ }
89
+ /**
90
+ * Build the combined read-only doctor report. Composes the existing ledger
91
+ * doctor, git hook inspection, and the epic-tick schedule enablement check.
92
+ * Never mutates the ledger, the hooks, the schema, or the OS scheduler.
93
+ */
94
+ export async function buildConductorDoctorReport(deps = {}) {
95
+ const doctorLedger = deps.doctorLedger ?? doctorConductorLedger;
96
+ const inspectHooks = deps.inspectHooks ?? inspectConductorGitHooks;
97
+ const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
98
+ return {
99
+ ledger: doctorLedger(),
100
+ git_hooks: inspectHooks(deps.hooksDeps),
101
+ epic_tick: epicTick,
102
+ };
103
+ }
104
+ /**
105
+ * Render the combined doctor report as human-readable text: ledger health,
106
+ * git hooks section, and an Epic Supervisor Schedule section using semantic
107
+ * status tags consistent with the git hooks section's visual hierarchy.
108
+ */
109
+ export function formatConductorDoctorReport(report) {
110
+ const { ledger, git_hooks, epic_tick } = report;
111
+ const lines = [
112
+ "Conductor ledger doctor",
113
+ "───────────────────────",
114
+ `path: ${ledger.path}`,
115
+ `directory exists: ${ledger.directory_exists}`,
116
+ `database exists: ${ledger.database_exists}`,
117
+ `directory mode: ${ledger.directory_mode ?? "n/a"} (ok: ${ledger.directory_permissions_ok})`,
118
+ `database mode: ${ledger.database_mode ?? "n/a"} (ok: ${ledger.database_permissions_ok})`,
119
+ `schema present: ${ledger.schema_present}`,
120
+ `journal mode: ${ledger.journal_mode ?? "n/a"}`,
121
+ `user_version: ${ledger.user_version ?? "n/a"}`,
122
+ `event count: ${ledger.event_count ?? "n/a"}`,
123
+ `max seq: ${ledger.max_seq ?? "n/a"}`,
124
+ `busy_timeout_ms: ${ledger.retention.busy_timeout_ms}`,
125
+ `retention_days: ${ledger.retention.retention_days}`,
126
+ `retention_max_rows: ${ledger.retention.retention_max_rows}`,
127
+ `message_cooldown_ms: ${ledger.message_relay.message_cooldown_ms}`,
128
+ `degraded (network FS): ${ledger.degraded}`,
129
+ ];
130
+ if (ledger.warnings.length > 0) {
131
+ lines.push("warnings:");
132
+ for (const w of ledger.warnings)
133
+ lines.push(` - ${w}`);
134
+ }
135
+ lines.push("");
136
+ lines.push("git hooks (optional, local)");
137
+ lines.push("───────────────────────────");
138
+ lines.push(`is git worktree: ${git_hooks.is_worktree}`);
139
+ lines.push(`hooks dir: ${git_hooks.hooks_dir ?? "n/a"}`);
140
+ lines.push(`degraded: ${git_hooks.degraded}`);
141
+ for (const hook of git_hooks.hooks) {
142
+ lines.push(` ${hook.name}: exists=${hook.exists} executable=${hook.executable} managed=${hook.managed_block_present}`);
143
+ }
144
+ if (git_hooks.warnings.length > 0) {
145
+ lines.push("git hook warnings:");
146
+ for (const w of git_hooks.warnings)
147
+ lines.push(` - ${w}`);
148
+ }
149
+ lines.push("");
150
+ lines.push("Epic Supervisor Schedule (optional, local)");
151
+ lines.push("──────────────────────────────────────────");
152
+ const registeredTag = epic_tick.registered ? "[SUCCESS] registered" : "[WARNING] not registered";
153
+ lines.push(`registered: ${registeredTag}`);
154
+ lines.push(`backend: ${epic_tick.backend ?? "n/a"}`);
155
+ lines.push(`next fire: ${epic_tick.next_fire_iso ?? "n/a"}`);
156
+ lines.push(`latest run status: ${epic_tick.latest_run_status ?? "n/a"}`);
157
+ lines.push(`degraded: ${epic_tick.degraded}`);
158
+ if (epic_tick.warnings.length > 0) {
159
+ lines.push("epic-tick warnings:");
160
+ for (const w of epic_tick.warnings)
161
+ lines.push(` - ${w}`);
162
+ }
163
+ return lines.join("\n");
164
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Pure done-gate config parsing, CI snapshot normalization, and deterministic
3
+ * gate evaluation (BAPI-395).
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
9
+ * `repo + pr_number + head_sha` binding. This module is pure (no I/O) and never
10
+ * throws for caller input.
11
+ */
12
+ import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
13
+ // ---------------------------------------------------------------------------
14
+ // Config parsing
15
+ // ---------------------------------------------------------------------------
16
+ function isPlainObject(value) {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+ /** Build a fail-closed inactive config with the given reason. */
20
+ function inactiveConfig(reason) {
21
+ return {
22
+ enabled: false,
23
+ valid: false,
24
+ reason,
25
+ condition: null,
26
+ config_hash: null,
27
+ gate_name: DEFAULT_GATE_NAME,
28
+ };
29
+ }
30
+ /**
31
+ * Coerce a raw config value into a JSON object, or `null` when it is unset or not
32
+ * an object. Accepts objects directly and non-empty JSON strings that parse to
33
+ * objects; treats `undefined`, `null`, empty/whitespace strings, and empty
34
+ * objects as unset. Malformed JSON, JSON arrays/primitives, and non-objects
35
+ * return `null` (the caller maps that to a fail-closed reason).
36
+ */
37
+ function coerceConfigObject(value) {
38
+ if (value === undefined || value === null)
39
+ return { kind: "unset" };
40
+ if (typeof value === "string") {
41
+ const trimmed = value.trim();
42
+ if (trimmed.length === 0)
43
+ return { kind: "unset" };
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(trimmed);
47
+ }
48
+ catch {
49
+ return { kind: "invalid" };
50
+ }
51
+ if (!isPlainObject(parsed))
52
+ return { kind: "invalid" };
53
+ if (Object.keys(parsed).length === 0)
54
+ return { kind: "unset" };
55
+ return { kind: "object", object: parsed };
56
+ }
57
+ if (isPlainObject(value)) {
58
+ if (Object.keys(value).length === 0)
59
+ return { kind: "unset" };
60
+ return { kind: "object", object: value };
61
+ }
62
+ return { kind: "invalid" };
63
+ }
64
+ /**
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`.
69
+ */
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;
80
+ if (!Array.isArray(rawChecks) || rawChecks.length === 0)
81
+ return null;
82
+ const normalized = [];
83
+ const seen = new Set();
84
+ for (const raw of rawChecks) {
85
+ const name = normalizeCheckName(raw);
86
+ if (name === null)
87
+ return null; // invalid name invalidates the whole config
88
+ if (seen.has(name))
89
+ return null; // duplicate after normalization
90
+ seen.add(name);
91
+ normalized.push(name);
92
+ }
93
+ return { type: REQUIRED_CI_CHECKS_GREEN, required_checks: normalized };
94
+ }
95
+ /**
96
+ * Parse a raw `conductor_done_gate` value into a fail-closed
97
+ * {@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.
100
+ */
101
+ export function parseDoneGateConfig(value) {
102
+ const coerced = coerceConfigObject(value);
103
+ if (coerced.kind === "unset")
104
+ return inactiveConfig("unset");
105
+ if (coerced.kind === "invalid")
106
+ return inactiveConfig("malformed");
107
+ const object = coerced.object;
108
+ // `enabled` must be the strict boolean `true`; truthy non-booleans are rejected.
109
+ if (object.enabled !== true) {
110
+ if (object.enabled === false)
111
+ return inactiveConfig("disabled");
112
+ return inactiveConfig("invalid: 'enabled' must be the boolean true");
113
+ }
114
+ const condition = parseSingleCondition(object);
115
+ if (condition === null) {
116
+ return inactiveConfig("invalid: expected exactly one valid required_ci_checks_green condition");
117
+ }
118
+ const gateName = DEFAULT_GATE_NAME;
119
+ const configHash = stableJsonHash({
120
+ gate_name: gateName,
121
+ type: condition.type,
122
+ required_checks: condition.required_checks,
123
+ });
124
+ return {
125
+ enabled: true,
126
+ valid: true,
127
+ reason: "active",
128
+ condition,
129
+ config_hash: configHash,
130
+ gate_name: gateName,
131
+ };
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // CI snapshot normalization
135
+ // ---------------------------------------------------------------------------
136
+ const SUCCESS_STATES = new Set(["success", "passed", "succeeded"]);
137
+ const COMPLETE_STATES = new Set([
138
+ "completed",
139
+ "complete",
140
+ "success",
141
+ "passed",
142
+ "succeeded",
143
+ "failure",
144
+ "failed",
145
+ "error",
146
+ "cancelled",
147
+ "canceled",
148
+ "timed_out",
149
+ "action_required",
150
+ "neutral",
151
+ "skipped",
152
+ ]);
153
+ function asLowerString(value) {
154
+ return typeof value === "string" && value.trim().length > 0 ? value.trim().toLowerCase() : undefined;
155
+ }
156
+ /**
157
+ * Normalize one raw check entry (already keyed by `name`) into a
158
+ * {@link NormalizedCiCheck}. Returns `null` for malformed entries so they are
159
+ * skipped rather than poisoning sibling checks.
160
+ */
161
+ function normalizeOneCheck(name, raw) {
162
+ const checkName = normalizeCheckName(name);
163
+ if (checkName === null)
164
+ return null;
165
+ if (!isPlainObject(raw)) {
166
+ // A bare name with no status info is not usable as a green check.
167
+ return { name: checkName, complete: false, green: false };
168
+ }
169
+ const status = asLowerString(raw.status);
170
+ const conclusion = asLowerString(raw.conclusion);
171
+ const explicitComplete = typeof raw.complete === "boolean" ? raw.complete : undefined;
172
+ const explicitPassed = typeof raw.passed === "boolean" ? raw.passed : undefined;
173
+ let complete = false;
174
+ if (explicitComplete !== undefined) {
175
+ complete = explicitComplete;
176
+ }
177
+ else if (conclusion !== undefined && COMPLETE_STATES.has(conclusion)) {
178
+ complete = true;
179
+ }
180
+ else if (status !== undefined && COMPLETE_STATES.has(status)) {
181
+ complete = true;
182
+ }
183
+ let green = false;
184
+ if (complete) {
185
+ if (explicitPassed === true) {
186
+ green = true;
187
+ }
188
+ else if (conclusion !== undefined && SUCCESS_STATES.has(conclusion)) {
189
+ green = true;
190
+ }
191
+ else if (conclusion === undefined &&
192
+ explicitPassed === undefined &&
193
+ status !== undefined &&
194
+ SUCCESS_STATES.has(status)) {
195
+ green = true;
196
+ }
197
+ }
198
+ // An explicit failure signal can never be green even if complete.
199
+ if (explicitPassed === false)
200
+ green = false;
201
+ const state = conclusion ?? status ?? (explicitPassed === true ? "passed" : undefined);
202
+ const check = { name: checkName, complete, green };
203
+ if (state !== undefined)
204
+ check.state = state;
205
+ return check;
206
+ }
207
+ /**
208
+ * Normalize a raw `poll_ci_checks` response into a SHA-keyed
209
+ * {@link NormalizedCiSnapshot}. Supports both array-shaped and object/map-shaped
210
+ * `checks`. Pending, missing, failed, cancelled, malformed, and unknown checks are
211
+ * never treated as green. `all_passed`/`all_complete` are recomputed from the
212
+ * normalized set, never trusted from the raw aggregate flags alone.
213
+ */
214
+ export function normalizeCiSnapshot(response) {
215
+ const checks = [];
216
+ const byName = new Map();
217
+ if (isPlainObject(response)) {
218
+ const rawChecks = response.checks;
219
+ if (Array.isArray(rawChecks)) {
220
+ for (const entry of rawChecks) {
221
+ if (!isPlainObject(entry))
222
+ continue;
223
+ const normalized = normalizeOneCheck(entry.name, entry);
224
+ if (normalized && !byName.has(normalized.name)) {
225
+ byName.set(normalized.name, normalized);
226
+ checks.push(normalized);
227
+ }
228
+ }
229
+ }
230
+ else if (isPlainObject(rawChecks)) {
231
+ for (const [name, value] of Object.entries(rawChecks)) {
232
+ const normalized = normalizeOneCheck(name, value);
233
+ if (normalized && !byName.has(normalized.name)) {
234
+ byName.set(normalized.name, normalized);
235
+ checks.push(normalized);
236
+ }
237
+ }
238
+ }
239
+ }
240
+ const unknownChecks = [];
241
+ if (isPlainObject(response) && Array.isArray(response.unknown_checks)) {
242
+ for (const raw of response.unknown_checks) {
243
+ const name = normalizeCheckName(raw);
244
+ if (name !== null && !unknownChecks.includes(name))
245
+ unknownChecks.push(name);
246
+ }
247
+ }
248
+ const allComplete = checks.length > 0 && checks.every((c) => c.complete);
249
+ const allPassed = checks.length > 0 && checks.every((c) => c.green) && unknownChecks.length === 0;
250
+ // Stable, order-independent hash: sort by name so array vs map and differing
251
+ // input order collide, while any status/conclusion change shifts the hash.
252
+ const hashInput = {
253
+ checks: [...checks]
254
+ .sort((a, b) => a.name.localeCompare(b.name))
255
+ .map((c) => ({ name: c.name, complete: c.complete, green: c.green })),
256
+ unknown_checks: [...unknownChecks].sort(),
257
+ };
258
+ return {
259
+ checks,
260
+ unknown_checks: unknownChecks,
261
+ check_state_hash: stableJsonHash(hashInput),
262
+ all_complete: allComplete,
263
+ all_passed: allPassed,
264
+ };
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // Gate evaluation
268
+ // ---------------------------------------------------------------------------
269
+ function failedEvaluation(reason) {
270
+ return { met: false, reason };
271
+ }
272
+ /**
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.
278
+ */
279
+ export function evaluateDoneGate(config, binding, snapshot, evaluatedAtIso) {
280
+ if (!config.enabled || !config.valid || config.condition === null) {
281
+ return failedEvaluation(`gate inactive: ${config.reason}`);
282
+ }
283
+ const headSha = normalizeSha(binding.head_sha);
284
+ if (headSha === null) {
285
+ return failedEvaluation("invalid binding: head_sha is not a valid SHA");
286
+ }
287
+ const byName = new Map();
288
+ for (const check of snapshot.checks)
289
+ byName.set(check.name, check);
290
+ 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;
299
+ }
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)`);
303
+ }
304
+ }
305
+ if (unmet.length > 0) {
306
+ return failedEvaluation(`required checks not green: ${unmet.join(", ")}`);
307
+ }
308
+ const details = {
309
+ repo: binding.repo,
310
+ pr_number: binding.pr_number,
311
+ head_sha: headSha,
312
+ gate_name: config.gate_name,
313
+ config_hash: config.config_hash,
314
+ condition_type: config.condition.type,
315
+ required_checks: [...config.condition.required_checks],
316
+ check_results: checkResults,
317
+ evaluated_at: evaluatedAtIso,
318
+ };
319
+ const gateEventData = {
320
+ summary: `Done gate "${config.gate_name}" met for ${binding.subject}`,
321
+ status: "met",
322
+ details,
323
+ };
324
+ return { met: true, reason: "met", gateEventData };
325
+ }