@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,185 @@
1
+ /**
2
+ * Best-effort local git inspection for the conductor producer (BAPI-395).
3
+ *
4
+ * These helpers run short, list-argument git subprocesses (never `shell: true`)
5
+ * with bounded timeouts and treat ALL output as best-effort local telemetry — a
6
+ * failed or unavailable git invocation degrades to a safe empty result rather than
7
+ * throwing. Raw stderr/stdout (which can contain credentials) is never surfaced;
8
+ * remote URLs are credential-stripped before they reach any event payload.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { basename } from "node:path";
12
+ import { normalizeRepoName, normalizeSha } from "./git-ci-types.js";
13
+ /** Default bounded timeout for a single git subprocess. */
14
+ export const GIT_COMMAND_TIMEOUT_MS = 5_000;
15
+ /** Max captured stdout per git command (10 MiB) — bounds memory for large logs. */
16
+ const GIT_COMMAND_MAX_BUFFER = 10 * 1024 * 1024;
17
+ /**
18
+ * Run `git <args>` with list-based arguments, a bounded timeout, and NO shell.
19
+ * Returns `{ ok: true, stdout }` on success or `{ ok: false, stdout: "" }` on any
20
+ * failure. Raw stderr/stdout from a failed command is intentionally discarded so
21
+ * credential-bearing error text never escapes.
22
+ */
23
+ export function runGitCommand(args, options = {}) {
24
+ try {
25
+ const stdout = execFileSync("git", args, {
26
+ cwd: options.cwd,
27
+ timeout: options.timeoutMs ?? GIT_COMMAND_TIMEOUT_MS,
28
+ encoding: "utf-8",
29
+ maxBuffer: GIT_COMMAND_MAX_BUFFER,
30
+ // Capture stdout only; ignore stdin and stderr so raw error text (which may
31
+ // include credentials) is never read back. `shell` defaults to false.
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ });
34
+ return { ok: true, stdout: typeof stdout === "string" ? stdout : "" };
35
+ }
36
+ catch {
37
+ return { ok: false, stdout: "" };
38
+ }
39
+ }
40
+ function firstLine(result) {
41
+ if (!result.ok)
42
+ return null;
43
+ const trimmed = result.stdout.trim();
44
+ return trimmed.length > 0 ? trimmed : null;
45
+ }
46
+ /**
47
+ * Strip embedded credentials from an HTTPS remote URL
48
+ * (`https://user:token@host/...` → `https://host/...`). SSH-style remotes
49
+ * (`git@host:org/repo.git`) and unparseable values are returned trimmed and
50
+ * unchanged — they carry no inline secret to remove.
51
+ */
52
+ export function sanitizeGitRemoteUrl(url) {
53
+ if (typeof url !== "string")
54
+ return null;
55
+ const trimmed = url.trim();
56
+ if (trimmed.length === 0)
57
+ return null;
58
+ if (/^https?:\/\//i.test(trimmed)) {
59
+ try {
60
+ const parsed = new URL(trimmed);
61
+ parsed.username = "";
62
+ parsed.password = "";
63
+ return parsed.toString();
64
+ }
65
+ catch {
66
+ // Fall through: strip a leading `user:pass@` segment defensively.
67
+ return trimmed.replace(/^(https?:\/\/)[^/@]*@/i, "$1");
68
+ }
69
+ }
70
+ return trimmed;
71
+ }
72
+ /**
73
+ * Resolve the local worktree context: worktree root, git common dir, current
74
+ * branch, HEAD SHA, sanitized origin remote, and repo identity. Repo name prefers
75
+ * `BAPI_CONDUCTOR_REPO_NAME`, then `BAPI_REPO_NAME`, then the git root basename.
76
+ * Never throws — missing git simply yields a degraded context.
77
+ */
78
+ export function getGitWorktreeContext(options = {}) {
79
+ const cwd = options.cwd ?? process.cwd();
80
+ const env = options.env ?? process.env;
81
+ const topLevel = firstLine(runGitCommand(["rev-parse", "--show-toplevel"], { cwd }));
82
+ const isWorktree = topLevel !== null;
83
+ const worktreePath = topLevel ?? cwd;
84
+ const gitCommonDir = firstLine(runGitCommand(["rev-parse", "--git-common-dir"], { cwd }));
85
+ const branchRaw = firstLine(runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }));
86
+ const branch = branchRaw === null || branchRaw === "HEAD" ? null : branchRaw;
87
+ const headSha = normalizeSha(firstLine(runGitCommand(["rev-parse", "HEAD"], { cwd })) ?? "");
88
+ const remoteOrigin = sanitizeGitRemoteUrl(firstLine(runGitCommand(["config", "--get", "remote.origin.url"], { cwd })) ?? "");
89
+ const repo = normalizeRepoName(env.BAPI_CONDUCTOR_REPO_NAME) ??
90
+ normalizeRepoName(env.BAPI_REPO_NAME) ??
91
+ normalizeRepoName(basename(worktreePath)) ??
92
+ "unknown";
93
+ return {
94
+ repo,
95
+ worktree_path: worktreePath,
96
+ git_common_dir: gitCommonDir,
97
+ branch,
98
+ head_sha: headSha,
99
+ remote_origin: remoteOrigin,
100
+ is_worktree: isWorktree,
101
+ };
102
+ }
103
+ const CO_AUTHOR_RE = /^co-authored-by:\s*(.+?)\s*<([^<>@\s]+@[^<>\s]+)>\s*$/i;
104
+ /**
105
+ * Parse `Co-Authored-By: Name <email>` trailers from a commit message,
106
+ * case-insensitively, ignoring malformed lines (missing or invalid email).
107
+ */
108
+ export function parseCoAuthoredByTrailers(message) {
109
+ if (typeof message !== "string" || message.length === 0)
110
+ return [];
111
+ const out = [];
112
+ for (const line of message.split(/\r?\n/)) {
113
+ const match = CO_AUTHOR_RE.exec(line.trim());
114
+ if (match) {
115
+ out.push({ name: match[1].trim(), email: match[2].trim() });
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ /** Unit-separator-delimited field format for `git show -s`. */
121
+ const COMMIT_FORMAT = "%H%x1f%P%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%aI%x1f%cI%x1f%s%x1f%b";
122
+ /**
123
+ * Read normalized metadata for `HEAD` (or another ref): SHA, parent SHAs,
124
+ * author/committer identity and timestamps, subject, body, and parsed co-authors.
125
+ * Returns `null` when the commit cannot be read.
126
+ */
127
+ export function readHeadCommitMetadata(options = {}) {
128
+ const ref = options.ref ?? "HEAD";
129
+ const result = runGitCommand(["show", "-s", `--format=${COMMIT_FORMAT}`, ref], { cwd: options.cwd });
130
+ if (!result.ok)
131
+ return null;
132
+ // Split into exactly 10 fields; the final body field may contain newlines.
133
+ const fields = result.stdout.replace(/\n$/, "").split("\u001f");
134
+ if (fields.length < 10)
135
+ return null;
136
+ const [sha, parentsRaw, authorName, authorEmail, committerName, committerEmail, authoredAt, committedAt, subject, body] = fields;
137
+ const parents = parentsRaw
138
+ .trim()
139
+ .split(/\s+/)
140
+ .map((p) => normalizeSha(p))
141
+ .filter((p) => p !== null);
142
+ const coAuthors = parseCoAuthoredByTrailers(body);
143
+ return {
144
+ sha: normalizeSha(sha),
145
+ parents,
146
+ author_name: authorName,
147
+ author_email: authorEmail,
148
+ committer_name: committerName,
149
+ committer_email: committerEmail,
150
+ authored_at: authoredAt,
151
+ committed_at: committedAt,
152
+ subject,
153
+ body,
154
+ co_authors: coAuthors,
155
+ attribution_source: coAuthors.length > 0 ? "co-authored-by-trailer" : "commit-author",
156
+ };
157
+ }
158
+ const REF_CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
159
+ /**
160
+ * Parse `reference-transaction` stdin (`<old_sha> <new_sha> <ref>` per line) into
161
+ * normalized updates. Malformed lines — wrong field count, non-hex SHAs, empty or
162
+ * control-char-bearing refs — are skipped rather than producing unsafe data.
163
+ */
164
+ export function parseReferenceTransactionUpdates(stdin) {
165
+ if (typeof stdin !== "string" || stdin.length === 0)
166
+ return [];
167
+ const out = [];
168
+ for (const line of stdin.split(/\r?\n/)) {
169
+ const trimmed = line.trim();
170
+ if (trimmed.length === 0)
171
+ continue;
172
+ const parts = trimmed.split(/\s+/);
173
+ if (parts.length !== 3)
174
+ continue;
175
+ const oldSha = normalizeSha(parts[0]);
176
+ const newSha = normalizeSha(parts[1]);
177
+ const ref = parts[2];
178
+ if (oldSha === null || newSha === null)
179
+ continue;
180
+ if (ref.length === 0 || REF_CONTROL_CHAR_RE.test(ref))
181
+ continue;
182
+ out.push({ old_sha: oldSha, new_sha: newSha, ref });
183
+ }
184
+ return out;
185
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Canonical local git hook event production (BAPI-395).
3
+ *
4
+ * Opportunistic, non-blocking local hooks emit `git.commit_created` (post-commit)
5
+ * and `worktree.changed` (reference-transaction) using normalized conductor event
6
+ * envelopes. Every producer is best-effort: inspection failures, unmapped phases,
7
+ * and ledger errors all resolve to a success-compatible result so a git commit or
8
+ * ref update is NEVER blocked by conductor telemetry. Events use only the
9
+ * conductor taxonomy and allowlisted top-level data keys; no provider-native or
10
+ * Claude-specific fields appear.
11
+ */
12
+ import { GIT_HOOK_PRODUCER, stableJsonHash, } from "./git-ci-types.js";
13
+ import { getGitWorktreeContext, parseReferenceTransactionUpdates, readHeadCommitMetadata, } from "./git-inspection.js";
14
+ import { emitConductorEventIfNew } from "./producer-ledger.js";
15
+ /** The git phase that represents a durable, committed ref transaction. */
16
+ export const COMMITTED_REF_PHASE = "committed";
17
+ /**
18
+ * Build a canonical `git.commit_created` event input from a worktree context and
19
+ * HEAD commit metadata. Only allowlisted top-level data keys are used; all commit
20
+ * facts live under `data.details`.
21
+ */
22
+ export function buildCommitCreatedEventInput(context, metadata) {
23
+ const details = {
24
+ repo: context.repo,
25
+ branch: context.branch,
26
+ worktree_path: context.worktree_path,
27
+ commit_sha: metadata.sha,
28
+ parent_shas: metadata.parents,
29
+ author: { name: metadata.author_name, email: metadata.author_email },
30
+ committer: { name: metadata.committer_name, email: metadata.committer_email },
31
+ co_authors: metadata.co_authors,
32
+ authored_at: metadata.authored_at,
33
+ committed_at: metadata.committed_at,
34
+ subject: metadata.subject,
35
+ attribution_source: metadata.attribution_source,
36
+ };
37
+ return {
38
+ source: "git",
39
+ type: "git.commit_created",
40
+ subject: context.repo,
41
+ producer: GIT_HOOK_PRODUCER,
42
+ observed_via: "git-hook:post-commit",
43
+ data: {
44
+ summary: `Commit ${metadata.sha ?? "(unknown)"} on ${context.branch ?? "(detached)"}`,
45
+ status: "created",
46
+ details,
47
+ },
48
+ };
49
+ }
50
+ /**
51
+ * Build a canonical `worktree.changed` event input from committed
52
+ * reference-transaction updates. Only allowlisted top-level data keys are used.
53
+ */
54
+ export function buildWorktreeChangedEventInput(context, updates, phase) {
55
+ const updatesHash = stableJsonHash({ phase, updates });
56
+ const details = {
57
+ repo: context.repo,
58
+ branch: context.branch,
59
+ worktree_path: context.worktree_path,
60
+ transaction_phase: phase,
61
+ ref_updates: updates,
62
+ updates_hash: updatesHash,
63
+ };
64
+ return {
65
+ source: "git",
66
+ type: "worktree.changed",
67
+ subject: context.repo,
68
+ producer: GIT_HOOK_PRODUCER,
69
+ observed_via: "git-hook:reference-transaction",
70
+ data: {
71
+ summary: `${updates.length} ref update(s) on ${context.repo}`,
72
+ status: "changed",
73
+ details,
74
+ },
75
+ };
76
+ }
77
+ /**
78
+ * Post-commit hook producer: inspect HEAD, build the `git.commit_created` event,
79
+ * dedupe by `repo + commit_sha`, and emit best-effort. Returns a
80
+ * success-compatible result for every failure mode so the commit is never blocked.
81
+ */
82
+ export function runPostCommitHookProducer(deps = {}) {
83
+ const getContext = deps.getContext ?? getGitWorktreeContext;
84
+ const readMetadata = deps.readMetadata ?? readHeadCommitMetadata;
85
+ const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
86
+ try {
87
+ const context = getContext({ cwd: deps.cwd, env: deps.env });
88
+ const metadata = readMetadata({ cwd: deps.cwd });
89
+ if (!metadata || metadata.sha === null) {
90
+ return { ok: true, emitted: false, reason: "no-head-commit" };
91
+ }
92
+ const input = buildCommitCreatedEventInput(context, metadata);
93
+ const decision = emitIfNew(input, {
94
+ event_type: "git.commit_created",
95
+ repo: context.repo,
96
+ commit_sha: metadata.sha,
97
+ });
98
+ return { ok: true, emitted: decision.emitted, reason: decision.reason };
99
+ }
100
+ catch {
101
+ // Best-effort: never block the commit on a conductor failure.
102
+ return { ok: true, emitted: false, reason: "skipped" };
103
+ }
104
+ }
105
+ /**
106
+ * Reference-transaction hook producer: ignore non-`committed` phases, parse the
107
+ * stdin updates, build the `worktree.changed` event, dedupe by
108
+ * `repo + phase + updates_hash`, and emit best-effort. Returns a
109
+ * success-compatible result for every failure mode so the ref update is never
110
+ * blocked.
111
+ */
112
+ export function runReferenceTransactionHookProducer(args, deps = {}) {
113
+ const getContext = deps.getContext ?? getGitWorktreeContext;
114
+ const parseUpdates = deps.parseUpdates ?? parseReferenceTransactionUpdates;
115
+ const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
116
+ try {
117
+ if (args.phase !== COMMITTED_REF_PHASE) {
118
+ return { ok: true, emitted: false, reason: "non-committed-phase" };
119
+ }
120
+ const updates = parseUpdates(args.stdin);
121
+ if (updates.length === 0) {
122
+ return { ok: true, emitted: false, reason: "no-updates" };
123
+ }
124
+ const context = getContext({ cwd: deps.cwd, env: deps.env });
125
+ const input = buildWorktreeChangedEventInput(context, updates, args.phase);
126
+ const refUpdatesHash = stableJsonHash({ phase: args.phase, updates });
127
+ const decision = emitIfNew(input, {
128
+ event_type: "worktree.changed",
129
+ repo: context.repo,
130
+ ref_updates_hash: refUpdatesHash,
131
+ });
132
+ return { ok: true, emitted: decision.emitted, reason: decision.reason };
133
+ }
134
+ catch {
135
+ return { ok: true, emitted: false, reason: "skipped" };
136
+ }
137
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Local conductor merge-ledger helpers (Conductor C6, BAPI-398).
3
+ *
4
+ * The conductor never performs a provider merge locally — it calls the protected
5
+ * Bridge API endpoint and records the returned `merge.*` lifecycle events into the
6
+ * LOCAL SQLite ledger. This module centralizes:
7
+ *
8
+ * - the deterministic action key `merge:{repo}:{pr}:{head_sha}:{gate}` (kept in
9
+ * lock-step with the Python `build_merge_action_key`),
10
+ * - immutable PR-identity extraction from a worker-scoped `gate.met` event
11
+ * (branch names are deliberately ignored — never used to bind a merge),
12
+ * - deterministic, idempotency-keyed event ids,
13
+ * - read-only terminal-success / dry-run lookups,
14
+ * - duplicate-tolerant event emission.
15
+ *
16
+ * It performs NO privileged action and never handles VCS write credentials.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { normalizePrNumber, normalizeRepoName, normalizeSha } from "./git-ci-types.js";
20
+ import { emitConductorEvent, openReadonlyConductorDatabaseIfExists, } from "./store.js";
21
+ /**
22
+ * Compose the stable gate-identity segment, mirroring the Python
23
+ * `normalize_gate_identity`: `{name}@{config_hash}` when a hash is present
24
+ * (lower-cased), otherwise just the gate name.
25
+ */
26
+ export function buildGateIdentity(gateName, configHash) {
27
+ const name = gateName.trim();
28
+ const hash = typeof configHash === "string" ? configHash.trim() : "";
29
+ return hash ? `${name}@${hash.toLowerCase()}` : name;
30
+ }
31
+ /**
32
+ * Build the deterministic action key. Normalizes repo / PR / head SHA exactly as
33
+ * the Python side does (lower-cased SHA, trimmed repo, positive integer PR) so the
34
+ * conductor-computed key and the API-recomputed key are byte-identical. Throws on
35
+ * any invalid component. The branch name is never part of the key.
36
+ */
37
+ export function makeMergeActionKey(repo, prNumber, headSha, gateIdentity) {
38
+ const r = normalizeRepoName(repo);
39
+ const pr = normalizePrNumber(prNumber);
40
+ const sha = normalizeSha(headSha);
41
+ const gate = (gateIdentity ?? "").trim();
42
+ if (r === null || pr === null || sha === null || gate.length === 0) {
43
+ throw new Error("invalid merge action key component");
44
+ }
45
+ return `merge:${r}:${pr}:${sha}:${gate}`;
46
+ }
47
+ /**
48
+ * Resolve the immutable merge identity from a `gate.met` event. Returns `null`
49
+ * unless the event is a worker-scoped `gate.met` carrying a complete PR binding
50
+ * (repo, pr_number, head_sha, gate_name). Run-level or incomplete events yield
51
+ * `null`. Branch-name fields, if present, are ignored.
52
+ */
53
+ export function extractMergeActionIdentityFromGateEvent(event) {
54
+ if (event.type !== "gate.met")
55
+ return null;
56
+ // Worker scope is required — a run-level gate.met never binds a specific worker.
57
+ if (typeof event.worker_id !== "string" || event.worker_id.trim().length === 0) {
58
+ return null;
59
+ }
60
+ const data = event.data ?? {};
61
+ const details = data.details;
62
+ if (!details || typeof details !== "object")
63
+ return null;
64
+ const d = details;
65
+ const repo = normalizeRepoName(d.repo);
66
+ const prNumber = normalizePrNumber(d.pr_number);
67
+ const headSha = normalizeSha(d.head_sha);
68
+ const gateName = typeof d.gate_name === "string" ? d.gate_name.trim() : "";
69
+ if (repo === null || prNumber === null || headSha === null || gateName.length === 0) {
70
+ return null;
71
+ }
72
+ const configHash = typeof d.config_hash === "string" && d.config_hash.trim().length > 0
73
+ ? d.config_hash.trim()
74
+ : null;
75
+ const requiredChecks = Array.isArray(d.required_checks)
76
+ ? d.required_checks.filter((c) => typeof c === "string" && c.trim().length > 0)
77
+ : [];
78
+ const gateIdentity = buildGateIdentity(gateName, configHash);
79
+ const actionKey = makeMergeActionKey(repo, prNumber, headSha, gateIdentity);
80
+ return {
81
+ repo,
82
+ pr_number: prNumber,
83
+ head_sha: headSha,
84
+ gate_name: gateName,
85
+ config_hash: configHash,
86
+ required_checks: requiredChecks,
87
+ gate_identity: gateIdentity,
88
+ action_key: actionKey,
89
+ gate_event: {
90
+ id: typeof event.id === "string" ? event.id : undefined,
91
+ seq: typeof event.seq === "number" ? event.seq : undefined,
92
+ time: typeof event.time === "string" ? event.time : undefined,
93
+ },
94
+ };
95
+ }
96
+ /**
97
+ * Derive a deterministic, UUID-shaped event id from the event type plus action
98
+ * key. Repeated emits of the same lifecycle event for the same action key collide
99
+ * on the `events.id` UNIQUE constraint instead of duplicating. `merge.succeeded`
100
+ * thus has one terminal id per action key. Mirrors `makeSupervisorAssessmentEventId`.
101
+ */
102
+ export function makeMergeEventId(eventType, actionKey) {
103
+ const h = createHash("sha256").update(`${eventType}:${actionKey}`).digest("hex");
104
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
105
+ }
106
+ function lookupMergeEventByActionKey(eventType, actionKey, deps) {
107
+ const openDb = deps.openDb ?? (() => openReadonlyConductorDatabaseIfExists());
108
+ const db = openDb();
109
+ if (!db)
110
+ return false;
111
+ try {
112
+ const row = db
113
+ .prepare(`SELECT 1 FROM events
114
+ WHERE type = ?
115
+ AND json_extract(data_json, '$.details.action_key') = ?
116
+ LIMIT 1`)
117
+ .get(eventType, actionKey);
118
+ return row !== undefined;
119
+ }
120
+ finally {
121
+ db.close();
122
+ }
123
+ }
124
+ /**
125
+ * Return `true` when a terminal `merge.succeeded` event already exists locally for
126
+ * the action key. Only `merge.succeeded` is terminal — `merge.failed` and
127
+ * `merge.dry_run` are never terminal.
128
+ */
129
+ export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
130
+ return lookupMergeEventByActionKey("merge.succeeded", actionKey, deps);
131
+ }
132
+ /**
133
+ * Return `true` when a `merge.dry_run` event already exists for the action key.
134
+ * Dry-run is audit/hygiene only and is NEVER treated as terminal — a later real
135
+ * merge for the same PR/head/gate may still proceed if the repo flag is enabled.
136
+ */
137
+ export function hasMergeDryRun(actionKey, deps = {}) {
138
+ return lookupMergeEventByActionKey("merge.dry_run", actionKey, deps);
139
+ }
140
+ /**
141
+ * Return `true` when a `merge.pending_approval` event already exists for the
142
+ * action key. Pending approval is NEVER terminal — the supervisor must re-dispatch
143
+ * until the backend reports approval (returning `merge.attempted` + `merge.succeeded`).
144
+ */
145
+ export function hasMergePendingApproval(actionKey, deps = {}) {
146
+ return lookupMergeEventByActionKey("merge.pending_approval", actionKey, deps);
147
+ }
148
+ /** Heuristically detect a SQLite duplicate-id / UNIQUE constraint failure. */
149
+ function isDuplicateConstraintError(error) {
150
+ if (!error || typeof error !== "object")
151
+ return false;
152
+ const code = error.code;
153
+ if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT"))
154
+ return true;
155
+ const message = error.message;
156
+ if (typeof message === "string") {
157
+ const lowered = message.toLowerCase();
158
+ if (lowered.includes("unique constraint") || lowered.includes("constraint failed"))
159
+ return true;
160
+ }
161
+ return false;
162
+ }
163
+ /**
164
+ * Emit a `merge.*` lifecycle event under a deterministic, action-key-derived id.
165
+ * Top-level `data` is restricted to the allowlisted `summary`/`status`/`reason`/
166
+ * `details` keys (repo, PR number, head SHA, gate, action key, and sanitized
167
+ * outcome fields live under `details`). A duplicate UNIQUE-constraint failure is
168
+ * classified as `{ emitted: false, reason: "duplicate" }` rather than thrown.
169
+ */
170
+ export function emitMergeLedgerEventIfNew(input, deps = {}) {
171
+ const emitEvent = deps.emitEvent ?? emitConductorEvent;
172
+ const eventId = makeMergeEventId(input.type, input.action_key);
173
+ const event = {
174
+ id: eventId,
175
+ source: "conductor-supervisor",
176
+ type: input.type,
177
+ run_id: input.run_id ?? null,
178
+ worker_id: input.worker_id ?? null,
179
+ producer: "conductor-merge",
180
+ observed_via: "supervisor",
181
+ data: {
182
+ summary: input.summary ?? `${input.type} ${input.action_key}`,
183
+ status: input.status,
184
+ reason: input.reason ?? undefined,
185
+ details: input.details,
186
+ },
187
+ };
188
+ try {
189
+ const result = emitEvent(event);
190
+ return { emitted: true, event_id: eventId, event: result.event };
191
+ }
192
+ catch (error) {
193
+ if (isDuplicateConstraintError(error)) {
194
+ return { emitted: false, reason: "duplicate" };
195
+ }
196
+ throw error;
197
+ }
198
+ }