@bridge_gpt/mcp-server 0.2.16 → 0.2.18

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 (45) hide show
  1. package/build/agents.generated.js +2 -2
  2. package/build/commands.generated.js +6 -6
  3. package/build/conductor/bridge-api-client.js +191 -11
  4. package/build/conductor/claude-hook.js +22 -4
  5. package/build/conductor/cli.js +11 -13
  6. package/build/conductor/done-gate.js +5 -0
  7. package/build/conductor/epic-reconcile.js +62 -13
  8. package/build/conductor/epic-runtime.js +447 -35
  9. package/build/conductor/epic-state.js +517 -63
  10. package/build/conductor/errors.js +41 -0
  11. package/build/conductor/event-accessors.js +234 -0
  12. package/build/conductor/file-scope-guard.js +201 -0
  13. package/build/conductor/github-mergeability.js +85 -0
  14. package/build/conductor/local-merge.js +47 -1
  15. package/build/conductor/merge-identity.js +41 -0
  16. package/build/conductor/merge-ledger.js +13 -68
  17. package/build/conductor/plan.js +12 -2
  18. package/build/conductor/pr-discovery.js +11 -1
  19. package/build/conductor/supervisor-config.js +4 -39
  20. package/build/conductor/supervisor-escalation.js +10 -26
  21. package/build/conductor/supervisor-ledger.js +5 -12
  22. package/build/conductor/supervisor-message-relay.js +2 -5
  23. package/build/conductor/supervisor-notification.js +1 -1
  24. package/build/conductor/supervisor-runtime.js +12 -54
  25. package/build/conductor/supervisor-state.js +4 -18
  26. package/build/conductor/supervisor-types.js +2 -2
  27. package/build/conductor/taxonomy.js +4 -0
  28. package/build/conductor-bin.js +2333 -666
  29. package/build/conductor-claude-hook-bin.js +4 -2
  30. package/build/doctor.js +32 -0
  31. package/build/index.js +10125 -8522
  32. package/build/install-bridge.js +25 -8
  33. package/build/install-doctor.js +387 -0
  34. package/build/pipelines.generated.js +30 -5
  35. package/build/regression-check.js +53 -1
  36. package/build/review-tickets.js +175 -21
  37. package/build/start-tickets-conductor.js +22 -6
  38. package/build/start-tickets-prereqs.js +33 -3
  39. package/build/start-tickets.js +122 -22
  40. package/build/version.generated.js +1 -1
  41. package/package.json +5 -5
  42. package/pipelines/review-ticket.json +24 -2
  43. package/public/css/main.min.css +3272 -1
  44. package/public/css/main.min.css.map +1 -1
  45. package/smoke-test/SMOKE-TEST.md +4 -2
@@ -10,6 +10,43 @@
10
10
  * generic message.
11
11
  */
12
12
  import { redactSecretString } from "./redaction.js";
13
+ /**
14
+ * Redact a diagnostic string destined for BACKEND LOGS ONLY (never the client
15
+ * envelope): strip secret-shaped material (tokens, keys, bearer creds) via
16
+ * {@link redactSecretString}, then additionally mask user home-dir path segments
17
+ * (`/Users/<name>/…`, `/home/<name>/…`) which leak usernames and credential-bearing
18
+ * locations. The rest of a stack trace is preserved so operators can still diagnose.
19
+ */
20
+ function redactDiagnostic(value) {
21
+ return redactSecretString(value).replace(/(\/(?:Users|home)\/)[^/\s:]+/g, "$1[REDACTED_USER]");
22
+ }
23
+ /**
24
+ * FINDING 5 (BAPI-463): surface an unexpected (INTERNAL_ERROR) error with its
25
+ * class + message (redacted so no secret or credential-bearing path leaks) so
26
+ * operators can root-cause the underlying fault that the sanitized client
27
+ * envelope intentionally hides (e.g. the recurring `check_messages` failure).
28
+ *
29
+ * Emitted as a SINGLE LINE with NO stack frames: the conductor's `console.error`
30
+ * IS the CLI's stderr surface, which a committed security invariant requires to
31
+ * stay stack-trace-free (`security-regressions.test.ts`). The error class +
32
+ * message is the diagnostic essence the acceptance criterion calls for; a raw
33
+ * stack would both violate that invariant and risk leaking internal file paths.
34
+ * Best-effort: a logging failure must never mask the original error, and NOTHING
35
+ * here is returned to the MCP/CLI caller.
36
+ */
37
+ function logInternalErrorDiagnostic(error) {
38
+ try {
39
+ const name = error instanceof Error ? error.name : typeof error;
40
+ const rawMessage = error instanceof Error ? error.message : String(error);
41
+ // Collapse any newlines so the single-line diagnostic can never resemble a
42
+ // multi-line stack trace to the CLI-surface guard.
43
+ const message = redactDiagnostic(rawMessage).replace(/\s*\n\s*/g, " ");
44
+ console.error(`[conductor:INTERNAL_ERROR] [${name}] ${message}`);
45
+ }
46
+ catch {
47
+ /* diagnostic logging must never throw */
48
+ }
49
+ }
13
50
  /** Raised when caller input violates a conductor validation rule. */
14
51
  export class ConductorValidationError extends Error {
15
52
  constructor(message) {
@@ -89,6 +126,10 @@ export function toConductorErrorEnvelope(error) {
89
126
  message: "Conductor ledger is busy; retry shortly.",
90
127
  };
91
128
  }
129
+ // Unexpected error: log the redacted class + message + stack to backend logs so
130
+ // the real cause is diagnosable, then return the fixed generic client envelope
131
+ // (no trace context, no secret material) to the MCP/CLI caller.
132
+ logInternalErrorDiagnostic(error);
92
133
  return {
93
134
  error: "INTERNAL_ERROR",
94
135
  status: 500,
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Canonical conductor event payload model + typed, fail-closed read accessors
3
+ * (BAPI-493).
4
+ *
5
+ * The stored ledger envelope is deliberately loose (`ConductorEventData.details?:
6
+ * unknown`), so every consumer historically re-parsed `data.details` by hand and
7
+ * producers/consumers silently drifted on field location and nesting. This module
8
+ * makes the read side type-enforced:
9
+ *
10
+ * - {@link EventDetailsByType} maps EVERY {@link SemanticEventType} to an explicit
11
+ * details payload shape (details-less families get a conservative empty payload),
12
+ * with `never`-based exhaustiveness guards that fail the TypeScript build if a
13
+ * new semantic type is added without a payload member.
14
+ * - {@link getEventDetails} is discriminant-constrained (E-27): it validates
15
+ * `event.type === expectedType` at RUNTIME before narrowing and returns `null`
16
+ * on mismatch, so a caller can never obtain a payload for the wrong type.
17
+ * - {@link getHeadSha} / {@link getMergeIdentity} are the behavior-critical
18
+ * accessors that fail closed (a head/identity that cannot be proven is never
19
+ * treated as valid), matching the pre-refactor `extractEventHeadSha` /
20
+ * `extractMergeActionIdentityFromGateEvent` behavior byte-for-byte.
21
+ *
22
+ * This is READ-SIDE typing only: the stored `data_json` envelope shape is unchanged
23
+ * and there is NO ledger migration. The module depends only on lower-level,
24
+ * dependency-light modules (`taxonomy`, `types`, `git-ci-types`, `merge-identity`)
25
+ * so it can be imported by both `epic-state.ts` and `merge-ledger.ts` without a
26
+ * cycle. It is NEVER applied to backend HTTP response envelopes (which use a
27
+ * `detail` wrapper, not `data.details`) — those keep their own normalizers.
28
+ */
29
+ import { normalizePrNumber, normalizeRepoName, normalizeSha, } from "./git-ci-types.js";
30
+ import { buildGateIdentity, makeMergeActionKey, } from "./merge-identity.js";
31
+ // Re-export the merge identity primitives so downstream consumers can import the
32
+ // whole merge-identity surface from a single typed-accessor entry point.
33
+ export { buildGateIdentity, makeMergeActionKey };
34
+ // ---------------------------------------------------------------------------
35
+ // Small structural helpers
36
+ // ---------------------------------------------------------------------------
37
+ /** Narrow an unknown value to a plain, indexable object. */
38
+ function isPlainObject(value) {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+ /** Runtime exhaustiveness guard for switch/dispatch fall-through. */
42
+ export function assertNever(value) {
43
+ throw new Error(`Unexpected value: ${String(value)}`);
44
+ }
45
+ /**
46
+ * Normalize a PR head SHA read from a conductor ledger event's
47
+ * `data.details.head_sha`. Preserves the exact behavior of the pre-refactor
48
+ * `epic-state.extractEventHeadSha`: accept only a trimmed 7–40 character
49
+ * hexadecimal string and return it lowercased; every other shape yields `null`.
50
+ * Deliberately looser than {@link normalizeSha} (which requires a full 40/64-char
51
+ * object id) because head observations may carry an abbreviated SHA.
52
+ */
53
+ export function normalizeEventHeadSha(value) {
54
+ if (typeof value !== "string")
55
+ return null;
56
+ const trimmed = value.trim();
57
+ if (!/^[0-9a-f]{7,40}$/i.test(trimmed))
58
+ return null;
59
+ return trimmed.toLowerCase();
60
+ }
61
+ /** Read ONLY `event.data.details` as a plain object, or `null`. */
62
+ function getRawEventDetails(event) {
63
+ const details = event.data?.details;
64
+ return isPlainObject(details) ? details : null;
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Per-type parsers
68
+ // ---------------------------------------------------------------------------
69
+ /** The conservative empty payload shared by all details-less families. */
70
+ const EMPTY_DETAILS = Object.freeze({});
71
+ /** Parse a head-observation payload: read only `details.head_sha`, fail closed. */
72
+ function parseHeadObservation(event) {
73
+ const details = getRawEventDetails(event);
74
+ return { head_sha: details ? normalizeEventHeadSha(details.head_sha) : null };
75
+ }
76
+ /** Parse a `merge.*` lifecycle payload: `action_key` string or null. */
77
+ function parseMergeLifecycle(event) {
78
+ const details = getRawEventDetails(event);
79
+ const actionKey = details && typeof details.action_key === "string" && details.action_key.trim().length > 0
80
+ ? details.action_key.trim()
81
+ : null;
82
+ return { action_key: actionKey };
83
+ }
84
+ /**
85
+ * Parse the canonical `gate.met` payload. `head_sha`/`repo`/`pr_number` use the
86
+ * STRICT normalizers (a merge binding requires a full SHA); `required_checks`
87
+ * preserves the top-level → nested `ci_check_status.required_checks` dual path.
88
+ */
89
+ function parseGateMet(event) {
90
+ const details = getRawEventDetails(event);
91
+ if (!details) {
92
+ return {
93
+ head_sha: null,
94
+ repo: null,
95
+ pr_number: null,
96
+ gate_name: null,
97
+ config_hash: null,
98
+ required_checks: [],
99
+ };
100
+ }
101
+ const gateName = typeof details.gate_name === "string" && details.gate_name.trim().length > 0
102
+ ? details.gate_name.trim()
103
+ : null;
104
+ const configHash = typeof details.config_hash === "string" && details.config_hash.trim().length > 0
105
+ ? details.config_hash.trim()
106
+ : null;
107
+ const ciCheckStatus = isPlainObject(details.ci_check_status) ? details.ci_check_status : null;
108
+ const rawRequiredChecks = Array.isArray(details.required_checks)
109
+ ? details.required_checks
110
+ : ciCheckStatus && Array.isArray(ciCheckStatus.required_checks)
111
+ ? ciCheckStatus.required_checks
112
+ : [];
113
+ const requiredChecks = rawRequiredChecks.filter((c) => typeof c === "string" && c.trim().length > 0);
114
+ return {
115
+ head_sha: normalizeSha(details.head_sha),
116
+ repo: normalizeRepoName(details.repo),
117
+ pr_number: normalizePrNumber(details.pr_number),
118
+ gate_name: gateName,
119
+ config_hash: configHash,
120
+ required_checks: requiredChecks,
121
+ };
122
+ }
123
+ /** Parse a spec-review verdict payload (conservative head read). */
124
+ function parseSpecReview(event) {
125
+ const details = getRawEventDetails(event);
126
+ return { head_sha: details ? normalizeEventHeadSha(details.head_sha) : null };
127
+ }
128
+ /** Return the shared conservative empty payload for details-less families. */
129
+ function parseEmpty() {
130
+ return EMPTY_DETAILS;
131
+ }
132
+ /**
133
+ * Exhaustive parser table keyed by every {@link SemanticEventType}. There is no
134
+ * catch-all default branch that returns raw `data.details` for an unrecognized
135
+ * type — a missing key breaks the `satisfies` check and fails the build.
136
+ */
137
+ const EVENT_PARSERS = {
138
+ "run.started": parseEmpty,
139
+ "run.heartbeat": parseEmpty,
140
+ "run.stopped": parseEmpty,
141
+ "agent.notification": parseEmpty,
142
+ "tool.intent": parseEmpty,
143
+ "worktree.changed": parseEmpty,
144
+ "git.commit_created": parseEmpty,
145
+ "git.pr_opened": parseHeadObservation,
146
+ "ci.passed": parseHeadObservation,
147
+ "ci.failed": parseHeadObservation,
148
+ "gate.met": parseGateMet,
149
+ "supervisor.assessment": parseEmpty,
150
+ "message.sent": parseEmpty,
151
+ "message.delivered": parseEmpty,
152
+ "message.acked": parseEmpty,
153
+ "merge.dry_run": parseMergeLifecycle,
154
+ "merge.attempted": parseMergeLifecycle,
155
+ "merge.succeeded": parseHeadObservation,
156
+ "merge.failed": parseMergeLifecycle,
157
+ "merge.conflict": parseHeadObservation,
158
+ "merge.pending_approval": parseMergeLifecycle,
159
+ "review.passed": parseHeadObservation,
160
+ "review.changes_requested": parseHeadObservation,
161
+ "spec_review.passed": parseSpecReview,
162
+ "spec_review.changes_requested": parseSpecReview,
163
+ "parse.triggered": parseEmpty,
164
+ };
165
+ // ---------------------------------------------------------------------------
166
+ // Public accessors
167
+ // ---------------------------------------------------------------------------
168
+ /**
169
+ * Typed, discriminant-constrained details accessor (E-27). Returns the parsed
170
+ * payload for `expectedType` ONLY when `event.type === expectedType` at runtime;
171
+ * returns `null` on any discriminant mismatch so a caller can never obtain a
172
+ * `PayloadFor<T>` by passing the wrong expected type. Details-less families return
173
+ * a conservative empty payload (never the raw `unknown` details).
174
+ */
175
+ export function getEventDetails(event, expectedType) {
176
+ if (event.type !== expectedType)
177
+ return null;
178
+ const parser = EVENT_PARSERS[expectedType];
179
+ return parser(event);
180
+ }
181
+ /**
182
+ * Read and normalize a PR head SHA from the CANONICAL `event.data.details.head_sha`
183
+ * path only. No fallback locations are consulted — a valid-looking head at
184
+ * `data.head_sha` or a nested snapshot is deliberately ignored. Returns a lowercased
185
+ * 7–40 char hex SHA, or `null` for missing/non-object details, non-string,
186
+ * malformed, or out-of-range values. Fail-closed: a head that cannot be proven is
187
+ * never treated as valid.
188
+ */
189
+ export function getHeadSha(event) {
190
+ const details = getRawEventDetails(event);
191
+ if (!details)
192
+ return null;
193
+ return normalizeEventHeadSha(details.head_sha);
194
+ }
195
+ /**
196
+ * Resolve the immutable {@link MergeActionIdentity} from a worker-scoped `gate.met`
197
+ * event. Returns `null` unless the event is a `gate.met` carrying a complete PR
198
+ * binding (repo, pr_number, head_sha, gate_name) and a non-empty `worker_id`.
199
+ * Run-level or incomplete events yield `null`. Preserves the historical
200
+ * `required_checks` dual path (top-level then nested `ci_check_status`) and never
201
+ * throws. Branch-name fields, if present, are ignored.
202
+ */
203
+ export function getMergeIdentity(event) {
204
+ if (event.type !== "gate.met")
205
+ return null;
206
+ // Worker scope is required — a run-level gate.met never binds a specific worker.
207
+ if (typeof event.worker_id !== "string" || event.worker_id.trim().length === 0) {
208
+ return null;
209
+ }
210
+ const details = getEventDetails(event, "gate.met");
211
+ if (details === null)
212
+ return null;
213
+ const { repo, pr_number: prNumber, head_sha: headSha, gate_name: gateName } = details;
214
+ if (repo === null || prNumber === null || headSha === null || gateName === null) {
215
+ return null;
216
+ }
217
+ const gateIdentity = buildGateIdentity(gateName, details.config_hash);
218
+ const actionKey = makeMergeActionKey(repo, prNumber, headSha, gateIdentity);
219
+ return {
220
+ repo,
221
+ pr_number: prNumber,
222
+ head_sha: headSha,
223
+ gate_name: gateName,
224
+ config_hash: details.config_hash,
225
+ required_checks: details.required_checks,
226
+ gate_identity: gateIdentity,
227
+ action_key: actionKey,
228
+ gate_event: {
229
+ id: typeof event.id === "string" ? event.id : undefined,
230
+ seq: typeof event.seq === "number" ? event.seq : undefined,
231
+ time: typeof event.time === "string" ? event.time : undefined,
232
+ },
233
+ };
234
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * BAPI-507 (N-2): warn-only worker file-scope guard.
3
+ *
4
+ * Compares a branch's changed files against the planner-declared touched-file
5
+ * set (delivered to the worker via `BAPI_CONDUCTOR_DECLARED_TOUCHED_FILES_JSON`)
6
+ * and warns when the diff strays outside that set. It is strictly warn-only /
7
+ * fail-open: it NEVER blocks PR creation, mutates the branch, or exits non-zero.
8
+ * A worker that implements a sibling's files (the N-2 over-reach observed in the
9
+ * BAPI-488 run) surfaces here as a bounded, secret-free warning rather than a
10
+ * silent clobber.
11
+ *
12
+ * Byte-identical false-positive protection: the guard analyzes ONLY the
13
+ * branch-local diff against `origin/main` (`git diff --name-only
14
+ * origin/main...HEAD`), which excludes files that are byte-identical to
15
+ * `origin/main`. So a legitimate `git merge origin/main` that carries
16
+ * sibling-owned files in branch history but does not change them at the tip
17
+ * produces no false positive.
18
+ *
19
+ * All subprocess calls are list-based `spawnSync` with `shell: false` — no shell
20
+ * string is ever constructed.
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+ /** Env var carrying the compact JSON array of declared repo-relative paths. */
24
+ export const DECLARED_TOUCHED_FILES_ENV = "BAPI_CONDUCTOR_DECLARED_TOUCHED_FILES_JSON";
25
+ /** Env var carrying the dispatched ticket key (used only to label warnings). */
26
+ export const FILE_SCOPE_GUARD_TICKET_KEY_ENV = "BAPI_CONDUCTOR_TICKET_KEY";
27
+ /** Base ref the branch diff is computed against. */
28
+ export const FILE_SCOPE_GUARD_BASE_REF = "origin/main";
29
+ /**
30
+ * Normalize a single declared/changed path to a repo-relative POSIX path, or
31
+ * `null` when it is unsafe/unusable. Rejects blank paths, POSIX- and
32
+ * Windows-absolute paths, and any parent traversal (`..`). Converts `\` → `/`
33
+ * and strips a single leading `./`. Never consults the filesystem.
34
+ */
35
+ export function normalizeRepoRelativePath(input) {
36
+ if (typeof input !== "string")
37
+ return null;
38
+ const trimmed = input.trim();
39
+ if (trimmed.length === 0)
40
+ return null;
41
+ // Reject absolute paths (POSIX `/…`, `\…`, or Windows drive `C:\…` / `C:/…`)
42
+ // BEFORE separator normalization so both separator styles are caught.
43
+ if (trimmed.startsWith("/") ||
44
+ trimmed.startsWith("\\") ||
45
+ /^[A-Za-z]:[\\/]/.test(trimmed)) {
46
+ return null;
47
+ }
48
+ let p = trimmed.replace(/\\/g, "/");
49
+ if (p.startsWith("./"))
50
+ p = p.slice(2);
51
+ const segments = p.split("/");
52
+ if (segments.some((s) => s === ".."))
53
+ return null;
54
+ const cleaned = segments.filter((s) => s !== "" && s !== ".").join("/");
55
+ return cleaned.length > 0 ? cleaned : null;
56
+ }
57
+ /**
58
+ * Normalize a raw list of declared paths: drop non-strings and unsafe paths,
59
+ * dedupe, and sort. Anything that is not an array yields an empty list. This is
60
+ * the single source of truth for the declared-file normalization contract shared
61
+ * by the env producer (`buildEpicIdentityEnv`), the runtime plan mapper, and the
62
+ * guard's env parser.
63
+ */
64
+ export function normalizeDeclaredTouchedFiles(list) {
65
+ if (!Array.isArray(list))
66
+ return [];
67
+ const out = new Set();
68
+ for (const item of list) {
69
+ const norm = normalizeRepoRelativePath(item);
70
+ if (norm)
71
+ out.add(norm);
72
+ }
73
+ return Array.from(out).sort();
74
+ }
75
+ /**
76
+ * Parse `BAPI_CONDUCTOR_DECLARED_TOUCHED_FILES_JSON` into a normalized
77
+ * declaration. Returns an "unspecified" result on a missing, blank, invalid-JSON,
78
+ * non-array, or empty (after normalization) value — the guard no-ops in every
79
+ * such case.
80
+ */
81
+ export function parseDeclaredTouchedFilesFromEnv(env = process.env) {
82
+ const raw = env[DECLARED_TOUCHED_FILES_ENV];
83
+ if (typeof raw !== "string" || raw.trim().length === 0) {
84
+ return { specified: false };
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(raw);
89
+ }
90
+ catch {
91
+ return { specified: false };
92
+ }
93
+ if (!Array.isArray(parsed))
94
+ return { specified: false };
95
+ const files = normalizeDeclaredTouchedFiles(parsed);
96
+ if (files.length === 0)
97
+ return { specified: false };
98
+ return { specified: true, files };
99
+ }
100
+ const defaultSpawnSync = (command, args, options) => spawnSync(command, args, options);
101
+ /**
102
+ * Collect the files changed on the current branch relative to `origin/main`
103
+ * using a list-based `git diff --name-only origin/main...HEAD` invocation with
104
+ * `shell: false`. Byte-identical files (unchanged at the branch tip) are excluded
105
+ * by git itself. Any spawn error / non-zero exit yields `{ ok: false }` so the
106
+ * caller can fail open. Never spawns a shell.
107
+ */
108
+ export function collectBranchChangedFiles(opts = {}) {
109
+ const baseRef = opts.baseRef ?? FILE_SCOPE_GUARD_BASE_REF;
110
+ const spawn = opts.spawnSyncFn ?? defaultSpawnSync;
111
+ let result;
112
+ try {
113
+ result = spawn("git", ["diff", "--name-only", `${baseRef}...HEAD`], {
114
+ cwd: opts.cwd,
115
+ encoding: "utf-8",
116
+ shell: false,
117
+ });
118
+ }
119
+ catch {
120
+ return { ok: false, files: [] };
121
+ }
122
+ if (result.error || result.status !== 0) {
123
+ return { ok: false, files: [] };
124
+ }
125
+ const stdout = typeof result.stdout === "string"
126
+ ? result.stdout
127
+ : result.stdout?.toString("utf-8") ?? "";
128
+ const files = [];
129
+ const seen = new Set();
130
+ for (const line of stdout.split("\n")) {
131
+ const norm = normalizeRepoRelativePath(line);
132
+ if (norm && !seen.has(norm)) {
133
+ seen.add(norm);
134
+ files.push(norm);
135
+ }
136
+ }
137
+ return { ok: true, files };
138
+ }
139
+ /**
140
+ * Compare `changedFiles` against a declaration using EXACT-PATH matching (v1: no
141
+ * directory-ownership inference). Returns `checked:false`/no warning when the
142
+ * declaration is unspecified. Otherwise flags every changed file not in the
143
+ * declared set as out-of-scope and builds a bounded, secret-free warning naming
144
+ * the ticket, the out-of-scope files, and the declared set size.
145
+ */
146
+ export function analyzeDiffScope(input) {
147
+ if (!input.declared.specified) {
148
+ return { checked: false, outOfScopeFiles: [], warning: null };
149
+ }
150
+ const declaredSet = new Set(input.declared.files);
151
+ const outOfScope = input.changedFiles
152
+ .filter((f) => !declaredSet.has(f))
153
+ .sort();
154
+ if (outOfScope.length === 0) {
155
+ return { checked: true, outOfScopeFiles: [], warning: null };
156
+ }
157
+ const ticket = input.ticketKey && input.ticketKey.trim().length > 0
158
+ ? input.ticketKey.trim()
159
+ : "unknown-ticket";
160
+ const warning = `[file-scope-guard] ${ticket}: ${outOfScope.length} file(s) changed outside the ` +
161
+ `declared touched-file set (${input.declared.files.length} declared): ` +
162
+ `${outOfScope.join(", ")}. Warn-only — PR creation continues.`;
163
+ return { checked: true, outOfScopeFiles: outOfScope, warning };
164
+ }
165
+ /**
166
+ * CLI entrypoint: parse the declaration from the env, collect the branch diff,
167
+ * analyze scope, and print a warning for an out-of-scope diff. ALWAYS returns
168
+ * exit code `0` — an unspecified declaration, a git failure, or an out-of-scope
169
+ * diff all leave PR creation unblocked (warn-only / fail-open). Output is bounded
170
+ * and secret-free; the scope warning goes to stdout (explicit CLI-mode output),
171
+ * the fail-open diagnostic to stderr.
172
+ */
173
+ export function runFileScopeGuardCli(deps = {}) {
174
+ const env = deps.env ?? process.env;
175
+ const writeOut = deps.writeOut ?? ((m) => process.stdout.write(`${m}\n`));
176
+ const writeErr = deps.writeErr ?? ((m) => process.stderr.write(`${m}\n`));
177
+ const ticketKey = env[FILE_SCOPE_GUARD_TICKET_KEY_ENV];
178
+ const declared = parseDeclaredTouchedFilesFromEnv(env);
179
+ if (!declared.specified) {
180
+ // No/blank/invalid/empty declaration → nothing to check (fail-open no-op).
181
+ return 0;
182
+ }
183
+ const collected = collectBranchChangedFiles({
184
+ cwd: deps.cwd,
185
+ spawnSyncFn: deps.spawnSyncFn,
186
+ });
187
+ if (!collected.ok) {
188
+ writeErr(`[file-scope-guard] ${ticketKey ?? "unknown-ticket"}: unable to check file ` +
189
+ `scope (git diff failed); continuing — PR creation is not blocked.`);
190
+ return 0;
191
+ }
192
+ const analysis = analyzeDiffScope({
193
+ ticketKey,
194
+ declared,
195
+ changedFiles: collected.files,
196
+ });
197
+ if (analysis.warning) {
198
+ writeOut(analysis.warning);
199
+ }
200
+ return 0;
201
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared GitHub PR mergeability normalization + conflict classification (BAPI-494).
3
+ *
4
+ * The conductor gained ZERO mergeability awareness before this ticket: a sibling
5
+ * PR left `CONFLICTING`/`DIRTY` by an overlapping merge would sit at
6
+ * `ready_for_review` forever (catalog A4 / run-report F3). This module is the
7
+ * single, dependency-light source of truth for reading GitHub's
8
+ * `mergeable`/`mergeStateStatus` fields and deciding whether a PR is un-mergeable,
9
+ * so PR discovery, the done-gate binding resolution, and the local merge executor
10
+ * all classify a conflict the SAME way.
11
+ *
12
+ * Every helper is PURE, NON-THROWING, and FAIL-CLOSED: an unknown/missing/malformed
13
+ * mergeability value is treated as non-conflicting/unknown, never as a conflict, and
14
+ * raw `gh` process output is only ever matched against sanitized text — it is never
15
+ * returned, logged, or copied into event data.
16
+ */
17
+ /**
18
+ * Normalize a raw GitHub mergeability field. Returns a trimmed, uppercased string
19
+ * for a non-empty string value, and `null` for every other shape (missing, empty,
20
+ * whitespace-only, non-string). Never throws.
21
+ */
22
+ export function normalizeGhMergeabilityValue(value) {
23
+ if (typeof value !== "string")
24
+ return null;
25
+ const trimmed = value.trim();
26
+ if (trimmed.length === 0)
27
+ return null;
28
+ return trimmed.toUpperCase();
29
+ }
30
+ /**
31
+ * Read `mergeable` and `mergeStateStatus` from an already-parsed `gh pr view`
32
+ * JSON record and return a normalized {@link GhPrMergeability}. Unknown/malformed
33
+ * fields become `null` (fail-closed) so a valid PR with an unreadable mergeability
34
+ * value is never mistaken for a conflict.
35
+ */
36
+ export function parseGhPrMergeabilityFields(record) {
37
+ return {
38
+ mergeable: normalizeGhMergeabilityValue(record.mergeable),
39
+ mergeStateStatus: normalizeGhMergeabilityValue(record.mergeStateStatus),
40
+ };
41
+ }
42
+ /**
43
+ * True only for the two authoritative conflict signals GitHub exposes:
44
+ * `mergeable === "CONFLICTING"` or `mergeStateStatus === "DIRTY"`. Every other
45
+ * combination — mergeable/clean, unknown, or null — is NOT a conflict. Fail-closed.
46
+ */
47
+ export function isPrMergeConflict(mergeability) {
48
+ return mergeability.mergeable === "CONFLICTING" || mergeability.mergeStateStatus === "DIRTY";
49
+ }
50
+ /**
51
+ * Sanitized signatures that a `gh pr merge` failure was caused by a merge
52
+ * conflict / non-fast-forward condition (rather than an auth, rate-limit, network,
53
+ * or not-found failure). Matched case-insensitively against the sanitized failure
54
+ * text; the raw output itself is never returned or logged.
55
+ */
56
+ const MERGE_CONFLICT_OUTPUT_SIGNATURES = [
57
+ "merge conflict",
58
+ "merge conflicts",
59
+ "conflicting",
60
+ "conflict with the base branch",
61
+ "not possible to fast-forward",
62
+ "non-fast-forward",
63
+ "not mergeable",
64
+ "is not mergeable",
65
+ "cannot be cleanly created",
66
+ "would create a merge conflict",
67
+ ];
68
+ /**
69
+ * Classify raw `gh pr merge` failure output as a LIKELY merge-conflict /
70
+ * non-fast-forward failure. Reads only `stdout`/`stderr` text for defensive
71
+ * substring matching and returns a boolean — it never returns, logs, or otherwise
72
+ * exposes the raw process output. Returns `false` for unrelated failures
73
+ * (authentication, rate limit, network timeout, repository not found, generic
74
+ * GraphQL errors).
75
+ */
76
+ export function isLikelyGhMergeConflictOutput(output) {
77
+ if (!output)
78
+ return false;
79
+ const stdout = typeof output.stdout === "string" ? output.stdout : "";
80
+ const stderr = typeof output.stderr === "string" ? output.stderr : "";
81
+ const haystack = `${stdout}\n${stderr}`.toLowerCase();
82
+ if (haystack.trim().length === 0)
83
+ return false;
84
+ return MERGE_CONFLICT_OUTPUT_SIGNATURES.some((sig) => haystack.includes(sig));
85
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { spawnSync } from "child_process";
20
20
  import { pollCiChecksForCommit, } from "./bridge-api-client.js";
21
+ import { isLikelyGhMergeConflictOutput, isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
21
22
  const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
22
23
  /**
23
24
  * Hard wall-clock cap on every `gh` subprocess. The epic-tick runs in a single
@@ -180,7 +181,52 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
180
181
  // 4. Provider merge.
181
182
  const merge = run("gh", ["pr", "merge", String(pr), `--${method}`, "--match-head-commit", expectedSha], ghEnv);
182
183
  if (merge.status !== 0) {
183
- const mergeFailReason = merge.timedOut ? "gh_merge_timeout" : "gh_merge_failed";
184
+ // A hung `gh` (killed by the wall-clock timeout) is operationally distinct
185
+ // from a permission/conflict failure — keep it mapped to gh_merge_timeout.
186
+ if (merge.timedOut) {
187
+ const reason = "gh_merge_timeout";
188
+ return buildResponse(request, "failed", reason, false, [
189
+ { type: "merge.attempted", status: "attempted", details: baseDetails },
190
+ { type: "merge.failed", status: "failed", reason, details: baseDetails },
191
+ ]);
192
+ }
193
+ // BAPI-494: classify a conflict / non-fast-forward failure distinctly so it
194
+ // folds to `blocked` and remediates instead of hiding behind gh_merge_failed.
195
+ // First match the sanitized failure output; if inconclusive, do ONE follow-up
196
+ // mergeability re-read (only AFTER a merge failure — never before a successful
197
+ // or guarded merge) and check it. Raw stdout/stderr is used only for defensive
198
+ // classification and is never copied into event data or logs.
199
+ let mergeability = { mergeable: null, mergeStateStatus: null };
200
+ let isConflict = isLikelyGhMergeConflictOutput(merge);
201
+ if (!isConflict) {
202
+ const recheck = run("gh", ["pr", "view", String(pr), "--json", "mergeable,mergeStateStatus"], ghEnv);
203
+ if (recheck.status === 0) {
204
+ try {
205
+ mergeability = parseGhPrMergeabilityFields(JSON.parse(recheck.stdout));
206
+ isConflict = isPrMergeConflict(mergeability);
207
+ }
208
+ catch {
209
+ /* best-effort classification only */
210
+ }
211
+ }
212
+ }
213
+ if (isConflict) {
214
+ const reason = "gh_merge_conflict";
215
+ // Head-scoped to the attempted head SHA so the fold blocks the exact head; a
216
+ // later rebase clears it via the stale-head logic. mergeable/mergeStateStatus
217
+ // are included only when known.
218
+ const conflictDetails = {
219
+ ...baseDetails,
220
+ head_sha: expectedSha,
221
+ ...(mergeability.mergeable ? { mergeable: mergeability.mergeable } : {}),
222
+ ...(mergeability.mergeStateStatus ? { mergeStateStatus: mergeability.mergeStateStatus } : {}),
223
+ };
224
+ return buildResponse(request, "failed", reason, false, [
225
+ { type: "merge.attempted", status: "attempted", details: baseDetails },
226
+ { type: "merge.conflict", status: "failed", reason, details: conflictDetails },
227
+ ]);
228
+ }
229
+ const mergeFailReason = "gh_merge_failed";
184
230
  return buildResponse(request, "failed", mergeFailReason, false, [
185
231
  { type: "merge.attempted", status: "attempted", details: baseDetails },
186
232
  { type: "merge.failed", status: "failed", reason: mergeFailReason, details: baseDetails },
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Low-level merge-identity primitives (BAPI-493).
3
+ *
4
+ * Extracted from `merge-ledger.ts` so the typed event-accessor layer
5
+ * (`event-accessors.ts`) can build a canonical {@link MergeActionIdentity}
6
+ * without importing the merge-ledger store/emit surface (which would create an
7
+ * import cycle: merge-ledger → event-accessors → merge-ledger).
8
+ *
9
+ * This module depends ONLY on the pure boundary validators in `git-ci-types.ts`.
10
+ * It performs no I/O and holds no VCS write credentials. The deterministic action
11
+ * key `merge:{repo}:{pr}:{head_sha}:{gate}` is kept in lock-step with the Python
12
+ * `build_merge_action_key`; the gate-identity segment mirrors the Python
13
+ * `normalize_gate_identity`.
14
+ */
15
+ import { normalizePrNumber, normalizeRepoName, normalizeSha } from "./git-ci-types.js";
16
+ /**
17
+ * Compose the stable gate-identity segment, mirroring the Python
18
+ * `normalize_gate_identity`: `{name}@{config_hash}` when a hash is present
19
+ * (lower-cased), otherwise just the gate name.
20
+ */
21
+ export function buildGateIdentity(gateName, configHash) {
22
+ const name = gateName.trim();
23
+ const hash = typeof configHash === "string" ? configHash.trim() : "";
24
+ return hash ? `${name}@${hash.toLowerCase()}` : name;
25
+ }
26
+ /**
27
+ * Build the deterministic action key. Normalizes repo / PR / head SHA exactly as
28
+ * the Python side does (lower-cased SHA, trimmed repo, positive integer PR) so the
29
+ * conductor-computed key and the API-recomputed key are byte-identical. Throws on
30
+ * any invalid component. The branch name is never part of the key.
31
+ */
32
+ export function makeMergeActionKey(repo, prNumber, headSha, gateIdentity) {
33
+ const r = normalizeRepoName(repo);
34
+ const pr = normalizePrNumber(prNumber);
35
+ const sha = normalizeSha(headSha);
36
+ const gate = (gateIdentity ?? "").trim();
37
+ if (r === null || pr === null || sha === null || gate.length === 0) {
38
+ throw new Error("invalid merge action key component");
39
+ }
40
+ return `merge:${r}:${pr}:${sha}:${gate}`;
41
+ }