@bridge_gpt/mcp-server 0.2.62 → 0.2.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
@@ -0,0 +1,323 @@
1
+ /**
2
+ * BAPI-1333 — guided operator repairs: the displayed binding and its local
3
+ * recovery reference.
4
+ *
5
+ * ExplainRun renders a guided command with one `--binding v1.<base64url>` token.
6
+ * The token names the verb, the run, and the bounded subject the command was
7
+ * rendered against (a run repair revision plus its control, block, or policy
8
+ * subject; a scope episode; or a ticket version and head). This module:
9
+ *
10
+ * - decodes and strictly validates that token (identifiers only, closed keys);
11
+ * - turns it into the typed `guard` the server enforces in its guarded write;
12
+ * - persists a small local reference BEFORE the first POST — the request id, the
13
+ * binding, and digests of the binding and the payload — so a transport retry
14
+ * or a restarted CLI process re-sends the SAME request instead of re-reading
15
+ * the run and approving whatever subject exists now.
16
+ *
17
+ * The stored reference never holds a payload value: a policy patch or a driver
18
+ * context is represented only by its SHA-256 digest. Nothing here performs HTTP.
19
+ */
20
+ import { createHash, randomUUID } from "node:crypto";
21
+ import { canonicalSha256, clearRecoveryReferenceFile, readRecoveryReferenceFile, writeRecoveryReferenceFile, } from "./epic-child-continuation.js";
22
+ export const GUIDED_BINDING_TOKEN_PREFIX = "v1.";
23
+ export const GUIDED_REPAIR_REFERENCE_NAMESPACE = "epic-guided-repairs";
24
+ /** The fixed message when the reference cannot be stored; nothing is sent. */
25
+ export const GUIDED_REFERENCE_STORE_FAILED_MESSAGE = "could not store the local recovery reference, so the request was not sent";
26
+ /** A rerun of the same displayed command with a DIFFERENT payload. */
27
+ export const GUIDED_PAYLOAD_CONFLICT_MESSAGE = "a different request was already issued for this displayed command; nothing " +
28
+ "was sent. Re-read the run explanation before choosing another action";
29
+ export const GUIDED_REPAIR_VERBS = [
30
+ "resume-run",
31
+ "patch-policy",
32
+ "scope-reparse",
33
+ "scope-catch-up",
34
+ "dispatch-remediation",
35
+ ];
36
+ /** A malformed or mismatched binding. The message never echoes the token. */
37
+ export class GuidedBindingError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = "GuidedBindingError";
41
+ }
42
+ }
43
+ const TOKEN_RE = /^[a-z][a-z0-9_]{0,63}$/;
44
+ const SHA_RE = /^[0-9a-f]{40}$/;
45
+ const REQUEST_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
46
+ const FINGERPRINT_RE = /^[0-9a-f]{64}$/;
47
+ /** Keys each verb's token may carry, beyond `v`, `verb`, and `epic_run_id`. */
48
+ const ALLOWED_KEYS = {
49
+ "resume-run": [
50
+ "repair_revision",
51
+ "subject",
52
+ "control_state",
53
+ "control_generation",
54
+ "block_reason",
55
+ ],
56
+ "patch-policy": ["repair_revision", "subject"],
57
+ "scope-reparse": ["scope_id", "lifecycle_state", "required_commit_sha"],
58
+ "scope-catch-up": ["scope_id", "lifecycle_state", "required_commit_sha"],
59
+ "dispatch-remediation": ["ticket_key", "expected_row_version", "expected_head_sha"],
60
+ };
61
+ function invalid() {
62
+ return new GuidedBindingError("--binding is not a valid guided-repair binding; nothing was sent. Copy the " +
63
+ "command exactly as the run explanation rendered it.");
64
+ }
65
+ function nonNegativeInt(value) {
66
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
67
+ throw invalid();
68
+ return value;
69
+ }
70
+ function boundedString(value, max) {
71
+ if (typeof value !== "string" || value.length === 0 || value.length > max)
72
+ throw invalid();
73
+ return value;
74
+ }
75
+ function optionalSha(value) {
76
+ if (value === undefined || value === null)
77
+ return null;
78
+ if (typeof value !== "string" || !SHA_RE.test(value))
79
+ throw invalid();
80
+ return value;
81
+ }
82
+ function decodeBase64Url(text) {
83
+ if (!/^[A-Za-z0-9_-]+$/.test(text) || text.length > 2048)
84
+ throw invalid();
85
+ return Buffer.from(text, "base64url").toString("utf8");
86
+ }
87
+ /** Decode and strictly validate a `--binding` token. Throws {@link GuidedBindingError}. */
88
+ export function decodeGuidedBinding(token) {
89
+ if (typeof token !== "string" || !token.startsWith(GUIDED_BINDING_TOKEN_PREFIX)) {
90
+ throw invalid();
91
+ }
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(decodeBase64Url(token.slice(GUIDED_BINDING_TOKEN_PREFIX.length)));
95
+ }
96
+ catch (err) {
97
+ if (err instanceof GuidedBindingError)
98
+ throw err;
99
+ throw invalid();
100
+ }
101
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
102
+ throw invalid();
103
+ const raw = parsed;
104
+ if (raw.v !== 1)
105
+ throw invalid();
106
+ const verb = raw.verb;
107
+ if (typeof verb !== "string" || !GUIDED_REPAIR_VERBS.includes(verb)) {
108
+ throw invalid();
109
+ }
110
+ const guidedVerb = verb;
111
+ const allowed = new Set(["v", "verb", "epic_run_id", ...ALLOWED_KEYS[guidedVerb]]);
112
+ for (const key of Object.keys(raw)) {
113
+ if (!allowed.has(key))
114
+ throw invalid();
115
+ }
116
+ const binding = {
117
+ verb: guidedVerb,
118
+ epicRunId: boundedString(raw.epic_run_id, 200),
119
+ };
120
+ if (guidedVerb === "resume-run" || guidedVerb === "patch-policy") {
121
+ binding.repairRevision = nonNegativeInt(raw.repair_revision);
122
+ const subject = raw.subject;
123
+ if (guidedVerb === "patch-policy") {
124
+ if (subject !== "policy")
125
+ throw invalid();
126
+ binding.subject = "policy";
127
+ }
128
+ else if (subject === "control") {
129
+ if (raw.control_state !== "paused" && raw.control_state !== "quarantined")
130
+ throw invalid();
131
+ const generation = nonNegativeInt(raw.control_generation);
132
+ if (generation < 1 || raw.block_reason !== undefined)
133
+ throw invalid();
134
+ binding.subject = "control";
135
+ binding.controlState = raw.control_state;
136
+ binding.controlGeneration = generation;
137
+ }
138
+ else if (subject === "integration_block") {
139
+ const reason = raw.block_reason;
140
+ if (typeof reason !== "string" || !TOKEN_RE.test(reason))
141
+ throw invalid();
142
+ if (raw.control_state !== undefined || raw.control_generation !== undefined)
143
+ throw invalid();
144
+ binding.subject = "integration_block";
145
+ binding.blockReason = reason;
146
+ }
147
+ else {
148
+ throw invalid();
149
+ }
150
+ }
151
+ else if (guidedVerb === "dispatch-remediation") {
152
+ binding.ticketKey = boundedString(raw.ticket_key, 64);
153
+ binding.expectedRowVersion = nonNegativeInt(raw.expected_row_version);
154
+ binding.expectedHeadSha = optionalSha(raw.expected_head_sha);
155
+ }
156
+ else {
157
+ binding.scopeId = boundedString(raw.scope_id, 200);
158
+ const lifecycle = raw.lifecycle_state;
159
+ if (typeof lifecycle !== "string" || !TOKEN_RE.test(lifecycle))
160
+ throw invalid();
161
+ binding.lifecycleState = lifecycle;
162
+ binding.requiredCommitSha = optionalSha(raw.required_commit_sha);
163
+ }
164
+ return binding;
165
+ }
166
+ /** The canonical, snake_case identity of a binding (what its digest covers). */
167
+ export function bindingIdentity(binding) {
168
+ const out = { verb: binding.verb, epic_run_id: binding.epicRunId };
169
+ const pairs = [
170
+ ["repair_revision", binding.repairRevision],
171
+ ["subject", binding.subject],
172
+ ["control_state", binding.controlState],
173
+ ["control_generation", binding.controlGeneration],
174
+ ["block_reason", binding.blockReason],
175
+ ["scope_id", binding.scopeId],
176
+ ["lifecycle_state", binding.lifecycleState],
177
+ ["required_commit_sha", binding.requiredCommitSha],
178
+ ["ticket_key", binding.ticketKey],
179
+ ["expected_row_version", binding.expectedRowVersion],
180
+ ["expected_head_sha", binding.expectedHeadSha],
181
+ ];
182
+ for (const [key, value] of pairs) {
183
+ if (value !== undefined)
184
+ out[key] = value;
185
+ }
186
+ return out;
187
+ }
188
+ /** The typed body `guard` for this binding and request id. */
189
+ export function guardForBinding(binding, requestId) {
190
+ if (binding.verb === "resume-run" || binding.verb === "patch-policy") {
191
+ const guard = {
192
+ request_id: requestId,
193
+ epic_run_id: binding.epicRunId,
194
+ repair_revision: binding.repairRevision ?? 0,
195
+ subject: binding.subject ?? "policy",
196
+ };
197
+ if (binding.controlState !== undefined)
198
+ guard.control_state = binding.controlState;
199
+ if (binding.controlGeneration !== undefined)
200
+ guard.control_generation = binding.controlGeneration;
201
+ if (binding.blockReason !== undefined)
202
+ guard.block_reason = binding.blockReason;
203
+ return guard;
204
+ }
205
+ if (binding.verb === "dispatch-remediation") {
206
+ return {
207
+ request_id: requestId,
208
+ epic_run_id: binding.epicRunId,
209
+ expected_row_version: binding.expectedRowVersion ?? 0,
210
+ expected_head_sha: binding.expectedHeadSha ?? null,
211
+ };
212
+ }
213
+ return {
214
+ request_id: requestId,
215
+ epic_run_id: binding.epicRunId,
216
+ lifecycle_state: binding.lifecycleState ?? "",
217
+ required_commit_sha: binding.requiredCommitSha ?? null,
218
+ };
219
+ }
220
+ function stableStringify(value) {
221
+ if (Array.isArray(value))
222
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
223
+ if (value !== null && typeof value === "object") {
224
+ const record = value;
225
+ return `{${Object.keys(record)
226
+ .sort()
227
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
228
+ .join(",")}}`;
229
+ }
230
+ return JSON.stringify(value) ?? "null";
231
+ }
232
+ /**
233
+ * SHA-256 of a request payload in canonical form (sorted keys; any JSON number).
234
+ * Only this digest is persisted, so a patch or context never reaches the disk.
235
+ */
236
+ export function guidedPayloadDigest(payload) {
237
+ return createHash("sha256").update(stableStringify(payload), "utf8").digest("hex");
238
+ }
239
+ function referenceTarget(binding) {
240
+ return binding.ticketKey ?? binding.scopeId ?? "run";
241
+ }
242
+ function referenceKey(binding) {
243
+ return { epicRunId: binding.epicRunId, ticketKey: `${binding.verb}:${referenceTarget(binding)}` };
244
+ }
245
+ function referenceFingerprint(ref) {
246
+ return canonicalSha256({
247
+ v: 1,
248
+ verb: ref.verb,
249
+ epic_run_id: ref.epicRunId,
250
+ target: ref.target,
251
+ request_id: ref.requestId,
252
+ binding_digest: ref.bindingDigest,
253
+ payload_digest: ref.payloadDigest,
254
+ });
255
+ }
256
+ function parseReference(raw) {
257
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
258
+ return null;
259
+ const ref = raw;
260
+ if (ref.version !== 1)
261
+ return null;
262
+ if (typeof ref.verb !== "string" || !GUIDED_REPAIR_VERBS.includes(ref.verb)) {
263
+ return null;
264
+ }
265
+ for (const key of ["epicRunId", "target", "createdAt"]) {
266
+ if (typeof ref[key] !== "string" || ref[key].length === 0)
267
+ return null;
268
+ }
269
+ if (typeof ref.requestId !== "string" || !REQUEST_ID_RE.test(ref.requestId))
270
+ return null;
271
+ for (const key of ["bindingDigest", "payloadDigest", "fingerprint"]) {
272
+ if (typeof ref[key] !== "string" || !FINGERPRINT_RE.test(ref[key]))
273
+ return null;
274
+ }
275
+ const parsed = raw;
276
+ // A reference whose fingerprint no longer matches its own identity was
277
+ // tampered with or truncated; it never authorizes a re-send.
278
+ return referenceFingerprint(parsed) === parsed.fingerprint ? parsed : null;
279
+ }
280
+ /**
281
+ * Reuse the persisted request for this exact binding and payload, or mint and
282
+ * durably persist a new one BEFORE anything is sent.
283
+ *
284
+ * - Same binding, same payload → the SAME request id (transport retry, restart).
285
+ * - Same binding, different payload → refused locally; nothing is sent.
286
+ * - Different binding (a newer rendered command) → a new request replaces it.
287
+ */
288
+ export async function prepareGuidedRequest(binding, payloadDigest, deps = {}) {
289
+ const key = referenceKey(binding);
290
+ const bindingDigest = canonicalSha256(bindingIdentity(binding));
291
+ const existing = parseReference(await readRecoveryReferenceFile(GUIDED_REPAIR_REFERENCE_NAMESPACE, key, deps.store));
292
+ if (existing !== null && existing.bindingDigest === bindingDigest) {
293
+ if (existing.payloadDigest !== payloadDigest)
294
+ return { kind: "payload_conflict" };
295
+ return { kind: "ready", requestId: existing.requestId, referenceReused: true };
296
+ }
297
+ const requestId = (deps.newRequestId ?? randomUUID)();
298
+ const base = {
299
+ version: 1,
300
+ verb: binding.verb,
301
+ epicRunId: binding.epicRunId,
302
+ target: referenceTarget(binding),
303
+ requestId,
304
+ bindingDigest,
305
+ payloadDigest,
306
+ };
307
+ const reference = {
308
+ ...base,
309
+ createdAt: (deps.now ?? (() => new Date()))().toISOString(),
310
+ fingerprint: referenceFingerprint(base),
311
+ };
312
+ try {
313
+ await writeRecoveryReferenceFile(GUIDED_REPAIR_REFERENCE_NAMESPACE, key, reference, deps.store);
314
+ }
315
+ catch {
316
+ return { kind: "store_failed" };
317
+ }
318
+ return { kind: "ready", requestId, referenceReused: false };
319
+ }
320
+ /** Remove the reference once the server returned a definitive answer. Never throws. */
321
+ export async function clearGuidedReference(binding, deps = {}) {
322
+ await clearRecoveryReferenceFile(GUIDED_REPAIR_REFERENCE_NAMESPACE, referenceKey(binding), deps.store);
323
+ }
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Read-only qualification of the runtime a managed conductor Git hook launches
3
+ * (BAPI-1290).
4
+ *
5
+ * BAPI-772 made the hook installer refuse a binary that does not EXIST. That is
6
+ * not enough for a hook's lifetime: a hook embeds one absolute path and runs it on
7
+ * every commit for as long as the hook file lives. When that path sits inside the
8
+ * `npx` cache (`~/.npm/_npx/<hash>/…`), ordinary cache cleanup deletes it and every
9
+ * later commit silently delivers nothing — the hook body is detached, discards its
10
+ * output, and ends in `|| true` by design.
11
+ *
12
+ * This module answers one question for both installation and diagnostics, so the
13
+ * two can never disagree: "is this entry a stable, package-owned invocation that
14
+ * the hook's `node` can actually run, including the native ledger binding?" The
15
+ * answer is evidence-based:
16
+ *
17
+ * 1. the canonical entry is a regular `build/epic-implementer-bin.js`;
18
+ * 2. it is not inside a prunable `_npx` cache directory;
19
+ * 3. it lives in the supported persistent npm layout
20
+ * (`<prefix>/node_modules/@bridge_gpt/mcp-server/build/…`);
21
+ * 4. the adjacent `package.json` names this package, maps the
22
+ * `epic-implementer` bin to that entry, and carries a compatible version;
23
+ * 5. the platform is a qualified one (macOS or Linux);
24
+ * 6. a bounded subprocess — `node` from PATH, exactly what the hook runs —
25
+ * executes the entry's private runtime probe and reports a qualified Node
26
+ * line and a loadable `better-sqlite3`.
27
+ *
28
+ * Anything missing, malformed, or inconclusive is UNAVAILABLE, never healthy. The
29
+ * failure carries only a fixed classification and a fixed action sentence: no
30
+ * probed path, no subprocess stderr, no caught exception text.
31
+ *
32
+ * Deliberately NOT here: running npm, downloading or installing anything, opening
33
+ * or migrating the ledger, or starting a service. The one subprocess is the
34
+ * bounded runtime probe, which only imports the native binding.
35
+ */
36
+ import { spawnSync } from "node:child_process";
37
+ import { readFileSync, realpathSync, statSync } from "node:fs";
38
+ import { basename, dirname, join } from "node:path";
39
+ import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
40
+ import { isTransientNpxEntryPath } from "../mcp-server-invocation.js";
41
+ /** The bin name the package maps to the hook entry. */
42
+ export const HOOK_RUNTIME_BIN_NAME = "epic-implementer";
43
+ /** The entry file every managed hook invokes. */
44
+ export const HOOK_RUNTIME_ENTRY_FILENAME = "epic-implementer-bin.js";
45
+ /** The private CLI action the bounded runtime probe runs. Never advertised. */
46
+ export const HOOK_RUNTIME_PROBE_ACTION = "__hook-runtime-probe";
47
+ /** Marker the probe prints so a stray line on stdout can never be mistaken for evidence. */
48
+ export const HOOK_RUNTIME_PROBE_MARKER = "bapi-hook-runtime-probe/1";
49
+ /**
50
+ * Platforms whose hook shell grammar and persistent npm layout are qualified.
51
+ * Windows Git hooks run under a different shell/runtime and are unqualified.
52
+ */
53
+ export const HOOK_RUNTIME_QUALIFIED_PLATFORMS = ["darwin", "linux"];
54
+ /**
55
+ * The lowest Node major line accepted for durable hooks. Only even (LTS) majors
56
+ * at or above it qualify; odd "current" lines are unqualified because they have a
57
+ * short support window and a native ABI that changes under the operator.
58
+ */
59
+ export const HOOK_RUNTIME_MIN_NODE_MAJOR = 20;
60
+ /**
61
+ * Compatible package versions: the `0.x` line from `0.2.0` onward. Every such
62
+ * release ships the renamed `epic-implementer-bin.js` entry and the
63
+ * `git-hook post-commit` / `git-hook reference-transaction` grammar the managed
64
+ * hook invokes, so an in-place upgrade inside this range keeps the hook valid
65
+ * without rewriting it. A `1.x` release must be requalified before it is added.
66
+ */
67
+ export const HOOK_RUNTIME_COMPATIBLE_RANGE = ">=0.2.0 <1.0.0";
68
+ /** Bounded wall-clock for the runtime probe subprocess. */
69
+ export const HOOK_RUNTIME_PROBE_TIMEOUT_MS = 15_000;
70
+ /** Upper bound on probe output read back; anything larger is malformed. */
71
+ const HOOK_RUNTIME_PROBE_MAX_BUFFER = 16 * 1024;
72
+ /** Every classification, for exhaustive rendering and tests. */
73
+ export const HOOK_RUNTIME_CLASSIFICATIONS = [
74
+ "ephemeral-cache",
75
+ "missing",
76
+ "legacy-name",
77
+ "unqualified-runtime",
78
+ "node-unavailable",
79
+ "native-unavailable",
80
+ "unknown",
81
+ ];
82
+ /** Fixed, path-free operator action for each classification. */
83
+ export const HOOK_RUNTIME_ACTIONS = {
84
+ "ephemeral-cache": "the hook runtime is inside the npx cache, which cleanup can delete. Install the CLI " +
85
+ `persistently (\`npm i -g ${MCP_PACKAGE_NAME}\`) and re-run \`epic-implementer install-git-hooks\`.`,
86
+ missing: "the hook runtime entry is not present. Reinstall the CLI persistently " +
87
+ `(\`npm i -g ${MCP_PACKAGE_NAME}\`) and re-run \`epic-implementer install-git-hooks\`.`,
88
+ "legacy-name": "the hook invokes the retired conductor-bin.js entry. Re-run `epic-implementer install-git-hooks` " +
89
+ "from a persistent install.",
90
+ "unqualified-runtime": "the hook runtime is not a qualified persistent installation (supported: a persistent npm " +
91
+ `install of ${MCP_PACKAGE_NAME} ${HOOK_RUNTIME_COMPATIBLE_RANGE} on macOS or Linux with an even ` +
92
+ `Node LTS line >= ${HOOK_RUNTIME_MIN_NODE_MAJOR}). See mcp_server/docs/CONDUCTOR.md.`,
93
+ "node-unavailable": "`node` could not be launched from PATH, so the hook cannot run. Put a qualified Node on PATH " +
94
+ "and re-run `epic-implementer doctor`.",
95
+ "native-unavailable": "better-sqlite3 does not load for the hook's Node, so no event can be recorded. Reinstall the " +
96
+ `CLI (\`npm i -g ${MCP_PACKAGE_NAME}\`) with the Node on PATH and re-run \`epic-implementer doctor\`.`,
97
+ unknown: "the hook runtime could not be verified (a persistent install that predates the runtime probe " +
98
+ `also lands here). Upgrade it with \`npm i -g ${MCP_PACKAGE_NAME}@latest\`, then re-run ` +
99
+ "`epic-implementer doctor`.",
100
+ };
101
+ /** One sentence naming a classification and its action. Path-free by construction. */
102
+ export function describeHookRuntimeClassification(classification) {
103
+ return `${classification}: ${HOOK_RUNTIME_ACTIONS[classification]}`;
104
+ }
105
+ function unavailable(classification) {
106
+ return { usable: false, classification, reason: describeHookRuntimeClassification(classification) };
107
+ }
108
+ function defaultRealpath(filePath) {
109
+ try {
110
+ return realpathSync(filePath);
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ function defaultIsFile(filePath) {
117
+ try {
118
+ return statSync(filePath).isFile();
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ function defaultReadText(filePath) {
125
+ try {
126
+ return readFileSync(filePath, "utf-8");
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ /**
133
+ * Run `node <stableEntry> __hook-runtime-probe` with a short timeout and a small
134
+ * output cap. `node` is resolved from PATH on purpose: that is the exact runtime
135
+ * the hook body invokes, so the probe measures what a commit will actually run.
136
+ * stderr is discarded unread.
137
+ */
138
+ export function runHookRuntimeProbe(stableEntry) {
139
+ const result = spawnSync("node", [stableEntry, HOOK_RUNTIME_PROBE_ACTION], {
140
+ encoding: "utf-8",
141
+ timeout: HOOK_RUNTIME_PROBE_TIMEOUT_MS,
142
+ maxBuffer: HOOK_RUNTIME_PROBE_MAX_BUFFER,
143
+ stdio: ["ignore", "pipe", "ignore"],
144
+ windowsHide: true,
145
+ });
146
+ if (result.error) {
147
+ const code = result.error.code ?? null;
148
+ if (code === "ETIMEDOUT")
149
+ return { kind: "timeout" };
150
+ return { kind: "spawn-failed", code };
151
+ }
152
+ if (result.signal !== null)
153
+ return { kind: "timeout" };
154
+ if (result.status !== 0)
155
+ return { kind: "exit-nonzero" };
156
+ return { kind: "ok", stdout: result.stdout ?? "" };
157
+ }
158
+ /** Parse `major.minor.patch` (pre-release suffix ignored), or `null`. */
159
+ export function parseSemver(version) {
160
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
161
+ if (match === null)
162
+ return null;
163
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
164
+ }
165
+ /** True when `version` is inside {@link HOOK_RUNTIME_COMPATIBLE_RANGE}. */
166
+ export function isCompatibleHookPackageVersion(version) {
167
+ const parsed = parseSemver(version);
168
+ if (parsed === null)
169
+ return false;
170
+ const [major, minor] = parsed;
171
+ return major === 0 && minor >= 2;
172
+ }
173
+ /** True when `nodeVersion` is an even LTS major at or above the minimum. */
174
+ export function isQualifiedHookNodeVersion(nodeVersion) {
175
+ const parsed = parseSemver(nodeVersion);
176
+ if (parsed === null)
177
+ return false;
178
+ const [major] = parsed;
179
+ return major >= HOOK_RUNTIME_MIN_NODE_MAJOR && major % 2 === 0;
180
+ }
181
+ /**
182
+ * Locate the persistent package root for a canonical entry: the entry must be
183
+ * `<root>/build/epic-implementer-bin.js` and `<root>` must be
184
+ * `…/node_modules/@bridge_gpt/mcp-server`. Returns `null` for any other layout.
185
+ */
186
+ export function persistentPackageRootOf(canonicalEntry) {
187
+ if (basename(canonicalEntry) !== HOOK_RUNTIME_ENTRY_FILENAME)
188
+ return null;
189
+ const buildDir = dirname(canonicalEntry);
190
+ if (basename(buildDir) !== "build")
191
+ return null;
192
+ const packageRoot = dirname(buildDir);
193
+ const segments = packageRoot.split(/[\\/]/).filter((s) => s.length > 0);
194
+ const [scope, name] = MCP_PACKAGE_NAME.split("/");
195
+ const n = segments.length;
196
+ if (n < 3)
197
+ return null;
198
+ if (segments[n - 1] !== name || segments[n - 2] !== scope || segments[n - 3] !== "node_modules") {
199
+ return null;
200
+ }
201
+ // A `.bin` shim is never a package root, and neither is anything nested in one.
202
+ if (segments.includes(".bin"))
203
+ return null;
204
+ return packageRoot;
205
+ }
206
+ /** Parse and validate the probe's single JSON line, or `null` when malformed. */
207
+ export function parseHookRuntimeProbeOutput(stdout) {
208
+ const lines = stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
209
+ if (lines.length !== 1)
210
+ return null;
211
+ let parsed;
212
+ try {
213
+ parsed = JSON.parse(lines[0]);
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ if (parsed === null || typeof parsed !== "object")
219
+ return null;
220
+ const p = parsed;
221
+ if (p.probe !== HOOK_RUNTIME_PROBE_MARKER)
222
+ return null;
223
+ const str = (v) => typeof v === "string" && v.length > 0 && v.length <= 64;
224
+ if (!str(p.node_version) || !str(p.node_modules_abi) || !str(p.native_module) || !str(p.failure_kind)) {
225
+ return null;
226
+ }
227
+ if (typeof p.native_loads !== "boolean")
228
+ return null;
229
+ const nativeVersion = p.native_module_version === null || p.native_module_version === undefined
230
+ ? null
231
+ : str(p.native_module_version) ? p.native_module_version : null;
232
+ const packageVersion = str(p.package_version) ? p.package_version : null;
233
+ return {
234
+ node_version: p.node_version,
235
+ node_modules_abi: p.node_modules_abi,
236
+ native_module: p.native_module,
237
+ native_module_version: nativeVersion,
238
+ native_loads: p.native_loads,
239
+ failure_kind: p.failure_kind,
240
+ package_version: packageVersion,
241
+ };
242
+ }
243
+ /**
244
+ * Qualify a candidate hook entry. Read-only and never throws: every boundary
245
+ * failure maps to a fixed classification.
246
+ */
247
+ export function qualifyHookRuntime(candidateEntry, deps = {}) {
248
+ try {
249
+ return qualifyHookRuntimeUnsafe(candidateEntry, deps);
250
+ }
251
+ catch {
252
+ return unavailable("unknown");
253
+ }
254
+ }
255
+ function qualifyHookRuntimeUnsafe(candidateEntry, deps) {
256
+ const realpath = deps.realpath ?? defaultRealpath;
257
+ const isFile = deps.isFile ?? defaultIsFile;
258
+ const readText = deps.readText ?? defaultReadText;
259
+ const platform = deps.platform ?? process.platform;
260
+ const runProbe = deps.runProbe ?? runHookRuntimeProbe;
261
+ if (typeof candidateEntry !== "string" || candidateEntry.length === 0)
262
+ return unavailable("missing");
263
+ if (basename(candidateEntry) === "conductor-bin.js")
264
+ return unavailable("legacy-name");
265
+ if (isTransientNpxEntryPath(candidateEntry))
266
+ return unavailable("ephemeral-cache");
267
+ const canonical = realpath(candidateEntry);
268
+ if (canonical === null || !isFile(canonical))
269
+ return unavailable("missing");
270
+ // A symlink into the cache is still the cache.
271
+ if (isTransientNpxEntryPath(canonical))
272
+ return unavailable("ephemeral-cache");
273
+ if (basename(canonical) !== HOOK_RUNTIME_ENTRY_FILENAME)
274
+ return unavailable("unqualified-runtime");
275
+ const packageRoot = persistentPackageRootOf(canonical);
276
+ if (packageRoot === null)
277
+ return unavailable("unqualified-runtime");
278
+ const pkgText = readText(join(packageRoot, "package.json"));
279
+ if (pkgText === null)
280
+ return unavailable("missing");
281
+ let pkg;
282
+ try {
283
+ pkg = JSON.parse(pkgText);
284
+ }
285
+ catch {
286
+ return unavailable("unqualified-runtime");
287
+ }
288
+ if (pkg.name !== MCP_PACKAGE_NAME)
289
+ return unavailable("unqualified-runtime");
290
+ if (typeof pkg.version !== "string" || !isCompatibleHookPackageVersion(pkg.version)) {
291
+ return unavailable("unqualified-runtime");
292
+ }
293
+ const binMap = pkg.bin !== null && typeof pkg.bin === "object" ? pkg.bin : null;
294
+ const mapped = binMap?.[HOOK_RUNTIME_BIN_NAME];
295
+ if (typeof mapped !== "string")
296
+ return unavailable("unqualified-runtime");
297
+ const normalizedMapped = mapped.replace(/^\.\//, "").replace(/\\/g, "/");
298
+ if (normalizedMapped !== `build/${HOOK_RUNTIME_ENTRY_FILENAME}`)
299
+ return unavailable("unqualified-runtime");
300
+ if (!HOOK_RUNTIME_QUALIFIED_PLATFORMS.includes(platform))
301
+ return unavailable("unqualified-runtime");
302
+ // The stable launch path is the package-owned entry under the persistent
303
+ // prefix. An in-place `npm i -g` upgrade replaces the files beneath it, so a
304
+ // hook that embeds this path keeps working across supported upgrades.
305
+ const stableEntry = join(packageRoot, "build", HOOK_RUNTIME_ENTRY_FILENAME);
306
+ const outcome = runProbe(stableEntry);
307
+ if (outcome.kind === "spawn-failed") {
308
+ return unavailable(outcome.code === "ENOENT" ? "node-unavailable" : "unknown");
309
+ }
310
+ if (outcome.kind !== "ok")
311
+ return unavailable("unknown");
312
+ const evidence = parseHookRuntimeProbeOutput(outcome.stdout);
313
+ if (evidence === null)
314
+ return unavailable("unknown");
315
+ if (!evidence.native_loads)
316
+ return unavailable("native-unavailable");
317
+ if (!isQualifiedHookNodeVersion(evidence.node_version))
318
+ return unavailable("unqualified-runtime");
319
+ // The probe must be answering for the package we just read, not some other
320
+ // build the entry happens to import.
321
+ if (evidence.package_version !== null && evidence.package_version !== pkg.version) {
322
+ return unavailable("unknown");
323
+ }
324
+ return {
325
+ usable: true,
326
+ stable_entry: stableEntry,
327
+ canonical_entry: canonical,
328
+ package_name: MCP_PACKAGE_NAME,
329
+ package_version: pkg.version,
330
+ platform,
331
+ runtime: {
332
+ node_version: evidence.node_version,
333
+ node_modules_abi: evidence.node_modules_abi,
334
+ native_module: evidence.native_module,
335
+ native_module_version: evidence.native_module_version,
336
+ native_loads: evidence.native_loads,
337
+ failure_kind: evidence.failure_kind,
338
+ },
339
+ };
340
+ }