@bridge_gpt/mcp-server 0.2.16 → 0.2.19

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 (103) hide show
  1. package/CONDUCTOR.md +75 -0
  2. package/README.md +2 -2
  3. package/build/agent-capabilities/probe-context.js +13 -3
  4. package/build/agent-capabilities/probes.js +262 -11
  5. package/build/agent-capabilities/reporter.js +1 -0
  6. package/build/agents.generated.js +3 -3
  7. package/build/backend-warnings.js +44 -0
  8. package/build/claude-settings.js +129 -0
  9. package/build/commands.generated.js +7 -6
  10. package/build/conductor/bridge-api-client.js +198 -18
  11. package/build/conductor/claude-hook.js +22 -4
  12. package/build/conductor/cli.js +76 -25
  13. package/build/conductor/deny-enforcement-preflight.js +96 -0
  14. package/build/conductor/doctor.js +183 -2
  15. package/build/conductor/done-gate.js +5 -0
  16. package/build/conductor/epic-reconcile.js +71 -14
  17. package/build/conductor/epic-runtime.js +839 -67
  18. package/build/conductor/epic-state.js +524 -63
  19. package/build/conductor/errors.js +156 -3
  20. package/build/conductor/event-accessors.js +252 -0
  21. package/build/conductor/file-scope-guard.js +201 -0
  22. package/build/conductor/github-mergeability.js +85 -0
  23. package/build/conductor/local-merge.js +47 -1
  24. package/build/conductor/merge-identity.js +41 -0
  25. package/build/conductor/merge-ledger.js +19 -72
  26. package/build/conductor/plan.js +12 -2
  27. package/build/conductor/pr-ci-producer.js +17 -2
  28. package/build/conductor/pr-discovery.js +11 -1
  29. package/build/conductor/producer-ledger.js +1 -1
  30. package/build/conductor/store.js +161 -18
  31. package/build/conductor/supervisor-config.js +4 -39
  32. package/build/conductor/supervisor-escalation.js +10 -26
  33. package/build/conductor/supervisor-ledger.js +5 -12
  34. package/build/conductor/supervisor-merge.js +32 -5
  35. package/build/conductor/supervisor-message-relay.js +2 -5
  36. package/build/conductor/supervisor-notification.js +1 -1
  37. package/build/conductor/supervisor-runtime.js +12 -54
  38. package/build/conductor/supervisor-state.js +4 -18
  39. package/build/conductor/supervisor-types.js +2 -2
  40. package/build/conductor/taxonomy.js +12 -0
  41. package/build/conductor/tools.js +28 -6
  42. package/build/conductor/worker-ledger-cli.js +244 -0
  43. package/build/conductor-bin.js +1800 -5166
  44. package/build/conductor-claude-hook-bin.js +4 -2
  45. package/build/doctor.js +40 -0
  46. package/build/executor/cli.js +229 -0
  47. package/build/executor/credentials.js +65 -0
  48. package/build/executor/deps.js +117 -0
  49. package/build/executor/env.js +79 -0
  50. package/build/executor/heartbeat.js +59 -0
  51. package/build/executor/http-client.js +131 -0
  52. package/build/executor/index.js +10 -0
  53. package/build/executor/job-errors.js +55 -0
  54. package/build/executor/job-log-registry.js +110 -0
  55. package/build/executor/job-runner.js +688 -0
  56. package/build/executor/job-types.js +60 -0
  57. package/build/executor/merge-job.js +155 -0
  58. package/build/executor/observation.js +123 -0
  59. package/build/executor/permissions.js +79 -0
  60. package/build/executor/preflight.js +144 -0
  61. package/build/executor/process.js +81 -0
  62. package/build/executor/prompt-spec.js +235 -0
  63. package/build/executor/results.js +134 -0
  64. package/build/executor/resume-pre-spawn.js +179 -0
  65. package/build/executor/runner.js +98 -0
  66. package/build/executor/terminal-mutation.js +34 -0
  67. package/build/executor/test-clock.js +109 -0
  68. package/build/executor/types.js +18 -0
  69. package/build/executor/verdict-artifact.js +53 -0
  70. package/build/executor/viewer-tabs.js +78 -0
  71. package/build/executor/watch-cli.js +113 -0
  72. package/build/executor/worker-command.js +106 -0
  73. package/build/executor/worker-finalization.js +97 -0
  74. package/build/executor/worker-log.js +92 -0
  75. package/build/executor/worktree-gc.js +134 -0
  76. package/build/executor/worktree-inspection.js +86 -0
  77. package/build/executor/worktree.js +103 -0
  78. package/build/index.js +13950 -9669
  79. package/build/install-bridge.js +25 -8
  80. package/build/install-doctor.js +387 -0
  81. package/build/mcp-invoke.js +19 -3
  82. package/build/mcp-provisioning.js +31 -25
  83. package/build/mcp-registration-doctor.js +27 -7
  84. package/build/mcp-server-invocation.js +152 -0
  85. package/build/pipelines.generated.js +31 -6
  86. package/build/readme.generated.js +1 -1
  87. package/build/regression-check.js +53 -1
  88. package/build/review-tickets.js +175 -21
  89. package/build/sfcc/reads-site-preference.js +52 -19
  90. package/build/start-tickets-conductor.js +47 -99
  91. package/build/start-tickets-prereqs.js +185 -4
  92. package/build/start-tickets.js +218 -180
  93. package/build/version.generated.js +1 -1
  94. package/build/visual-diff-worker.js +313 -0
  95. package/build/visual-diff.js +632 -0
  96. package/build/worktree-core.js +202 -0
  97. package/package.json +10 -6
  98. package/pipelines/review-ticket.json +24 -2
  99. package/public/css/main.min.css +3311 -1
  100. package/public/css/main.min.css.map +1 -1
  101. package/public/js/main.min.js +7924 -1
  102. package/public/js/main.min.js.map +1 -1
  103. package/smoke-test/SMOKE-TEST.md +5 -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) {
@@ -24,6 +61,76 @@ export class ConductorStoreError extends Error {
24
61
  this.name = "ConductorStoreError";
25
62
  }
26
63
  }
64
+ /**
65
+ * Raised when the frozen v1 `conductor epic-tick` entrypoint is invoked
66
+ * (BAPI-534, Epic Conductor v2 slice 0). The v1 local reconciler/ledger tick is
67
+ * frozen: Epic Conductor v2 replaces it with a server-side reconciler plus the
68
+ * local executor (`mcp-server executor`). The message points operators at the v2
69
+ * path and is path-free / secret-free.
70
+ */
71
+ export class ConductorEpicTickV1FrozenError extends Error {
72
+ constructor() {
73
+ super("The v1 `conductor epic-tick` path is frozen (EPIC_TICK_V1_FROZEN). Epic " +
74
+ "Conductor v2 replaces the local epic tick with a server-side reconciler " +
75
+ "plus the local executor — run `mcp-server executor` instead.");
76
+ this.name = "ConductorEpicTickV1FrozenError";
77
+ }
78
+ }
79
+ /**
80
+ * Raised (BAPI-527) when the worker-side ledger subprocess boundary cannot run.
81
+ *
82
+ * Worker-facing ledger operations (`check_messages`, `wait_for_done_gate` event
83
+ * emission) MUST execute through `conductor-bin.js` under the captured conductor
84
+ * Node (`CONDUCTOR_NODE_PATH`) so the worker Node never loads the `better-sqlite3`
85
+ * native binary. When that runtime is missing or invalid, this error makes the
86
+ * failure LOUD and structured rather than silently falling back to the worker
87
+ * Node. The carried `reason`/`envVar` are the ONLY diagnostic surface — both are
88
+ * fixed, non-secret, path-free tokens. No caught error, path, argv, stdout,
89
+ * stderr, or credential value is ever attached.
90
+ */
91
+ export class ConductorLedgerSubprocessRuntimeError extends Error {
92
+ reason;
93
+ envVar;
94
+ constructor(reason, envVar) {
95
+ // Single-line, path-free, secret-free (the committed CLI-stderr invariant in
96
+ // security-regressions.test.ts requires it). Names only the reason token.
97
+ super(`Conductor worker ledger subprocess is unavailable (reason: ${reason}).`);
98
+ this.name = "ConductorLedgerSubprocessRuntimeError";
99
+ this.reason = reason;
100
+ this.envVar = envVar;
101
+ }
102
+ }
103
+ /**
104
+ * Structural guard for a native-module-load failure raised by the conductor
105
+ * store (BAPI-526). Detected structurally (by `name` + a valid `details` object)
106
+ * rather than by importing `ConductorNativeModuleLoadError` from `store.ts`, so
107
+ * `errors.ts` avoids a runtime import cycle with the store.
108
+ */
109
+ export function isConductorNativeModuleLoadError(error) {
110
+ if (!(error instanceof Error))
111
+ return false;
112
+ if (error.name !== "ConductorNativeModuleLoadError")
113
+ return false;
114
+ const details = error.details;
115
+ if (!details || typeof details !== "object" || Array.isArray(details))
116
+ return false;
117
+ const d = details;
118
+ return (typeof d.node_version === "string" &&
119
+ typeof d.node_modules_abi === "string" &&
120
+ typeof d.module === "string");
121
+ }
122
+ /**
123
+ * Copy ONLY the three allowlisted native-load detail fields. Never spreads
124
+ * arbitrary properties from the error, so a secret/path smuggled onto the error
125
+ * object cannot round-trip into the client envelope.
126
+ */
127
+ function nativeModuleDetailsEnvelope(details) {
128
+ return {
129
+ node_version: details.node_version,
130
+ node_modules_abi: details.node_modules_abi,
131
+ module: details.module,
132
+ };
133
+ }
27
134
  /**
28
135
  * Heuristically detect SQLite busy/locked failures so they can be surfaced as a
29
136
  * retryable 503 rather than an opaque 500. Matches the `better-sqlite3` error
@@ -64,11 +171,37 @@ function isSqliteBusyError(error) {
64
171
  * the raw error text, stack, and any secret material are discarded.
65
172
  */
66
173
  export function toConductorErrorEnvelope(error) {
174
+ // v1 epic-tick freeze (BAPI-534): the frozen entrypoint maps to a stable,
175
+ // named 400 pointing operators at Epic Conductor v2 (server-side reconciler +
176
+ // local executor). Checked first — it is a fail-loud guard, not a runtime fault.
177
+ if (error instanceof ConductorEpicTickV1FrozenError) {
178
+ return {
179
+ error: "EPIC_TICK_V1_FROZEN",
180
+ status: 400,
181
+ message: error.message,
182
+ };
183
+ }
184
+ // Native-module LOAD failure (BAPI-526): `better-sqlite3` is present but fails
185
+ // to load for this Node runtime (ABI/dlopen mismatch). Checked BEFORE the
186
+ // generic persistence-unavailable branch below (the native-load error subclasses
187
+ // it) so an ABI/dlopen failure surfaces as a distinct, machine-readable kind
188
+ // carrying node_version/ABI — never masked as INTERNAL_ERROR or the
189
+ // undifferentiated PERSISTENCE_UNAVAILABLE. `details` is a fixed three-field
190
+ // allowlist copied explicitly, never spread from the error object.
191
+ if (isConductorNativeModuleLoadError(error)) {
192
+ return {
193
+ error: "LEDGER_NATIVE_MODULE_LOAD_FAILED",
194
+ status: 500,
195
+ message: "Conductor ledger native module failed to load for this Node runtime.",
196
+ details: nativeModuleDetailsEnvelope(error.details),
197
+ };
198
+ }
67
199
  // Optional-native-binding degradation (BAPI-451): the conductor `store.ts`
68
200
  // throws ConductorPersistenceUnavailableError when `better-sqlite3` could not be
69
- // loaded. Detected by name (not instanceof) to avoid an import cycle with
70
- // store.ts. Surfaced as a retryable-shaped 503 with a fixed, secret-free message
71
- // so the caller learns persistence is unavailable instead of getting an opaque 500.
201
+ // loaded (or is not installed). Detected by name (not instanceof) to avoid an
202
+ // import cycle with store.ts. Surfaced as a retryable-shaped 503 with a fixed,
203
+ // secret-free message so the caller learns persistence is unavailable instead of
204
+ // getting an opaque 500.
72
205
  if (error instanceof Error && error.name === "ConductorPersistenceUnavailableError") {
73
206
  return {
74
207
  error: "PERSISTENCE_UNAVAILABLE",
@@ -76,6 +209,22 @@ export function toConductorErrorEnvelope(error) {
76
209
  message: "Conductor persistence is unavailable (the optional better-sqlite3 native module is not loaded). Conductor coordination features are disabled; core tools are unaffected.",
77
210
  };
78
211
  }
212
+ // Worker-ledger subprocess runtime failure (BAPI-527): CONDUCTOR_NODE_PATH /
213
+ // BAPI_CONDUCTOR_CLI_FILE is missing or invalid so the worker cannot reach the
214
+ // local ledger through the captured conductor Node. Surfaced as a retryable-
215
+ // shaped 503 with a fixed, secret-free message plus a small non-secret details
216
+ // bag (env_var + stable reason). NOTHING path/secret-bearing is echoed. This is
217
+ // distinct from LEDGER_NATIVE_MODULE_LOAD_FAILED / PERSISTENCE_UNAVAILABLE,
218
+ // which cover conductor-owned processes that still load better-sqlite3 directly.
219
+ if (error instanceof ConductorLedgerSubprocessRuntimeError) {
220
+ return {
221
+ error: "LEDGER_SUBPROCESS_RUNTIME_UNAVAILABLE",
222
+ status: 503,
223
+ message: "Conductor worker ledger subprocess is unavailable; the worker Node cannot reach the local ledger. " +
224
+ "The dispatched conductor Node runtime is missing or invalid — conductor worker coordination is degraded; core tools are unaffected.",
225
+ details: { env_var: error.envVar, reason: error.reason },
226
+ };
227
+ }
79
228
  if (error instanceof ConductorValidationError) {
80
229
  // Validation messages are conductor-authored and name the offending field,
81
230
  // never its value — but redact defensively so an adversarial/secret-bearing
@@ -89,6 +238,10 @@ export function toConductorErrorEnvelope(error) {
89
238
  message: "Conductor ledger is busy; retry shortly.",
90
239
  };
91
240
  }
241
+ // Unexpected error: log the redacted class + message + stack to backend logs so
242
+ // the real cause is diagnosable, then return the fixed generic client envelope
243
+ // (no trace context, no secret material) to the MCP/CLI caller.
244
+ logInternalErrorDiagnostic(error);
92
245
  return {
93
246
  error: "INTERNAL_ERROR",
94
247
  status: 500,
@@ -0,0 +1,252 @@
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
+ // BAPI-527: parse-after-merge terminal markers (details-less typed payload).
165
+ "parse.succeeded": parseEmpty,
166
+ "parse.failed": parseEmpty,
167
+ };
168
+ // ---------------------------------------------------------------------------
169
+ // Public accessors
170
+ // ---------------------------------------------------------------------------
171
+ /**
172
+ * Typed, discriminant-constrained details accessor (E-27). Returns the parsed
173
+ * payload for `expectedType` ONLY when `event.type === expectedType` at runtime;
174
+ * returns `null` on any discriminant mismatch so a caller can never obtain a
175
+ * `PayloadFor<T>` by passing the wrong expected type. Details-less families return
176
+ * a conservative empty payload (never the raw `unknown` details).
177
+ */
178
+ export function getEventDetails(event, expectedType) {
179
+ if (event.type !== expectedType)
180
+ return null;
181
+ const parser = EVENT_PARSERS[expectedType];
182
+ return parser(event);
183
+ }
184
+ /**
185
+ * Read and normalize a PR head SHA from the CANONICAL `event.data.details.head_sha`
186
+ * path only. No fallback locations are consulted — a valid-looking head at
187
+ * `data.head_sha` or a nested snapshot is deliberately ignored. Returns a lowercased
188
+ * 7–40 char hex SHA, or `null` for missing/non-object details, non-string,
189
+ * malformed, or out-of-range values. Fail-closed: a head that cannot be proven is
190
+ * never treated as valid.
191
+ */
192
+ export function getHeadSha(event) {
193
+ const details = getRawEventDetails(event);
194
+ if (!details)
195
+ return null;
196
+ return normalizeEventHeadSha(details.head_sha);
197
+ }
198
+ /**
199
+ * Read and normalize a PR number from the CANONICAL `event.data.details.pr_number`
200
+ * path only (BAPI-525). Returns a positive integer PR number or `null` for
201
+ * missing/non-object details or a malformed value. Fail-closed. Used by the
202
+ * dispatched-backstop's local-first PR binding resolver to bind a ticket's PR from
203
+ * ledger events (e.g. `gate.met`) that carry both `pr_number` and `head_sha`,
204
+ * before falling back to a `gh` lookup.
205
+ */
206
+ export function getPrNumber(event) {
207
+ const details = getRawEventDetails(event);
208
+ if (!details)
209
+ return null;
210
+ return normalizePrNumber(details.pr_number);
211
+ }
212
+ /**
213
+ * Resolve the immutable {@link MergeActionIdentity} from a PR-bound `gate.met`
214
+ * event. The true merge identity is `(repo, pr_number, head_sha, gate_name)`;
215
+ * `worker_id` is attribution metadata only and is NOT required for identity
216
+ * extraction (BAPI-525 Change A). Returns `null` unless the event is a `gate.met`
217
+ * carrying a complete PR binding (repo, pr_number, head_sha, gate_name) — an
218
+ * incomplete PR binding remains fail-closed regardless of `worker_id`. A
219
+ * worker_id-less `gate.met` produced by the autonomous git-hook chain therefore
220
+ * yields the SAME non-null identity (and byte-identical `action_key`) as its
221
+ * worker-stamped twin. Preserves the historical `required_checks` dual path
222
+ * (top-level then nested `ci_check_status`) and never throws. Branch-name fields,
223
+ * if present, are ignored.
224
+ */
225
+ export function getMergeIdentity(event) {
226
+ if (event.type !== "gate.met")
227
+ return null;
228
+ const details = getEventDetails(event, "gate.met");
229
+ if (details === null)
230
+ return null;
231
+ const { repo, pr_number: prNumber, head_sha: headSha, gate_name: gateName } = details;
232
+ if (repo === null || prNumber === null || headSha === null || gateName === null) {
233
+ return null;
234
+ }
235
+ const gateIdentity = buildGateIdentity(gateName, details.config_hash);
236
+ const actionKey = makeMergeActionKey(repo, prNumber, headSha, gateIdentity);
237
+ return {
238
+ repo,
239
+ pr_number: prNumber,
240
+ head_sha: headSha,
241
+ gate_name: gateName,
242
+ config_hash: details.config_hash,
243
+ required_checks: details.required_checks,
244
+ gate_identity: gateIdentity,
245
+ action_key: actionKey,
246
+ gate_event: {
247
+ id: typeof event.id === "string" ? event.id : undefined,
248
+ seq: typeof event.seq === "number" ? event.seq : undefined,
249
+ time: typeof event.time === "string" ? event.time : undefined,
250
+ },
251
+ };
252
+ }
@@ -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
+ }