@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.
- package/CONDUCTOR.md +75 -0
- package/README.md +2 -2
- package/build/agent-capabilities/probe-context.js +13 -3
- package/build/agent-capabilities/probes.js +262 -11
- package/build/agent-capabilities/reporter.js +1 -0
- package/build/agents.generated.js +3 -3
- package/build/backend-warnings.js +44 -0
- package/build/claude-settings.js +129 -0
- package/build/commands.generated.js +7 -6
- package/build/conductor/bridge-api-client.js +198 -18
- package/build/conductor/claude-hook.js +22 -4
- package/build/conductor/cli.js +76 -25
- package/build/conductor/deny-enforcement-preflight.js +96 -0
- package/build/conductor/doctor.js +183 -2
- package/build/conductor/done-gate.js +5 -0
- package/build/conductor/epic-reconcile.js +71 -14
- package/build/conductor/epic-runtime.js +839 -67
- package/build/conductor/epic-state.js +524 -63
- package/build/conductor/errors.js +156 -3
- package/build/conductor/event-accessors.js +252 -0
- package/build/conductor/file-scope-guard.js +201 -0
- package/build/conductor/github-mergeability.js +85 -0
- package/build/conductor/local-merge.js +47 -1
- package/build/conductor/merge-identity.js +41 -0
- package/build/conductor/merge-ledger.js +19 -72
- package/build/conductor/plan.js +12 -2
- package/build/conductor/pr-ci-producer.js +17 -2
- package/build/conductor/pr-discovery.js +11 -1
- package/build/conductor/producer-ledger.js +1 -1
- package/build/conductor/store.js +161 -18
- package/build/conductor/supervisor-config.js +4 -39
- package/build/conductor/supervisor-escalation.js +10 -26
- package/build/conductor/supervisor-ledger.js +5 -12
- package/build/conductor/supervisor-merge.js +32 -5
- package/build/conductor/supervisor-message-relay.js +2 -5
- package/build/conductor/supervisor-notification.js +1 -1
- package/build/conductor/supervisor-runtime.js +12 -54
- package/build/conductor/supervisor-state.js +4 -18
- package/build/conductor/supervisor-types.js +2 -2
- package/build/conductor/taxonomy.js +12 -0
- package/build/conductor/tools.js +28 -6
- package/build/conductor/worker-ledger-cli.js +244 -0
- package/build/conductor-bin.js +1800 -5166
- package/build/conductor-claude-hook-bin.js +4 -2
- package/build/doctor.js +40 -0
- package/build/executor/cli.js +229 -0
- package/build/executor/credentials.js +65 -0
- package/build/executor/deps.js +117 -0
- package/build/executor/env.js +79 -0
- package/build/executor/heartbeat.js +59 -0
- package/build/executor/http-client.js +131 -0
- package/build/executor/index.js +10 -0
- package/build/executor/job-errors.js +55 -0
- package/build/executor/job-log-registry.js +110 -0
- package/build/executor/job-runner.js +688 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +155 -0
- package/build/executor/observation.js +123 -0
- package/build/executor/permissions.js +79 -0
- package/build/executor/preflight.js +144 -0
- package/build/executor/process.js +81 -0
- package/build/executor/prompt-spec.js +235 -0
- package/build/executor/results.js +134 -0
- package/build/executor/resume-pre-spawn.js +179 -0
- package/build/executor/runner.js +98 -0
- package/build/executor/terminal-mutation.js +34 -0
- package/build/executor/test-clock.js +109 -0
- package/build/executor/types.js +18 -0
- package/build/executor/verdict-artifact.js +53 -0
- package/build/executor/viewer-tabs.js +78 -0
- package/build/executor/watch-cli.js +113 -0
- package/build/executor/worker-command.js +106 -0
- package/build/executor/worker-finalization.js +97 -0
- package/build/executor/worker-log.js +92 -0
- package/build/executor/worktree-gc.js +134 -0
- package/build/executor/worktree-inspection.js +86 -0
- package/build/executor/worktree.js +103 -0
- package/build/index.js +13950 -9669
- package/build/install-bridge.js +25 -8
- package/build/install-doctor.js +387 -0
- package/build/mcp-invoke.js +19 -3
- package/build/mcp-provisioning.js +31 -25
- package/build/mcp-registration-doctor.js +27 -7
- package/build/mcp-server-invocation.js +152 -0
- package/build/pipelines.generated.js +31 -6
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +53 -1
- package/build/review-tickets.js +175 -21
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/start-tickets-conductor.js +47 -99
- package/build/start-tickets-prereqs.js +185 -4
- package/build/start-tickets.js +218 -180
- package/build/version.generated.js +1 -1
- package/build/visual-diff-worker.js +313 -0
- package/build/visual-diff.js +632 -0
- package/build/worktree-core.js +202 -0
- package/package.json +10 -6
- package/pipelines/review-ticket.json +24 -2
- package/public/css/main.min.css +3311 -1
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +7924 -1
- package/public/js/main.min.js.map +1 -1
- 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
|
|
70
|
-
// store.ts. Surfaced as a retryable-shaped 503 with a fixed,
|
|
71
|
-
// so the caller learns persistence is unavailable instead of
|
|
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
|
+
}
|