@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.
- package/README.md +47 -8
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-profile.js +14 -5
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- 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 {
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
|
106
|
-
//
|
|
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
|
|
201
|
+
return canonical;
|
|
128
202
|
}
|
|
129
203
|
catch {
|
|
130
204
|
continue;
|
|
131
205
|
}
|
|
132
206
|
}
|
|
133
|
-
return
|
|
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 <
|
|
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
|
-
|
|
172
|
-
`( node ${
|
|
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 <
|
|
180
|
-
* detached in the background
|
|
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
|
-
|
|
289
|
+
MANAGED_HOOK_COMMENT,
|
|
185
290
|
'__bridge_tmp="$(mktemp 2>/dev/null || echo "/tmp/bridge-conductor-refs.$$")"',
|
|
186
291
|
'cat > "$__bridge_tmp"',
|
|
187
|
-
`( node ${
|
|
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-
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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(
|
|
240
|
-
if (!
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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-
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
286
|
-
|
|
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
|
-
|
|
486
|
+
content = fs.readFile(hookPath);
|
|
289
487
|
}
|
|
290
488
|
catch {
|
|
291
|
-
|
|
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(
|
|
494
|
+
const merge = mergeManagedHookSnippet(snapshot?.content ?? null, snippetForHook(name, conductorBin));
|
|
297
495
|
if (!merge.ok) {
|
|
298
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
|
318
|
-
* `node
|
|
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
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* spaces or
|
|
325
|
-
*
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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,
|
|
347
|
-
* managed-block presence
|
|
348
|
-
*
|
|
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.
|
|
385
|
-
//
|
|
386
|
-
//
|
|
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):
|
|
389
|
-
//
|
|
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
|
-
|
|
399
|
-
"reinstall with `epic-implementer install-git-hooks`");
|
|
805
|
+
state = "unrecognized";
|
|
400
806
|
}
|
|
401
|
-
else
|
|
402
|
-
|
|
403
|
-
|
|
807
|
+
else {
|
|
808
|
+
const qualification = qualifyOnce(embeddedBinPath);
|
|
809
|
+
state = qualification.usable ? "usable" : qualification.classification;
|
|
404
810
|
}
|
|
405
811
|
}
|
|
406
812
|
}
|
|
407
813
|
catch {
|
|
408
|
-
|
|
814
|
+
state = "unknown";
|
|
409
815
|
}
|
|
816
|
+
if (state === "usable" && !executable)
|
|
817
|
+
state = "not-executable";
|
|
410
818
|
}
|
|
411
|
-
|
|
412
|
-
|
|
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.
|
|
835
|
+
const degraded = hooks.some((h) => !h.usable);
|
|
425
836
|
return { is_worktree: true, hooks_dir, hooks, warnings, degraded };
|
|
426
837
|
}
|