@bridge_gpt/mcp-server 0.2.62 → 0.2.65

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 (80) hide show
  1. package/README.md +47 -8
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +6 -5
  7. package/build/conductor/bridge-api-client.js +335 -22
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/readiness-sections.js +5 -4
  15. package/build/conductor/readiness.js +22 -11
  16. package/build/conductor/recovery-cli.js +354 -13
  17. package/build/conductor/recovery-operations.js +251 -5
  18. package/build/conductor/repair-contract.js +8 -0
  19. package/build/conductor-bundle-artifacts.js +1 -0
  20. package/build/credential-store.js +50 -12
  21. package/build/docs.generated.js +2 -1
  22. package/build/doctor.js +77 -11
  23. package/build/drive-epic-key.js +12 -0
  24. package/build/drive-epic.js +1211 -116
  25. package/build/epic-coherence-input-cli.js +137 -0
  26. package/build/epic-coherence-input.js +920 -0
  27. package/build/epic-coherence-input.test-fixtures.js +175 -0
  28. package/build/epic-implementer-bin.js +35 -29
  29. package/build/epic-implementer-bundle-cli.js +14 -1
  30. package/build/epic-launch-profile.js +14 -5
  31. package/build/epic-run-status.js +5 -0
  32. package/build/epic-shared/bridge-client.js +46 -4
  33. package/build/executor/cli.js +39 -0
  34. package/build/executor/completion-contract.js +423 -0
  35. package/build/executor/completion-supervision.js +286 -0
  36. package/build/executor/credentials.js +3 -1
  37. package/build/executor/deps.js +65 -1
  38. package/build/executor/env.js +6 -0
  39. package/build/executor/heartbeat.js +13 -3
  40. package/build/executor/http-client.js +76 -4
  41. package/build/executor/job-errors.js +48 -0
  42. package/build/executor/job-runner.js +1294 -26
  43. package/build/executor/job-types.js +10 -2
  44. package/build/executor/preflight.js +99 -0
  45. package/build/executor/process-containment.js +756 -0
  46. package/build/executor/process.js +235 -5
  47. package/build/executor/prompt-spec.js +262 -1
  48. package/build/executor/provider-checks.js +69 -0
  49. package/build/executor/rebase-finalization.js +351 -0
  50. package/build/executor/reconcile-overlap-job.js +77 -84
  51. package/build/executor/required-verification.js +313 -0
  52. package/build/executor/required-work-ledger.js +1121 -0
  53. package/build/executor/results.js +142 -0
  54. package/build/executor/resume-pre-spawn.js +87 -4
  55. package/build/executor/runner.js +145 -0
  56. package/build/executor/test-declaration.js +20 -0
  57. package/build/executor/types.js +12 -0
  58. package/build/executor/worker-command.js +49 -13
  59. package/build/executor/worker-finalization.js +88 -11
  60. package/build/executor/worker-guard-hook.js +29 -4
  61. package/build/executor/worktree.js +13 -0
  62. package/build/executor-launch.js +658 -0
  63. package/build/index.js +537 -943
  64. package/build/install-bridge-conductor.js +231 -41
  65. package/build/merge-pull-request.js +1 -1
  66. package/build/pipelines.generated.js +8 -7
  67. package/build/plan-discovery.js +137 -0
  68. package/build/plan-epic-implementer-eligibility.js +2 -2
  69. package/build/plane/cli.js +37 -3
  70. package/build/plane/member-logs.js +3 -0
  71. package/build/plane/member-roster.js +7 -1
  72. package/build/plane/shutdown.js +59 -1
  73. package/build/plane/supervisor.js +142 -0
  74. package/build/readme.generated.js +1 -1
  75. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  76. package/build/setup-epic.js +28 -17
  77. package/build/version.generated.js +3 -3
  78. package/build/worker-guard-hook-bin.js +2 -2
  79. package/docs/CONDUCTOR.md +116 -2
  80. package/package.json +2 -1
@@ -8,11 +8,25 @@
8
8
  * surrounding user hook content is preserved, and anything that looks binary or
9
9
  * unsafe to merge is left untouched with a warning. Missing hooks are a degraded
10
10
  * OPTIONAL capability — never a fatal error.
11
+ *
12
+ * BAPI-1290 adds LIFETIME to BAPI-772's existence check. A hook embeds one
13
+ * absolute entry path and runs it for as long as the hook file lives, so the
14
+ * installer now embeds only a QUALIFIED persistent package-owned entry (see
15
+ * `hook-runtime-qualification.ts`) and refuses a prunable `npx` cache path before
16
+ * touching anything. Both hook files are planned before either is published, each
17
+ * is published through a same-directory temporary file and an atomic rename, and a
18
+ * two-file update that stops half-way is reported as partial rather than complete.
11
19
  */
12
- import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs";
20
+ import { randomBytes } from "node:crypto";
21
+ import { spawnSync } from "node:child_process";
22
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
13
23
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
14
24
  import { fileURLToPath } from "node:url";
25
+ import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
26
+ import { isTransientNpxEntryPath } from "../mcp-server-invocation.js";
27
+ import { posixShellQuote } from "../platform-escaping.js";
15
28
  import { runGitCommand } from "./git-inspection.js";
29
+ import { describeHookRuntimeClassification, qualifyHookRuntime, } from "./hook-runtime-qualification.js";
16
30
  /** Markers delimiting the conductor-managed block inside a hook file. */
17
31
  export const BRIDGE_CONDUCTOR_HOOK_START = "# >>> BRIDGE_CONDUCTOR_HOOK_START >>>";
18
32
  export const BRIDGE_CONDUCTOR_HOOK_END = "# <<< BRIDGE_CONDUCTOR_HOOK_END <<<";
@@ -52,27 +66,55 @@ export const CONDUCTOR_HOOK_BIN_UNRESOLVED_REASON = `no usable ${CONDUCTOR_BIN_F
52
66
  "(searched: the sibling bundle layout, the parent TypeScript-emit layout, and " +
53
67
  `the invocation target when it is named ${CONDUCTOR_BIN_FILENAME}). ` +
54
68
  "Reinstall or rebuild the package so a conductor binary ships beside the executing module.";
69
+ /** Bounded wall-clock for the installation-time `npm root -g` lookup. */
70
+ export const NPM_GLOBAL_ROOT_TIMEOUT_MS = 10_000;
71
+ /**
72
+ * Default `npm root -g`: bounded, offline, output capped, stderr discarded.
73
+ * Returns `null` on any failure, timeout, or output that is not one absolute path.
74
+ */
75
+ export function defaultNpmGlobalRoot() {
76
+ if (process.platform === "win32")
77
+ return null;
78
+ try {
79
+ const result = spawnSync("npm", ["root", "-g"], {
80
+ encoding: "utf-8",
81
+ timeout: NPM_GLOBAL_ROOT_TIMEOUT_MS,
82
+ maxBuffer: 8 * 1024,
83
+ stdio: ["ignore", "pipe", "ignore"],
84
+ env: { ...process.env, npm_config_offline: "true", npm_config_update_notifier: "false" },
85
+ });
86
+ if (result.error || result.status !== 0)
87
+ return null;
88
+ const lines = (result.stdout ?? "").split(/\r?\n/).filter((l) => l.trim().length > 0);
89
+ if (lines.length !== 1)
90
+ return null;
91
+ const root = lines[0].trim();
92
+ return isAbsolute(root) ? root : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
55
98
  /**
56
- * Absolute, canonical path of the `epic-implementer-bin.js` belonging to the
57
- * artifact that is currently executing (BAPI-772, the BAPI-768 pattern applied
58
- * to hooks; renamed from `conductor-bin.js` by S4/BAPI-1080).
99
+ * Resolve the QUALIFIED persistent hook entry for the artifact that is
100
+ * executing (BAPI-772 probing, BAPI-1290 lifetime qualification).
59
101
  *
60
102
  * ## Why this cannot be path arithmetic
61
103
  *
62
- * The old implementation hard-coded ONE layout — `dirname(import.meta.url)/../
63
- * conductor-bin.js`, the tsc emit — and returned it unverified. In the shipped
64
- * esbuild bundle this module IS `build/epic-implementer-bin.js`, so the directory hop
65
- * landed on `<pkg>/epic-implementer-bin.js`, a path that has never existed. Every hook
66
- * installed from the published package embedded that dead path, and because hook
67
- * bodies discard output and end in `|| true`, each commit ENOENTed in silence
68
- * while `epic-implementer doctor` still called the hooks healthy.
104
+ * The old implementation hard-coded ONE layout and returned it unverified; every
105
+ * hook installed from the published package embedded a path that never existed.
106
+ * So candidates are drawn only from the executing module's own URL and Node's
107
+ * actual invocation target — never `process.cwd()` or an assumed package root —
108
+ * and each is canonicalized and must stat as a regular file.
109
+ *
110
+ * ## Why existence is not enough
69
111
  *
70
- * So this resolver PROBES. Candidates are drawn only from the executing module's
71
- * own URL and Node's actual invocation target — never `process.cwd()` or an
72
- * assumed package root, which could point a hook at a *different build* than the
73
- * one the operator installed from. Every candidate is canonicalized through
74
- * `realpath` (which also collapses `..` segments and rejects a dangling symlink)
75
- * and must stat as a regular file before it can be returned.
112
+ * An `npx` invocation executes from `~/.npm/_npx/<hash>/…`, which ordinary cache
113
+ * cleanup deletes. A hook that embeds that path dies with the cache, silently.
114
+ * So the found candidate is QUALIFIED: it must be a persistent package-owned
115
+ * entry whose `node` and native binding actually work. An `_npx` caller may
116
+ * instead select an already-installed persistent global package (located with a
117
+ * bounded offline `npm root -g`); otherwise it is refused before any mutation.
76
118
  */
77
119
  export function resolveConductorHookBin(overrides = {}) {
78
120
  const deps = {
@@ -80,7 +122,44 @@ export function resolveConductorHookBin(overrides = {}) {
80
122
  argv1: overrides.argv1 ?? process.argv[1],
81
123
  realpath: overrides.realpath ?? defaultRealpath,
82
124
  isFile: overrides.isFile ?? defaultIsFile,
125
+ qualify: overrides.qualify ?? ((entry) => qualifyHookRuntime(entry)),
126
+ npmGlobalRoot: overrides.npmGlobalRoot ?? defaultNpmGlobalRoot,
83
127
  };
128
+ const found = findExecutingHookBinCandidate(deps);
129
+ if (found === null) {
130
+ return { ok: false, reason: CONDUCTOR_HOOK_BIN_UNRESOLVED_REASON, classification: "missing" };
131
+ }
132
+ if (isTransientNpxEntryPath(found)) {
133
+ // The executing artifact is in the prunable npx cache. It may hand the hook
134
+ // to an ALREADY-installed persistent package, never to itself.
135
+ let root;
136
+ try {
137
+ root = deps.npmGlobalRoot();
138
+ }
139
+ catch {
140
+ root = null;
141
+ }
142
+ if (root === null)
143
+ return refusal("ephemeral-cache");
144
+ const persistent = join(root, ...MCP_PACKAGE_NAME.split("/"), "build", CONDUCTOR_BIN_FILENAME);
145
+ const qualification = safeQualify(deps.qualify, persistent);
146
+ if (!qualification.usable) {
147
+ // A missing or cache-backed global install means "install persistently",
148
+ // which is what the ephemeral-cache action says. Any other failure names
149
+ // what is wrong with the global install that WAS found.
150
+ return refusal(qualification.classification === "missing" || qualification.classification === "ephemeral-cache"
151
+ ? "ephemeral-cache"
152
+ : qualification.classification);
153
+ }
154
+ return qualifiedResolution(qualification);
155
+ }
156
+ const qualification = safeQualify(deps.qualify, found);
157
+ if (!qualification.usable)
158
+ return refusal(qualification.classification);
159
+ return qualifiedResolution(qualification);
160
+ }
161
+ /** First canonical regular-file candidate for the executing artifact, or `null`. */
162
+ function findExecutingHookBinCandidate(deps) {
84
163
  const moduleFile = moduleFilePath(deps.moduleUrl);
85
164
  const candidates = [];
86
165
  if (moduleFile !== null) {
@@ -94,7 +173,8 @@ export function resolveConductorHookBin(overrides = {}) {
94
173
  // Last resort, and only ever a validated one: a packaged install launches
95
174
  // through a `node_modules/.bin` symlink. Accepted only when the invocation
96
175
  // target is exactly the epic-implementer bin — `index.js` or
97
- // `epic-implementer-bin.js.bak` must never become a hook target.
176
+ // `epic-implementer-bin.js.bak` must never become a hook target. The symlink
177
+ // is canonicalized below, so a `.bin` shim is never itself embedded.
98
178
  if (typeof deps.argv1 === "string" && deps.argv1.length > 0) {
99
179
  if (basename(deps.argv1) === CONDUCTOR_BIN_FILENAME)
100
180
  candidates.push(deps.argv1);
@@ -102,14 +182,8 @@ export function resolveConductorHookBin(overrides = {}) {
102
182
  const seen = new Set();
103
183
  for (const candidate of candidates) {
104
184
  // Canonicalize BEFORE probing so duplicates that differ only by symlink or
105
- // `..` segment collapse, and a written hook can only ever embed a canonical
106
- // path to an existing file.
107
- //
108
- // The whole loop is exception-contained: this resolver's contract is that it
109
- // returns a discriminated result and NEVER throws, and that has to hold for
110
- // an injected boundary too — otherwise a filesystem error (whose message can
111
- // carry a home-dir path or credential) escapes as a raw exception and defeats
112
- // the sanitized `reason` this function exists to produce.
185
+ // `..` segment collapse. Exception-contained: an injected boundary that
186
+ // throws must not escape as a raw (possibly path-bearing) exception.
113
187
  let canonical;
114
188
  try {
115
189
  canonical = deps.realpath(candidate);
@@ -124,13 +198,32 @@ export function resolveConductorHookBin(overrides = {}) {
124
198
  seen.add(canonical);
125
199
  try {
126
200
  if (deps.isFile(canonical))
127
- return { ok: true, path: canonical };
201
+ return canonical;
128
202
  }
129
203
  catch {
130
204
  continue;
131
205
  }
132
206
  }
133
- return { ok: false, reason: CONDUCTOR_HOOK_BIN_UNRESOLVED_REASON };
207
+ return null;
208
+ }
209
+ function safeQualify(qualify, entry) {
210
+ try {
211
+ return qualify(entry);
212
+ }
213
+ catch {
214
+ return { usable: false, classification: "unknown", reason: describeHookRuntimeClassification("unknown") };
215
+ }
216
+ }
217
+ function refusal(classification) {
218
+ return { ok: false, classification, reason: describeHookRuntimeClassification(classification) };
219
+ }
220
+ function qualifiedResolution(qualification) {
221
+ return {
222
+ ok: true,
223
+ path: qualification.stable_entry,
224
+ canonical_path: qualification.canonical_entry,
225
+ qualification,
226
+ };
134
227
  }
135
228
  /** `file://` URL → filesystem path, or `null` when the URL is unusable. */
136
229
  function moduleFilePath(moduleUrl) {
@@ -160,31 +253,43 @@ function defaultIsFile(filePath) {
160
253
  function wrapManagedBlock(body) {
161
254
  return [BRIDGE_CONDUCTOR_HOOK_START, body, BRIDGE_CONDUCTOR_HOOK_END].join("\n");
162
255
  }
256
+ /** Comment line every generated managed block carries. */
257
+ const MANAGED_HOOK_COMMENT = "# Managed by Bridge conductor (BAPI-395). Local, unversioned, opportunistic, bypassable.";
163
258
  /**
164
259
  * Build the managed `post-commit` snippet: launches
165
- * `node <conductor-bin> git-hook post-commit` fully detached in the background,
260
+ * `node '<entry>' git-hook post-commit` fully detached in the background,
166
261
  * redirecting all output away from the user's git workflow. Failures are tolerated
167
- * (`|| true`) so the hook never returns non-zero.
262
+ * (`|| true`) so the hook never returns non-zero. The entry is a single literal
263
+ * POSIX-quoted argument (BAPI-1290), so spaces and shell metacharacters in the
264
+ * install prefix are never interpreted.
168
265
  */
169
266
  export function buildPostCommitHookSnippet(conductorBin) {
170
267
  const body = [
171
- "# Managed by Bridge conductor (BAPI-395). Local, unversioned, opportunistic, bypassable.",
172
- `( node ${JSON.stringify(conductorBin)} git-hook post-commit >/dev/null 2>&1 & ) || true`,
268
+ MANAGED_HOOK_COMMENT,
269
+ `( node ${posixShellQuote(conductorBin)} git-hook post-commit >/dev/null 2>&1 & ) || true`,
173
270
  ].join("\n");
174
271
  return wrapManagedBlock(body);
175
272
  }
176
273
  /**
177
274
  * Build the managed `reference-transaction` snippet: captures stdin to a temp
178
275
  * file (the ref updates), then launches
179
- * `node <conductor-bin> git-hook reference-transaction --phase "$1" --stdin-file <tmp>`
180
- * detached in the background, redirecting output away from the user's workflow.
276
+ * `node '<entry>' git-hook reference-transaction --phase "$1" --stdin-file <tmp>`
277
+ * detached in the background. The temp file is removed INSIDE the detached
278
+ * subshell after Node exits (BAPI-1290), so it is cleaned up even when `node`, the
279
+ * entry, or the native binding is unavailable — the producer's own cleanup only
280
+ * runs when it starts.
281
+ *
282
+ * The output redirect sits on the WHOLE backgrounded subshell, not just `node`.
283
+ * Git runs hooks with stdout sent to its stderr; a background subshell that kept
284
+ * those descriptors open would make any caller reading git's stderr to EOF
285
+ * (captured `git` subprocesses, IDEs, agent shells) wait for the producer.
181
286
  */
182
287
  export function buildReferenceTransactionHookSnippet(conductorBin) {
183
288
  const body = [
184
- "# Managed by Bridge conductor (BAPI-395). Local, unversioned, opportunistic, bypassable.",
289
+ MANAGED_HOOK_COMMENT,
185
290
  '__bridge_tmp="$(mktemp 2>/dev/null || echo "/tmp/bridge-conductor-refs.$$")"',
186
291
  'cat > "$__bridge_tmp"',
187
- `( node ${JSON.stringify(conductorBin)} git-hook reference-transaction --phase "$1" --stdin-file "$__bridge_tmp" >/dev/null 2>&1 & ) || true`,
292
+ `( ( node ${posixShellQuote(conductorBin)} git-hook reference-transaction --phase "$1" --stdin-file "$__bridge_tmp"; rm -f "$__bridge_tmp" ) >/dev/null 2>&1 & ) || true`,
188
293
  ].join("\n");
189
294
  return wrapManagedBlock(body);
190
295
  }
@@ -225,45 +330,118 @@ export function mergeManagedHookSnippet(existing, managedBlock) {
225
330
  }
226
331
  /** Stable discriminator for an install that never touched the filesystem. */
227
332
  export const CONDUCTOR_BIN_UNRESOLVED_ERROR = "CONDUCTOR_BIN_UNRESOLVED";
333
+ /**
334
+ * Stable discriminator for an install refused while PLANNING the two edits: one
335
+ * hook is unreadable, symlinked, not a regular file, or unsafe to merge. Nothing
336
+ * was created, written, or chmodded for either hook.
337
+ */
338
+ export const GIT_HOOKS_PLAN_REFUSED_ERROR = "GIT_HOOKS_PLAN_REFUSED";
228
339
  function snippetForHook(name, conductorBin) {
229
340
  return name === "post-commit"
230
341
  ? buildPostCommitHookSnippet(conductorBin)
231
342
  : buildReferenceTransactionHookSnippet(conductorBin);
232
343
  }
233
344
  /**
234
- * Re-validate a resolution before it can reach a hook body. A success produced
235
- * by an injected {@link GitHooksDeps.resolveBin} seam goes through the same
236
- * realpath + regular-file rules the production resolver applies, so the seam can
237
- * substitute a fixture binary but can never bypass validation.
345
+ * Re-qualify a candidate before it can reach a hook body. A success produced by
346
+ * an injected {@link GitHooksDeps.resolveBin} seam goes through the same runtime
347
+ * qualification the production resolver applies, so the seam can substitute a
348
+ * fixture candidate but can never authorize a merely-existing file, an ephemeral
349
+ * cache path, or an unverifiable runtime.
238
350
  */
239
- function validateResolvedBin(resolution) {
240
- if (!resolution.ok)
241
- return resolution;
242
- const canonical = defaultRealpath(resolution.path);
243
- if (canonical === null || !defaultIsFile(canonical)) {
244
- return { ok: false, reason: CONDUCTOR_HOOK_BIN_UNRESOLVED_REASON };
245
- }
246
- return { ok: true, path: canonical };
351
+ export function validateResolvedBin(candidate, qualify = (entry) => qualifyHookRuntime(entry)) {
352
+ if (!candidate.ok) {
353
+ const classification = candidate.classification ?? "missing";
354
+ return { ok: false, reason: candidate.reason, classification };
355
+ }
356
+ const qualification = safeQualify(qualify, candidate.path);
357
+ if (!qualification.usable)
358
+ return refusal(qualification.classification);
359
+ return qualifiedResolution(qualification);
360
+ }
361
+ const DEFAULT_PUBLICATION_FS = {
362
+ writeExclusive: (filePath, content, mode) => {
363
+ const fd = openSync(filePath, "wx", mode);
364
+ try {
365
+ writeSync(fd, content, null, "utf-8");
366
+ }
367
+ finally {
368
+ closeSync(fd);
369
+ }
370
+ },
371
+ chmod: (filePath, mode) => chmodSync(filePath, mode),
372
+ rename: (from, to) => renameSync(from, to),
373
+ unlink: (filePath) => unlinkSync(filePath),
374
+ mkdir: (dirPath) => {
375
+ mkdirSync(dirPath, { recursive: true });
376
+ },
377
+ lstat: (filePath) => {
378
+ try {
379
+ const st = lstatSync(filePath);
380
+ return { isFile: st.isFile(), isSymlink: st.isSymbolicLink(), mode: st.mode & 0o7777 };
381
+ }
382
+ catch (err) {
383
+ if (err.code === "ENOENT")
384
+ return null;
385
+ throw err;
386
+ }
387
+ },
388
+ readFile: (filePath) => readFileSync(filePath, "utf-8"),
389
+ };
390
+ /**
391
+ * The mode a published hook gets: the ORIGINAL mode with only the missing
392
+ * execute bits added (execute wherever read is granted, and always for the
393
+ * owner). A new hook gets 0755.
394
+ */
395
+ export function publishedHookMode(originalMode) {
396
+ if (originalMode === null)
397
+ return 0o755;
398
+ const base = originalMode & 0o7777;
399
+ return base | ((base & 0o444) >> 2) | 0o100;
247
400
  }
248
401
  /**
249
402
  * Install or update the managed `post-commit` and `reference-transaction` hooks.
250
- * Creates the hooks directory when needed and marks hook files executable on
251
- * POSIX. A non-worktree directory is reported as a degraded optional capability
252
- * (warning), never a fatal error.
253
403
  *
254
- * BAPI-772: the conductor binary is resolved and validated BEFORE any filesystem
255
- * mutation. When it cannot be resolved this returns the failure variant and
256
- * touches nothing — a dead hook is now structurally unwritable rather than
257
- * silently installed. The generated runtime bodies are unchanged (detached,
258
- * output discarded, `|| true`): only installation-time resolution is fail-loud.
404
+ * Order of operations (BAPI-1290):
405
+ * 1. Resolve and QUALIFY the hook runtime. A refusal touches nothing.
406
+ * 2. Resolve the shared hooks directory (linked worktrees share one).
407
+ * 3. PLAN both edits: snapshot each target's bytes and mode, refuse a
408
+ * symlinked, non-regular, unreadable, or unsafe hook. A refusal here also
409
+ * touches nothing — for either hook.
410
+ * 4. PUBLISH each hook: write an exclusive same-directory temp file, give it the
411
+ * original mode plus only the missing execute bits, re-check that the target
412
+ * still matches its snapshot, then rename atomically over it. A target that
413
+ * changed meanwhile is left untouched; only owned temp files are removed.
414
+ *
415
+ * Two renames are not a transaction. When one hook publishes and the other does
416
+ * not, the result is `partial` with per-hook outcomes, and no automatic rollback
417
+ * overwrites the published hook. Re-running the installer re-reads the CURRENT
418
+ * content and converges to exactly one managed block per hook.
259
419
  */
260
420
  export function installConductorGitHooks(deps = {}) {
261
421
  const platform = deps.platform ?? process.platform;
262
- const resolution = validateResolvedBin(deps.resolveBin ? deps.resolveBin() : resolveConductorHookBin());
422
+ const qualify = deps.qualifyRuntime ?? ((entry) => qualifyHookRuntime(entry));
423
+ const resolution = deps.resolveBin
424
+ ? validateResolvedBin(safeCandidate(deps.resolveBin), qualify)
425
+ : resolveConductorHookBin({ qualify });
263
426
  if (!resolution.ok) {
264
- return { ok: false, error: CONDUCTOR_BIN_UNRESOLVED_ERROR, reason: resolution.reason };
427
+ return {
428
+ ok: false,
429
+ error: CONDUCTOR_BIN_UNRESOLVED_ERROR,
430
+ reason: resolution.reason,
431
+ classification: resolution.classification,
432
+ };
265
433
  }
266
434
  const conductorBin = resolution.path;
435
+ const q = resolution.qualification;
436
+ const runtime = {
437
+ package_name: q.package_name,
438
+ package_version: q.package_version,
439
+ platform: q.platform,
440
+ node_version: q.runtime.node_version,
441
+ node_modules_abi: q.runtime.node_modules_abi,
442
+ native_module: q.runtime.native_module,
443
+ native_module_version: q.runtime.native_module_version,
444
+ };
267
445
  const { is_worktree, hooks_dir } = resolveGitHooksDirectory(deps);
268
446
  if (!is_worktree || hooks_dir === null) {
269
447
  return {
@@ -273,56 +451,250 @@ export function installConductorGitHooks(deps = {}) {
273
451
  installed: [],
274
452
  warnings: ["not a git worktree; git hooks were not installed (degraded optional capability)"],
275
453
  conductor_bin: conductorBin,
454
+ canonical_bin: resolution.canonical_path,
455
+ runtime,
456
+ complete: false,
457
+ partial: false,
276
458
  };
277
459
  }
278
- const warnings = [];
279
- if (!existsSync(hooks_dir)) {
280
- mkdirSync(hooks_dir, { recursive: true });
281
- }
282
- const installed = [];
460
+ const fs = { ...DEFAULT_PUBLICATION_FS, ...(deps.fs ?? {}) };
461
+ // ---- Plan both edits before publishing either. ---------------------------
462
+ const plans = [];
463
+ const refused = [];
283
464
  for (const name of MANAGED_HOOK_NAMES) {
284
465
  const hookPath = resolve(hooks_dir, name);
285
- let existing = null;
286
- if (existsSync(hookPath)) {
466
+ let stat;
467
+ try {
468
+ stat = fs.lstat(hookPath);
469
+ }
470
+ catch {
471
+ refused.push({ name, path: hookPath, warning: "could not inspect existing hook; left untouched" });
472
+ continue;
473
+ }
474
+ let snapshot = null;
475
+ if (stat !== null) {
476
+ if (stat.isSymlink) {
477
+ refused.push({ name, path: hookPath, warning: "existing hook is a symlink; left untouched" });
478
+ continue;
479
+ }
480
+ if (!stat.isFile) {
481
+ refused.push({ name, path: hookPath, warning: "existing hook is not a regular file; left untouched" });
482
+ continue;
483
+ }
484
+ let content;
287
485
  try {
288
- existing = readFileSync(hookPath, "utf-8");
486
+ content = fs.readFile(hookPath);
289
487
  }
290
488
  catch {
291
- installed.push({ name, path: hookPath, action: "skipped", warning: "could not read existing hook" });
292
- warnings.push(`could not read existing ${name} hook; left untouched`);
489
+ refused.push({ name, path: hookPath, warning: "could not read existing hook; left untouched" });
293
490
  continue;
294
491
  }
492
+ snapshot = { content, mode: stat.mode };
295
493
  }
296
- const merge = mergeManagedHookSnippet(existing, snippetForHook(name, conductorBin));
494
+ const merge = mergeManagedHookSnippet(snapshot?.content ?? null, snippetForHook(name, conductorBin));
297
495
  if (!merge.ok) {
298
- installed.push({ name, path: hookPath, action: "skipped", warning: merge.warning });
299
- if (merge.warning)
300
- warnings.push(`${name}: ${merge.warning}`);
496
+ refused.push({ name, path: hookPath, warning: merge.warning ?? "existing hook is unsafe to merge; left untouched" });
301
497
  continue;
302
498
  }
303
- writeFileSync(hookPath, merge.content, "utf-8");
304
- if (platform !== "win32") {
305
- try {
306
- chmodSync(hookPath, 0o755);
307
- }
308
- catch {
309
- warnings.push(`could not mark ${name} executable`);
310
- }
499
+ plans.push({ name, path: hookPath, action: merge.action, content: merge.content, snapshot });
500
+ }
501
+ if (refused.length > 0) {
502
+ return {
503
+ ok: false,
504
+ error: GIT_HOOKS_PLAN_REFUSED_ERROR,
505
+ reason: "managed git hooks were NOT installed: " +
506
+ refused.map((r) => `${r.name}: ${r.warning}`).join("; ") +
507
+ ". Neither hook was changed.",
508
+ refused,
509
+ };
510
+ }
511
+ // ---- Publish. -------------------------------------------------------------
512
+ const warnings = [];
513
+ try {
514
+ if (!existsSync(hooks_dir))
515
+ fs.mkdir(hooks_dir);
516
+ }
517
+ catch {
518
+ const installed = plans.map((plan) => failedStatus(plan, "publish-failed", "hooks directory could not be created"));
519
+ return {
520
+ ok: true,
521
+ is_worktree: true,
522
+ hooks_dir,
523
+ installed,
524
+ warnings: ["the hooks directory could not be created; no hook was changed"],
525
+ conductor_bin: conductorBin,
526
+ canonical_bin: resolution.canonical_path,
527
+ runtime,
528
+ complete: false,
529
+ partial: false,
530
+ };
531
+ }
532
+ const installed = [];
533
+ for (const plan of plans) {
534
+ const status = publishPlannedHook(plan, hooks_dir, fs, platform);
535
+ if (status.warning !== undefined)
536
+ warnings.push(`${status.name}: ${status.warning}`);
537
+ installed.push(status);
538
+ }
539
+ const publishedCount = installed.filter((h) => h.published).length;
540
+ const complete = MANAGED_HOOK_NAMES.every((name) => installed.some((h) => h.name === name && h.usable));
541
+ const partial = !complete && publishedCount > 0;
542
+ if (partial) {
543
+ warnings.push("only some managed hooks were published; the update is PARTIAL. Re-run " +
544
+ "`epic-implementer install-git-hooks` to converge (published hooks are not rolled back).");
545
+ }
546
+ return {
547
+ ok: true,
548
+ is_worktree: true,
549
+ hooks_dir,
550
+ installed,
551
+ warnings,
552
+ conductor_bin: conductorBin,
553
+ canonical_bin: resolution.canonical_path,
554
+ runtime,
555
+ complete,
556
+ partial,
557
+ };
558
+ }
559
+ function safeCandidate(resolveBin) {
560
+ try {
561
+ return resolveBin();
562
+ }
563
+ catch {
564
+ return { ok: false, reason: CONDUCTOR_HOOK_BIN_UNRESOLVED_REASON, classification: "unknown" };
565
+ }
566
+ }
567
+ function failedStatus(plan, outcome, warning) {
568
+ return {
569
+ name: plan.name,
570
+ path: plan.path,
571
+ action: plan.action,
572
+ outcome,
573
+ published: false,
574
+ mode_ready: false,
575
+ usable: false,
576
+ warning,
577
+ };
578
+ }
579
+ /** True when the target still matches the planned snapshot. */
580
+ function targetMatchesSnapshot(plan, fs) {
581
+ const current = fs.lstat(plan.path);
582
+ if (plan.snapshot === null)
583
+ return current === null;
584
+ if (current === null || current.isSymlink || !current.isFile)
585
+ return false;
586
+ if (current.mode !== plan.snapshot.mode)
587
+ return false;
588
+ return fs.readFile(plan.path) === plan.snapshot.content;
589
+ }
590
+ function publishPlannedHook(plan, hooksDir, fs, platform) {
591
+ const tmpPath = join(hooksDir, `.${plan.name}.bridge-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
592
+ let tmpOwned = false;
593
+ const removeTmp = () => {
594
+ if (!tmpOwned)
595
+ return;
596
+ try {
597
+ fs.unlink(tmpPath);
311
598
  }
312
- installed.push({ name, path: hookPath, action: merge.action });
599
+ catch {
600
+ /* best-effort: only our own unpublished temp file */
601
+ }
602
+ };
603
+ const mode = publishedHookMode(plan.snapshot?.mode ?? null);
604
+ try {
605
+ fs.writeExclusive(tmpPath, plan.content, 0o600);
606
+ tmpOwned = true;
607
+ if (platform !== "win32")
608
+ fs.chmod(tmpPath, mode);
609
+ }
610
+ catch {
611
+ removeTmp();
612
+ return failedStatus(plan, "publish-failed", "the hook could not be staged; left untouched");
613
+ }
614
+ let unchanged;
615
+ try {
616
+ unchanged = targetMatchesSnapshot(plan, fs);
617
+ }
618
+ catch {
619
+ unchanged = false;
620
+ }
621
+ if (!unchanged) {
622
+ removeTmp();
623
+ return failedStatus(plan, "intervening-edit", "the hook changed after it was planned; left untouched (re-run install-git-hooks)");
624
+ }
625
+ try {
626
+ fs.rename(tmpPath, plan.path);
627
+ tmpOwned = false;
628
+ }
629
+ catch {
630
+ removeTmp();
631
+ return failedStatus(plan, "publish-failed", "the hook could not be replaced; left untouched");
632
+ }
633
+ let modeReady = true;
634
+ if (platform !== "win32") {
635
+ try {
636
+ const after = fs.lstat(plan.path);
637
+ modeReady = after !== null && (after.mode & 0o100) !== 0;
638
+ }
639
+ catch {
640
+ modeReady = false;
641
+ }
642
+ }
643
+ return {
644
+ name: plan.name,
645
+ path: plan.path,
646
+ action: plan.action,
647
+ outcome: "published",
648
+ published: true,
649
+ mode_ready: modeReady,
650
+ usable: modeReady,
651
+ ...(modeReady ? {} : { warning: "the published hook is not executable" }),
652
+ };
653
+ }
654
+ /** Recognized generated command lines, captured quoted-entry token first. */
655
+ const LEGACY_JSON_TOKEN = '"(?:[^"\\\\]|\\\\.)*"';
656
+ const POSIX_TOKEN = "'[^']*(?:'\\\\''[^']*)*'";
657
+ const POST_COMMIT_TAIL = " git-hook post-commit >/dev/null 2>&1 & \\) \\|\\| true";
658
+ const LEGACY_REF_TAIL = ' git-hook reference-transaction --phase "\\$1" --stdin-file "\\$__bridge_tmp" >/dev/null 2>&1 & \\) \\|\\| true';
659
+ const POSIX_REF_TAIL = ' git-hook reference-transaction --phase "\\$1" --stdin-file "\\$__bridge_tmp"; rm -f "\\$__bridge_tmp" \\) >/dev/null 2>&1 & \\) \\|\\| true';
660
+ const MANAGED_COMMAND_GRAMMARS = [
661
+ { re: new RegExp(`^\\( node (${LEGACY_JSON_TOKEN})${POST_COMMIT_TAIL}$`), decode: decodeJsonToken },
662
+ { re: new RegExp(`^\\( node (${LEGACY_JSON_TOKEN})${LEGACY_REF_TAIL}$`), decode: decodeJsonToken },
663
+ { re: new RegExp(`^\\( node (${POSIX_TOKEN})${POST_COMMIT_TAIL}$`), decode: decodePosixToken },
664
+ { re: new RegExp(`^\\( \\( node (${POSIX_TOKEN})${POSIX_REF_TAIL}$`), decode: decodePosixToken },
665
+ ];
666
+ function decodeJsonToken(token) {
667
+ try {
668
+ const parsed = JSON.parse(token);
669
+ return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
670
+ }
671
+ catch {
672
+ return null;
673
+ }
674
+ }
675
+ function decodePosixToken(token) {
676
+ const value = token.slice(1, -1).replace(/'\\''/g, "'");
677
+ if (value.length === 0)
678
+ return null;
679
+ // Round-trip check: only exactly what posixShellQuote would emit is accepted.
680
+ try {
681
+ return posixShellQuote(value) === token ? value : null;
682
+ }
683
+ catch {
684
+ return null;
313
685
  }
314
- return { ok: true, is_worktree: true, hooks_dir, installed, warnings, conductor_bin: conductorBin };
315
686
  }
316
687
  /**
317
- * Extract the JSON-quoted binary argument from the managed block's generated
318
- * `node "<path>" git-hook ...` command (BAPI-772).
688
+ * Extract the entry argument from the managed block's generated
689
+ * `node <entry> git-hook ...` command (BAPI-772, BAPI-1290).
319
690
  *
320
691
  * Parsing is deliberately narrow rather than a general shell tokenization: only
321
- * the exact command shape this module writes is recognized, and the quoted
322
- * argument is read back with `JSON.parse` — the same encoding
323
- * {@link buildPostCommitHookSnippet} used to write it, so a path containing
324
- * spaces or quotes round-trips exactly. A hook that was hand-edited into an
325
- * unrecognized shape yields `null` rather than a throw or a guess.
692
+ * the exact command lines this module writes are recognized — the legacy
693
+ * JSON-quoted form and the current POSIX single-quoted form, for each hook — and
694
+ * the quoted argument is decoded with the same encoding that wrote it, so a path
695
+ * containing spaces, quotes, or shell metacharacters round-trips exactly. A hook
696
+ * that was hand-edited into any other shape (command substitution, extra tokens,
697
+ * malformed quoting) yields `null` rather than a throw or a guess.
326
698
  */
327
699
  export function extractManagedHookBinPath(content) {
328
700
  const startIdx = content.indexOf(BRIDGE_CONDUCTOR_HOOK_START);
@@ -330,25 +702,52 @@ export function extractManagedHookBinPath(content) {
330
702
  if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx)
331
703
  return null;
332
704
  const block = content.slice(startIdx, endIdx);
333
- const match = /\bnode\s+("(?:[^"\\]|\\.)*")\s+git-hook\b/.exec(block);
334
- if (match === null)
335
- return null;
336
- try {
337
- const parsed = JSON.parse(match[1]);
338
- return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
705
+ for (const rawLine of block.split("\n")) {
706
+ const line = rawLine.replace(/\r$/, "");
707
+ for (const grammar of MANAGED_COMMAND_GRAMMARS) {
708
+ const match = grammar.re.exec(line);
709
+ if (match === null)
710
+ continue;
711
+ const decoded = grammar.decode(match[1]);
712
+ if (decoded !== null)
713
+ return decoded;
714
+ }
339
715
  }
340
- catch {
341
- return null;
716
+ return null;
717
+ }
718
+ /** Fixed, path-free warning for a degraded hook state. */
719
+ function hookStateWarning(name, state) {
720
+ switch (state) {
721
+ case "usable":
722
+ return null;
723
+ case "not-installed":
724
+ return `${name} hook not installed (degraded optional capability)`;
725
+ case "no-managed-block":
726
+ return `${name} hook has no managed conductor block; run \`epic-implementer install-git-hooks\``;
727
+ case "not-executable":
728
+ return `${name} hook is not executable, so it never runs; re-run \`epic-implementer install-git-hooks\``;
729
+ case "unrecognized":
730
+ return (`${name} hook has a managed block with no recognizable conductor command; ` +
731
+ "reinstall with `epic-implementer install-git-hooks`");
732
+ default:
733
+ return `${name} hook runtime unavailable — ${describeHookRuntimeClassification(state)}`;
342
734
  }
343
735
  }
344
736
  /**
345
737
  * Inspect the managed git hooks WITHOUT modifying anything. Reports worktree
346
- * status, the resolved hooks directory, and per-hook existence, executability, and
347
- * managed-block presence. Missing hooks and non-worktree directories are reported
348
- * as degraded optional capability, never fatal.
738
+ * status, the resolved hooks directory, and per-hook existence, executability,
739
+ * managed-block presence, and — BAPI-1290 — whether the embedded runtime is a
740
+ * QUALIFIED persistent install whose Node and native binding actually load.
741
+ * Missing hooks, unknown evidence, and non-worktree directories are reported as
742
+ * degraded optional capability, never fatal and never healthy.
743
+ *
744
+ * Read-only by contract: no npm discovery, no download, no hook rewrite, no
745
+ * service start, no ledger open. The only subprocess is the bounded runtime
746
+ * probe, run at most once per distinct embedded entry.
349
747
  */
350
748
  export function inspectConductorGitHooks(deps = {}) {
351
749
  const platform = deps.platform ?? process.platform;
750
+ const qualify = deps.qualifyRuntime ?? ((entry) => qualifyHookRuntime(entry));
352
751
  const { is_worktree, hooks_dir } = resolveGitHooksDirectory(deps);
353
752
  if (!is_worktree || hooks_dir === null) {
354
753
  return {
@@ -359,6 +758,15 @@ export function inspectConductorGitHooks(deps = {}) {
359
758
  degraded: true,
360
759
  };
361
760
  }
761
+ const qualifications = new Map();
762
+ const qualifyOnce = (entry) => {
763
+ const cached = qualifications.get(entry);
764
+ if (cached !== undefined)
765
+ return cached;
766
+ const result = safeQualify(qualify, entry);
767
+ qualifications.set(entry, result);
768
+ return result;
769
+ };
362
770
  const warnings = [];
363
771
  const hooks = [];
364
772
  for (const name of MANAGED_HOOK_NAMES) {
@@ -368,6 +776,7 @@ export function inspectConductorGitHooks(deps = {}) {
368
776
  let managedPresent = false;
369
777
  let embeddedBinPath = null;
370
778
  let embeddedBinIsFile = false;
779
+ let state = "not-installed";
371
780
  if (exists) {
372
781
  try {
373
782
  const mode = statSync(hookPath).mode;
@@ -376,41 +785,40 @@ export function inspectConductorGitHooks(deps = {}) {
376
785
  catch {
377
786
  /* unreadable stat — leave executable false */
378
787
  }
788
+ state = "no-managed-block";
379
789
  try {
380
790
  const content = readFileSync(hookPath, "utf-8");
381
791
  managedPresent = content.includes(BRIDGE_CONDUCTOR_HOOK_START);
382
792
  if (managedPresent) {
383
793
  // BAPI-772: a managed block is not enough — the block written by an
384
- // older build can point at a binary that never existed. Stat the
385
- // embedded target READ-ONLY so a dead hook is visible instead of
386
- // reported healthy. Nothing here rewrites or canonicalizes the hook.
794
+ // older build can point at a binary that never existed. BAPI-1290: a
795
+ // binary that exists is not enough either — it can be a prunable
796
+ // cache entry or a runtime whose native binding does not load.
387
797
  embeddedBinPath = extractManagedHookBinPath(content);
388
- // S4 (BAPI-1080): a hook installed before the rename still embeds
389
- // the old `conductor-bin.js` path, which still exists post-upgrade
390
- // as the migration stub — stat-true but dead. Require the embedded
391
- // path's basename to be the current real bin so a stale hook is
392
- // correctly flagged degraded instead of reported healthy.
798
+ // S4 (BAPI-1080): the retained `conductor-bin.js` stub still exists
799
+ // post-upgrade but is dead, so the basename must be the current bin.
393
800
  embeddedBinIsFile =
394
801
  embeddedBinPath !== null &&
395
802
  basename(embeddedBinPath) === CONDUCTOR_BIN_FILENAME &&
396
803
  defaultIsFile(embeddedBinPath);
397
804
  if (embeddedBinPath === null) {
398
- warnings.push(`${name} hook has a managed block with no recognizable conductor command; ` +
399
- "reinstall with `epic-implementer install-git-hooks`");
805
+ state = "unrecognized";
400
806
  }
401
- else if (!embeddedBinIsFile) {
402
- warnings.push(`${name} hook points at a missing or invalid conductor binary (${embeddedBinPath}); ` +
403
- "the hook is dead — reinstall with `epic-implementer install-git-hooks`");
807
+ else {
808
+ const qualification = qualifyOnce(embeddedBinPath);
809
+ state = qualification.usable ? "usable" : qualification.classification;
404
810
  }
405
811
  }
406
812
  }
407
813
  catch {
408
- /* unreadable — leave managedPresent false */
814
+ state = "unknown";
409
815
  }
816
+ if (state === "usable" && !executable)
817
+ state = "not-executable";
410
818
  }
411
- else {
412
- warnings.push(`${name} hook not installed (degraded optional capability)`);
413
- }
819
+ const warning = hookStateWarning(name, state);
820
+ if (warning !== null)
821
+ warnings.push(warning);
414
822
  hooks.push({
415
823
  name,
416
824
  path: hookPath,
@@ -419,8 +827,11 @@ export function inspectConductorGitHooks(deps = {}) {
419
827
  managed_block_present: managedPresent,
420
828
  embedded_bin_path: embeddedBinPath,
421
829
  embedded_bin_is_file: embeddedBinIsFile,
830
+ recognized_command: embeddedBinPath !== null,
831
+ state,
832
+ usable: state === "usable",
422
833
  });
423
834
  }
424
- const degraded = hooks.some((h) => !h.exists || !h.managed_block_present || !h.embedded_bin_is_file);
835
+ const degraded = hooks.some((h) => !h.usable);
425
836
  return { is_worktree: true, hooks_dir, hooks, warnings, degraded };
426
837
  }