@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
@@ -45,7 +45,7 @@
45
45
  import { fileURLToPath } from "node:url";
46
46
  import path from "node:path";
47
47
  import fs from "node:fs";
48
- import { API, SymbolFlags } from "typescript/unstable/sync";
48
+ import { API, SymbolFlags, TypeFlags } from "typescript/unstable/sync";
49
49
  import { isInterfaceDeclaration, isTypeAliasDeclaration } from "typescript/unstable/ast";
50
50
  import { createExecutorHttpClient, RECONCILER_LIVENESS_HEADER, HEARTBEAT_STOP_FIELD, } from "../executor/http-client.js";
51
51
  import { EXECUTOR_JOB_TYPE_VALUES, EXECUTOR_CLASSIFICATION_VALUES, EXECUTOR_MUTATION_OUTCOME_VALUES, RECONCILER_LIVENESS_VALUES, } from "../executor/types.js";
@@ -192,7 +192,15 @@ const PROJECTED_SHAPE_NAMES = [
192
192
  "ExecutorFailureWireRequest",
193
193
  "ExecutorProcessHeartbeatRequest",
194
194
  ];
195
- /** Return the closed string-literal members of *type*, or `undefined` when it is not one. */
195
+ /**
196
+ * Return the closed string-literal members of *type*, or `undefined` when it is not one.
197
+ *
198
+ * `undefined`/`null` union members are skipped (BAPI-1301): an OPTIONAL
199
+ * literal property (`marker?: "a" | "b"`) is typed `"a" | "b" | undefined`
200
+ * under `strictNullChecks`, and its vocabulary is still exactly `["a", "b"]` —
201
+ * mirroring the Python side, which unwraps `Optional[Literal[...]]` before
202
+ * reading its members. Requiredness is compared separately.
203
+ */
196
204
  function literalValuesOf(type) {
197
205
  if (type.isStringLiteralType()) {
198
206
  return [type.value];
@@ -200,11 +208,13 @@ function literalValuesOf(type) {
200
208
  if (type.isUnionType()) {
201
209
  const values = [];
202
210
  for (const member of type.getTypes()) {
211
+ if (member.flags & (TypeFlags.Undefined | TypeFlags.Null))
212
+ continue;
203
213
  if (!member.isStringLiteralType())
204
214
  return undefined;
205
215
  values.push(member.value);
206
216
  }
207
- return values.sort();
217
+ return values.length > 0 ? values.sort() : undefined;
208
218
  }
209
219
  return undefined;
210
220
  }
@@ -209,10 +209,11 @@ export function getSetupEpicUsage() {
209
209
  "that selects no epic branch (--into-base, or a single-node plan) is unchanged",
210
210
  "and composes no posture at all.",
211
211
  "",
212
- "Unattended runs have TWO one-time repository prerequisites, refused by name",
213
- "before any run is created: repository consent (unattended_epic_implementer_allowed) and",
214
- "a verified notify webhook default. `drive-epic` readiness lists both with their",
215
- "current state.",
212
+ "Unattended runs have TWO repository prerequisites, refused by name before any",
213
+ "run is created: repository consent (ON by default; the repository owner can",
214
+ "switch 'Allow unattended Epic Implementer runs' off and on in the Bridge admin",
215
+ "panel's Setup page) and a verified notify webhook default. `drive-epic`",
216
+ "readiness lists both with their current state.",
216
217
  "",
217
218
  "Required:",
218
219
  " --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
@@ -261,7 +262,8 @@ export function getSetupEpicUsage() {
261
262
  " --json Emit a single JSON result object on stdout",
262
263
  " --launch-profile <name> (BAPI-1260) Resolve the target, repository, and bound",
263
264
  " credential from this host-local launch profile. Passed",
264
- " by drive-epic; there is no production default with it.",
265
+ " by drive-epic. The profile names its own target; it",
266
+ " never falls back to production by itself.",
265
267
  " --base-url <url> (BAPI-1260) Explicit target; must agree with the profile.",
266
268
  " -h, --help Show this help",
267
269
  "",
@@ -1235,10 +1237,10 @@ export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPol
1235
1237
  if (!unattended.conductor_allowed) {
1236
1238
  return {
1237
1239
  reason: "target_not_allowed",
1238
- message: "this repository has not consented to unattended conductor runs. Set " +
1239
- "`unattended_epic_implementer_allowed` for it (see " +
1240
- "docs/claude/account-settings-operator-runbook.md), or pass --attended to " +
1241
- "create an attended run. No run was created.",
1240
+ message: "unattended runs are off for this repository. Turn on 'Allow unattended " +
1241
+ "Epic Implementer runs' on the repository's Setup page in the Bridge admin " +
1242
+ "panel, or pass --attended to create an attended run. If it is already on, " +
1243
+ "contact Bridge support. No run was created.",
1242
1244
  };
1243
1245
  }
1244
1246
  // Two independent sources satisfy the escalation channel, and the check has to
@@ -1253,13 +1255,21 @@ export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPol
1253
1255
  // Presence, not verification: the server probes the URL at activation and is
1254
1256
  // the authority on whether it is reachable. This gate exists to spare the
1255
1257
  // operator a created-and-refused run, not to duplicate the probe.
1258
+ //
1259
+ // BAPI-1274 — a third source: when no webhook is declared anywhere, the
1260
+ // account's email address is the escalation channel, and the server reports
1261
+ // its presence (never the address). A declared webhook stays authoritative;
1262
+ // email only fills the gap when none is declared. `notify.local_sink` still
1263
+ // satisfies nothing.
1256
1264
  if (!unattended.notify_webhook_default_declared &&
1257
- !policyDeclaresNotifyWebhook(requestedPolicy)) {
1265
+ !policyDeclaresNotifyWebhook(requestedPolicy) &&
1266
+ unattended.escalation_email_available !== true) {
1258
1267
  return {
1259
1268
  reason: "webhook_unverified",
1260
- message: "an unattended run must have a verified escalation channel, and neither this " +
1261
- "repository nor this run declares one. Supply a --policy-file declaring " +
1262
- "notify.webhook_url (notify.local_sink does not satisfy this), or pass " +
1269
+ message: "an unattended run must have an escalation channel, and this repository, this " +
1270
+ "run, and the account provide none. Supply a --policy-file declaring " +
1271
+ "notify.webhook_url (notify.local_sink does not satisfy this), or add an email " +
1272
+ "address to the account so escalation alerts can be emailed, or pass " +
1263
1273
  "--attended to create an attended run. No run was created.",
1264
1274
  };
1265
1275
  }
@@ -2946,10 +2956,11 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
2946
2956
  deps.errorLog(`The plan was NOT approved: this repository is no longer authorized for ` +
2947
2957
  `unattended runs. The run WAS created and is left in 'planning' with no ` +
2948
2958
  `activation and no executor dispatch — nothing was half-applied.\n` +
2949
- `Restore \`unattended_epic_implementer_allowed\` for this repository (see ` +
2950
- `docs/claude/account-settings-operator-runbook.md) and re-run setup-epic, ` +
2951
- `or deliberately convert this run to an attended posture through the ` +
2952
- `supported operator flow.\nDetail: ${errorDetail(err)}`);
2959
+ `Turn 'Allow unattended Epic Implementer runs' back on in the repository's ` +
2960
+ `Setup page in the Bridge admin panel and re-run setup-epic, or ` +
2961
+ `deliberately convert this run to an attended posture through the ` +
2962
+ `supported operator flow. If it is already on, contact Bridge support.` +
2963
+ `\nDetail: ${errorDetail(err)}`);
2953
2964
  }
2954
2965
  else {
2955
2966
  deps.errorLog(`Failed to approve the plan: ${errorDetail(err)}`);
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED — do not edit manually. Regenerate with: npm run build
2
- export const VERSION = "0.2.62";
3
- export const BUILD_COMMIT = "9351fac413bf";
4
- export const LAUNCHER_ARGS = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.62", "serve"];
2
+ export const VERSION = "0.2.65";
3
+ export const BUILD_COMMIT = "a6ee899f43d8";
4
+ export const LAUNCHER_ARGS = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.65", "serve"];
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import{readFileSync}from"node:fs";import path from"node:path";var WORKER_GUARD_REASON_PREFIX="bridge worker guard",WORKER_GUARD_MALFORMED_LINE=`${WORKER_GUARD_REASON_PREFIX}: payload could not be read; continuing fail-open`;function renderWorkerGuardReason(category,family){return`${WORKER_GUARD_REASON_PREFIX}: denied ${family} (${category}). This command family is restricted to the worker's own branch, worktree, and read-only network access. Use a different approach and continue.`}var UNSAFE_UNQUOTED=new Set(["$","`",">","<"]),SEPARATORS=new Set([";","|","&"]);function tokenizeShellCommand(command){let commands=[],argv=[],current="",hasCurrent=!1,unsafe=!1,endToken=()=>{hasCurrent&&(argv.push(current),current="",hasCurrent=!1)},endCommand=()=>{endToken(),(argv.length>0||unsafe)&&commands.push({argv,unsafe}),argv=[],unsafe=!1};for(let i=0;i<command.length;i+=1){let ch=command[i];if(ch==="\\"){let next=command[i+1];next!==void 0&&(current+=next,hasCurrent=!0,i+=1);continue}if(ch==="'"){let close=command.indexOf("'",i+1);if(close===-1){unsafe=!0,current+=command.slice(i+1),hasCurrent=!0;break}current+=command.slice(i+1,close),hasCurrent=!0,i=close;continue}if(ch==='"'){let j=i+1,body="",closed=!1;for(;j<command.length;){let c=command[j];if(c==="\\"&&j+1<command.length){body+=command[j+1],j+=2;continue}if(c==='"'){closed=!0;break}(c==="$"||c==="`")&&(unsafe=!0),body+=c,j+=1}closed||(unsafe=!0),current+=body,hasCurrent=!0,i=j;continue}if(UNSAFE_UNQUOTED.has(ch)){unsafe=!0,current+=ch,hasCurrent=!0;continue}if(SEPARATORS.has(ch)){endCommand();continue}if(ch===`
3
- `){endCommand();continue}if(ch===" "||ch===" "||ch==="\r"){endToken();continue}current+=ch,hasCurrent=!0}return endCommand(),commands}var WORKER_GUARD_MAX_PAYLOAD_BYTES=1e6;function asRecord(value){return value!==null&&typeof value=="object"&&!Array.isArray(value)?value:void 0}function isInside(root,candidate){let rel=path.relative(root,candidate);return rel===""?!0:rel.startsWith("..")?!1:!path.isAbsolute(rel)}function resolveEffectiveCwd(ctx,payloadCwd){let root=ctx.worktreePath;if(typeof root!="string"||!path.isAbsolute(root))return;let normalizedRoot=path.resolve(root);if(payloadCwd===void 0)return normalizedRoot;if(typeof payloadCwd!="string"||payloadCwd.length===0)return;let resolved=path.isAbsolute(payloadCwd)?path.resolve(payloadCwd):path.resolve(normalizedRoot,payloadCwd);return isInside(normalizedRoot,resolved)?resolved:void 0}function allTargetsContained(ctx,cwd,targets){let root=ctx.worktreePath;if(typeof root!="string"||!path.isAbsolute(root)||cwd===void 0)return!1;let normalizedRoot=path.resolve(root);return targets.every(target=>{if(target.length===0||target==="~"||target.startsWith("~/"))return!1;let resolved=path.isAbsolute(target)?path.resolve(target):path.resolve(cwd,target);return isInside(normalizedRoot,resolved)})}function parseGitInvocation(argv,baseCwd){let cwd=baseCwd,unsafe=!1,i=1;for(;i<argv.length;){let token=argv[i];if(token==="-C"){let dir=argv[i+1];dir===void 0||cwd===void 0?(unsafe=!0,cwd=void 0):cwd=path.isAbsolute(dir)?path.resolve(dir):path.resolve(cwd,dir),i+=2;continue}if(token==="-c"||token==="--namespace"||token==="--git-dir"||token==="--work-tree"){unsafe=!0,i+=2;continue}if(token.startsWith("-")){i+=1;continue}break}return{subcommand:argv[i]??"",args:argv.slice(i+1),cwd,unsafe}}function isOwnBranchDestination(destination,workerBranch){let colon=destination.indexOf(":"),dst=colon===-1?destination:destination.slice(colon+1);if(dst.length===0)return!1;let bare=dst.startsWith("+")?dst.slice(1):dst;return bare===workerBranch||bare===`refs/heads/${workerBranch}`}function evaluateGitPush(args,ctx){let deny={kind:"deny",category:"git-push",family:"git"},positional=[];for(let i=0;i<args.length;i+=1){let token=args[i];if(token==="--"){positional.push(...args.slice(i+1));break}if(token.startsWith("-")){if(token==="--tags"||token==="--delete"||token==="-d"||token==="--mirror"||token==="--all"||token==="--follow-tags"||token.startsWith("--repo"))return deny;(token==="-o"||token==="--push-option"||token==="--receive-pack"||token==="--exec")&&(i+=1);continue}positional.push(token)}let workerBranch=ctx.workerBranch;return typeof workerBranch!="string"||workerBranch.length===0?deny:positional.length<=1?{kind:"allow"}:positional.slice(1).every(d=>isOwnBranchDestination(d,workerBranch))?{kind:"allow"}:deny}function evaluateGitReset(args,ctx){let deny={kind:"deny",category:"git-reset-hard",family:"git"};if(!args.includes("--hard"))return{kind:"allow"};let positional=[];for(let i=0;i<args.length;i+=1){let token=args[i];if(token==="--"){positional.push(...args.slice(i+1));break}token.startsWith("-")||positional.push(token)}if(positional.length===0)return{kind:"allow"};let workerBranch=ctx.workerBranch;if(typeof workerBranch!="string"||workerBranch.length===0)return deny;let allowed=new Set([workerBranch,`origin/${workerBranch}`,`refs/heads/${workerBranch}`,`refs/remotes/origin/${workerBranch}`]);return positional.every(ref=>allowed.has(ref))?{kind:"allow"}:deny}function evaluateGitClean(args,ctx,cwd){let deny={kind:"deny",category:"filesystem-clean",family:"git"};if(cwd===void 0||!allTargetsContained(ctx,cwd,[cwd]))return deny;let pathspecs=[];for(let i=0;i<args.length;i+=1){let token=args[i];if(token==="--"){pathspecs.push(...args.slice(i+1));break}if(token==="-e"||token==="--exclude"){i+=1;continue}token.startsWith("-")||pathspecs.push(token)}return pathspecs.length===0?{kind:"allow"}:allTargetsContained(ctx,cwd,pathspecs)?{kind:"allow"}:deny}function isRecursiveForcedRemove(args){let recursive=!1,force=!1;for(let token of args){if(token==="--")break;if(token==="--recursive")recursive=!0;else if(token==="--force")force=!0;else{if(token.startsWith("--"))continue;if(token.startsWith("-")&&token.length>1)for(let flag of token.slice(1))(flag==="r"||flag==="R")&&(recursive=!0),flag==="f"&&(force=!0)}}return recursive&&force}function evaluateRemove(argv,ctx,cwd){let deny={kind:"deny",category:"filesystem-remove",family:"rm"},args=argv.slice(1);if(!isRecursiveForcedRemove(args))return{kind:"allow"};let targets=[],sawDoubleDash=!1;for(let token of args){if(!sawDoubleDash&&token==="--"){sawDoubleDash=!0;continue}!sawDoubleDash&&token.startsWith("-")&&token.length>1||targets.push(token)}return targets.length===0?deny:allTargetsContained(ctx,cwd,targets)?{kind:"allow"}:deny}function isDatabaseCommand(executable){let base=path.basename(executable);return base==="psql"||base.startsWith("pg_")}var DESTRUCTIVE_SQL=/\b(?:DROP|TRUNCATE)\b/i;function evaluateDatabase(argv){let deny={kind:"deny",category:"database-destructive",family:"psql"};return argv.slice(1).some(arg=>DESTRUCTIVE_SQL.test(arg))?deny:{kind:"allow"}}var MUTATING_METHODS=new Set(["POST","PUT","PATCH","DELETE"]);function evaluateNetwork(argv){let deny={kind:"deny",category:"network-mutating",family:"curl"},base=path.basename(argv[0]??""),args=argv.slice(1);for(let i=0;i<args.length;i+=1){let token=args[i];if(token==="-X"||token==="--request"||token==="--method"){let value=args[i+1];if(value===void 0||MUTATING_METHODS.has(value.toUpperCase()))return deny;i+=1;continue}if(token.startsWith("-X")&&token.length>2){if(MUTATING_METHODS.has(token.slice(2).toUpperCase()))return deny;continue}if(token.startsWith("--request=")||token.startsWith("--method=")){let value=token.slice(token.indexOf("=")+1);if(MUTATING_METHODS.has(value.toUpperCase()))return deny;continue}if(token==="--data"||token.startsWith("--data=")||token.startsWith("--data-")||token==="--form"||token==="-F"||token==="--upload-file"||token==="-T"||token==="--post-data"||token.startsWith("--post-data=")||token==="--post-file"||token.startsWith("--post-file=")||token==="--body-data"||token.startsWith("--body-data=")||token==="--body-file"||token.startsWith("--body-file=")||base==="curl"&&token==="-d")return deny}return{kind:"allow"}}function classify(executable){let base=path.basename(executable);return base==="git"?"git":base==="rm"?"rm":isDatabaseCommand(base)?"database":base==="curl"||base==="wget"?"network":null}function unsafeSegmentDecision(family){switch(family){case"git":return{kind:"deny",category:"git-push",family:"git"};case"rm":return{kind:"deny",category:"filesystem-remove",family:"rm"};case"database":return{kind:"deny",category:"database-destructive",family:"psql"};case"network":return{kind:"deny",category:"network-mutating",family:"curl"}}}var TRANSPARENT_PREFIXES=new Set(["sudo","env","command","nohup","time","nice","exec"]),STDIN_ARGUMENT_PREFIXES=new Set(["xargs"]),SHELL_EXECUTABLES=new Set(["sh","bash","zsh","dash","ksh"]),EVAL_BUILTINS=new Set(["eval"]);function unwrapTransparentPrefixes(argv){let rest=argv;for(let depth=0;depth<8;depth+=1){let head=rest[0];if(head===void 0||!TRANSPARENT_PREFIXES.has(path.basename(head)))return rest;let i=1;for(;i<rest.length;){let token=rest[i];if(path.basename(head)==="env"&&/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)){i+=1;continue}if(token.startsWith("-")){i+=1;continue}break}rest=rest.slice(i)}return rest}function stripStdinPrefixOptions(argv){let VALUE_OPTS=new Set(["-I","-i","-L","-n","-P","-s","-d","-E","-e","-a","--replace","--max-lines","--max-args","--max-procs","--max-chars","--delimiter","--eof","--arg-file"]),i=1;for(;i<argv.length;){let token=argv[i];if(token==="--"){i+=1;break}if(!token.startsWith("-"))break;if(token.includes("=")){i+=1;continue}i+=VALUE_OPTS.has(token)?2:1}return argv.slice(i)}function evaluateSimpleCommand(segment,ctx,payloadCwd,depth=0,stdinFed=!1){let argv=unwrapTransparentPrefixes(segment.argv),executable=argv[0];if(executable===void 0)return{kind:"allow"};if(SHELL_EXECUTABLES.has(path.basename(executable))&&depth<4){let cIndex=argv.findIndex(t=>t==="-c"),inner=cIndex===-1?void 0:argv[cIndex+1];if(inner!==void 0){for(let nested of tokenizeShellCommand(inner)){let decision=evaluateSimpleCommand(nested,ctx,payloadCwd,depth+1,stdinFed);if(decision.kind==="deny")return decision}return{kind:"allow"}}}if(EVAL_BUILTINS.has(path.basename(executable))&&depth<4){for(let nested of tokenizeShellCommand(argv.slice(1).join(" "))){let decision=evaluateSimpleCommand(nested,ctx,payloadCwd,depth+1,stdinFed);if(decision.kind==="deny")return decision}return{kind:"allow"}}if(STDIN_ARGUMENT_PREFIXES.has(path.basename(executable))&&depth<4){let rest=stripStdinPrefixOptions(argv);return rest.length===0?{kind:"allow"}:evaluateSimpleCommand({argv:rest,unsafe:segment.unsafe},ctx,payloadCwd,depth+1,!0)}segment={argv,unsafe:segment.unsafe};let family=classify(executable);if(family===null)return{kind:"allow"};if(stdinFed||segment.unsafe)return unsafeSegmentDecision(family);let cwd=resolveEffectiveCwd(ctx,payloadCwd);if(family==="rm")return evaluateRemove(segment.argv,ctx,cwd);if(family==="database")return evaluateDatabase(segment.argv);if(family==="network")return evaluateNetwork(segment.argv);let git=parseGitInvocation(segment.argv,cwd);return git.subcommand==="push"?git.unsafe?{kind:"deny",category:"git-push",family:"git"}:evaluateGitPush(git.args,ctx):git.subcommand==="reset"?git.unsafe?{kind:"deny",category:"git-reset-hard",family:"git"}:evaluateGitReset(git.args,ctx):git.subcommand==="clean"?git.unsafe?{kind:"deny",category:"filesystem-clean",family:"git"}:evaluateGitClean(git.args,ctx,git.cwd):{kind:"allow"}}function evaluateWorkerGuardPayload(payload,ctx){let root=asRecord(payload);if(root===void 0)return{kind:"malformed"};let toolName=root.tool_name??root.toolName;if(typeof toolName!="string")return{kind:"malformed"};if(toolName!=="Bash")return{kind:"allow"};let input=asRecord(root.tool_input??root.toolInput);if(input===void 0)return{kind:"malformed"};let command=input.command;if(typeof command!="string")return{kind:"malformed"};let rawCwd=root.cwd??input.cwd,payloadCwd=typeof rawCwd=="string"?rawCwd:void 0;for(let segment of tokenizeShellCommand(command)){let decision=evaluateSimpleCommand(segment,ctx,payloadCwd);if(decision.kind==="deny")return decision}return{kind:"allow"}}function evaluateWorkerGuardInput(raw,ctx){if(typeof raw!="string")return{kind:"malformed"};if(raw.length>WORKER_GUARD_MAX_PAYLOAD_BYTES)return{kind:"malformed"};if(raw.trim().length===0)return{kind:"malformed"};let parsed;try{parsed=JSON.parse(raw)}catch{return{kind:"malformed"}}try{return evaluateWorkerGuardPayload(parsed,ctx)}catch{return{kind:"malformed"}}}var WORKER_GUARD_ROOT_FLAG="--worktree-root";function resolveWorkerGuardContext(env,cwd,argv=[]){let ctx={},root;for(let i=0;i<argv.length;i+=1){if(argv[i]===WORKER_GUARD_ROOT_FLAG){root=argv[i+1];break}if(argv[i].startsWith(`${WORKER_GUARD_ROOT_FLAG}=`)){root=argv[i].slice(WORKER_GUARD_ROOT_FLAG.length+1);break}}root===void 0&&typeof cwd=="string"&&(root=cwd),typeof root=="string"&&path.isAbsolute(root)&&(ctx.worktreePath=path.resolve(root));let branch=env.BAPI_WORKER_BRANCH;return typeof branch=="string"&&branch.trim().length>0&&(ctx.workerBranch=branch.trim()),ctx}function readStdinSync(){return readFileSync(0,"utf-8")}var exitCode=0;try{let raw=readStdinSync(),ctx=resolveWorkerGuardContext(process.env,process.cwd(),process.argv.slice(2)),decision=evaluateWorkerGuardInput(raw,ctx);decision.kind==="deny"?(process.stderr.write(`${renderWorkerGuardReason(decision.category,decision.family)}
2
+ import{readFileSync}from"node:fs";import path from"node:path";var WORKER_GUARD_REASON_PREFIX="bridge worker guard",WORKER_GUARD_MALFORMED_LINE=`${WORKER_GUARD_REASON_PREFIX}: payload could not be read; continuing fail-open`;function renderWorkerGuardReason(category,family){return category==="git-ambiguous"?`${WORKER_GUARD_REASON_PREFIX}: denied ${family} (${category}). This shell form cannot be safely resolved: substitution, redirection, or stdin-fed arguments hide what git will run. Use literal arguments instead; for a commit message, write it to a file and run git commit -F <file>.`:`${WORKER_GUARD_REASON_PREFIX}: denied ${family} (${category}). This command family is restricted to the worker's own branch, worktree, and read-only network access. Use a different approach and continue.`}var UNSAFE_UNQUOTED=new Set(["$","`",">","<"]),SEPARATORS=new Set([";","|","&"]);function tokenizeShellCommand(command){let commands=[],argv=[],current="",hasCurrent=!1,unsafe=!1,endToken=()=>{hasCurrent&&(argv.push(current),current="",hasCurrent=!1)},endCommand=()=>{endToken(),(argv.length>0||unsafe)&&commands.push({argv,unsafe}),argv=[],unsafe=!1};for(let i=0;i<command.length;i+=1){let ch=command[i];if(ch==="\\"){let next=command[i+1];next!==void 0&&(current+=next,hasCurrent=!0,i+=1);continue}if(ch==="'"){let close=command.indexOf("'",i+1);if(close===-1){unsafe=!0,current+=command.slice(i+1),hasCurrent=!0;break}current+=command.slice(i+1,close),hasCurrent=!0,i=close;continue}if(ch==='"'){let j=i+1,body="",closed=!1;for(;j<command.length;){let c=command[j];if(c==="\\"&&j+1<command.length){body+=command[j+1],j+=2;continue}if(c==='"'){closed=!0;break}(c==="$"||c==="`")&&(unsafe=!0),body+=c,j+=1}closed||(unsafe=!0),current+=body,hasCurrent=!0,i=j;continue}if(UNSAFE_UNQUOTED.has(ch)){unsafe=!0,current+=ch,hasCurrent=!0;continue}if(SEPARATORS.has(ch)){endCommand();continue}if(ch===`
3
+ `){endCommand();continue}if(ch===" "||ch===" "||ch==="\r"){endToken();continue}current+=ch,hasCurrent=!0}return endCommand(),commands}var WORKER_GUARD_MAX_PAYLOAD_BYTES=1e6;function asRecord(value){return value!==null&&typeof value=="object"&&!Array.isArray(value)?value:void 0}function isInside(root,candidate){let rel=path.relative(root,candidate);return rel===""?!0:rel.startsWith("..")?!1:!path.isAbsolute(rel)}function resolveEffectiveCwd(ctx,payloadCwd){let root=ctx.worktreePath;if(typeof root!="string"||!path.isAbsolute(root))return;let normalizedRoot=path.resolve(root);if(payloadCwd===void 0)return normalizedRoot;if(typeof payloadCwd!="string"||payloadCwd.length===0)return;let resolved=path.isAbsolute(payloadCwd)?path.resolve(payloadCwd):path.resolve(normalizedRoot,payloadCwd);return isInside(normalizedRoot,resolved)?resolved:void 0}function allTargetsContained(ctx,cwd,targets){let root=ctx.worktreePath;if(typeof root!="string"||!path.isAbsolute(root)||cwd===void 0)return!1;let normalizedRoot=path.resolve(root);return targets.every(target=>{if(target.length===0||target==="~"||target.startsWith("~/"))return!1;let resolved=path.isAbsolute(target)?path.resolve(target):path.resolve(cwd,target);return isInside(normalizedRoot,resolved)})}function parseGitInvocation(argv,baseCwd){let cwd=baseCwd,unsafe=!1,i=1;for(;i<argv.length;){let token=argv[i];if(token==="-C"){let dir=argv[i+1];dir===void 0||cwd===void 0?(unsafe=!0,cwd=void 0):cwd=path.isAbsolute(dir)?path.resolve(dir):path.resolve(cwd,dir),i+=2;continue}if(token==="-c"||token==="--namespace"||token==="--git-dir"||token==="--work-tree"){unsafe=!0,i+=2;continue}if(token.startsWith("-")){i+=1;continue}break}return{subcommand:argv[i]??"",args:argv.slice(i+1),cwd,unsafe}}function isOwnBranchDestination(destination,workerBranch){let colon=destination.indexOf(":"),dst=colon===-1?destination:destination.slice(colon+1);if(dst.length===0)return!1;let bare=dst.startsWith("+")?dst.slice(1):dst;return bare===workerBranch||bare===`refs/heads/${workerBranch}`}function evaluateGitPush(args,ctx){let deny={kind:"deny",category:"git-push",family:"git"},positional=[];for(let i=0;i<args.length;i+=1){let token=args[i];if(token==="--"){positional.push(...args.slice(i+1));break}if(token.startsWith("-")){if(token==="--tags"||token==="--delete"||token==="-d"||token==="--mirror"||token==="--all"||token==="--follow-tags"||token.startsWith("--repo"))return deny;(token==="-o"||token==="--push-option"||token==="--receive-pack"||token==="--exec")&&(i+=1);continue}positional.push(token)}let workerBranch=ctx.workerBranch;return typeof workerBranch!="string"||workerBranch.length===0?deny:positional.length<=1?{kind:"allow"}:positional.slice(1).every(d=>isOwnBranchDestination(d,workerBranch))?{kind:"allow"}:deny}function evaluateGitReset(args,ctx){let deny={kind:"deny",category:"git-reset-hard",family:"git"};if(!args.includes("--hard"))return{kind:"allow"};let positional=[];for(let i=0;i<args.length;i+=1){let token=args[i];if(token==="--"){positional.push(...args.slice(i+1));break}token.startsWith("-")||positional.push(token)}if(positional.length===0)return{kind:"allow"};let workerBranch=ctx.workerBranch;if(typeof workerBranch!="string"||workerBranch.length===0)return deny;let allowed=new Set([workerBranch,`origin/${workerBranch}`,`refs/heads/${workerBranch}`,`refs/remotes/origin/${workerBranch}`]);return positional.every(ref=>allowed.has(ref))?{kind:"allow"}:deny}function evaluateGitClean(args,ctx,cwd){let deny={kind:"deny",category:"filesystem-clean",family:"git"};if(cwd===void 0||!allTargetsContained(ctx,cwd,[cwd]))return deny;let pathspecs=[];for(let i=0;i<args.length;i+=1){let token=args[i];if(token==="--"){pathspecs.push(...args.slice(i+1));break}if(token==="-e"||token==="--exclude"){i+=1;continue}token.startsWith("-")||pathspecs.push(token)}return pathspecs.length===0?{kind:"allow"}:allTargetsContained(ctx,cwd,pathspecs)?{kind:"allow"}:deny}function isRecursiveForcedRemove(args){let recursive=!1,force=!1;for(let token of args){if(token==="--")break;if(token==="--recursive")recursive=!0;else if(token==="--force")force=!0;else{if(token.startsWith("--"))continue;if(token.startsWith("-")&&token.length>1)for(let flag of token.slice(1))(flag==="r"||flag==="R")&&(recursive=!0),flag==="f"&&(force=!0)}}return recursive&&force}function evaluateRemove(argv,ctx,cwd){let deny={kind:"deny",category:"filesystem-remove",family:"rm"},args=argv.slice(1);if(!isRecursiveForcedRemove(args))return{kind:"allow"};let targets=[],sawDoubleDash=!1;for(let token of args){if(!sawDoubleDash&&token==="--"){sawDoubleDash=!0;continue}!sawDoubleDash&&token.startsWith("-")&&token.length>1||targets.push(token)}return targets.length===0?deny:allTargetsContained(ctx,cwd,targets)?{kind:"allow"}:deny}function isDatabaseCommand(executable){let base=path.basename(executable);return base==="psql"||base.startsWith("pg_")}var DESTRUCTIVE_SQL=/\b(?:DROP|TRUNCATE)\b/i;function evaluateDatabase(argv){let deny={kind:"deny",category:"database-destructive",family:"psql"};return argv.slice(1).some(arg=>DESTRUCTIVE_SQL.test(arg))?deny:{kind:"allow"}}var MUTATING_METHODS=new Set(["POST","PUT","PATCH","DELETE"]);function evaluateNetwork(argv){let deny={kind:"deny",category:"network-mutating",family:"curl"},base=path.basename(argv[0]??""),args=argv.slice(1);for(let i=0;i<args.length;i+=1){let token=args[i];if(token==="-X"||token==="--request"||token==="--method"){let value=args[i+1];if(value===void 0||MUTATING_METHODS.has(value.toUpperCase()))return deny;i+=1;continue}if(token.startsWith("-X")&&token.length>2){if(MUTATING_METHODS.has(token.slice(2).toUpperCase()))return deny;continue}if(token.startsWith("--request=")||token.startsWith("--method=")){let value=token.slice(token.indexOf("=")+1);if(MUTATING_METHODS.has(value.toUpperCase()))return deny;continue}if(token==="--data"||token.startsWith("--data=")||token.startsWith("--data-")||token==="--form"||token==="-F"||token==="--upload-file"||token==="-T"||token==="--post-data"||token.startsWith("--post-data=")||token==="--post-file"||token.startsWith("--post-file=")||token==="--body-data"||token.startsWith("--body-data=")||token==="--body-file"||token.startsWith("--body-file=")||base==="curl"&&token==="-d")return deny}return{kind:"allow"}}function classify(executable){let base=path.basename(executable);return base==="git"?"git":base==="rm"?"rm":isDatabaseCommand(base)?"database":base==="curl"||base==="wget"?"network":null}function unsafeGitCategory(argv){let{subcommand}=parseGitInvocation(argv,void 0);return subcommand==="push"?"git-push":subcommand==="reset"?"git-reset-hard":subcommand==="clean"?"filesystem-clean":"git-ambiguous"}function unsafeSegmentDecision(family,argv){switch(family){case"git":return{kind:"deny",category:unsafeGitCategory(argv),family:"git"};case"rm":return{kind:"deny",category:"filesystem-remove",family:"rm"};case"database":return{kind:"deny",category:"database-destructive",family:"psql"};case"network":return{kind:"deny",category:"network-mutating",family:"curl"}}}var TRANSPARENT_PREFIXES=new Set(["sudo","env","command","nohup","time","nice","exec"]),STDIN_ARGUMENT_PREFIXES=new Set(["xargs"]),SHELL_EXECUTABLES=new Set(["sh","bash","zsh","dash","ksh"]),EVAL_BUILTINS=new Set(["eval"]);function unwrapTransparentPrefixes(argv){let rest=argv;for(let depth=0;depth<8;depth+=1){let head=rest[0];if(head===void 0||!TRANSPARENT_PREFIXES.has(path.basename(head)))return rest;let i=1;for(;i<rest.length;){let token=rest[i];if(path.basename(head)==="env"&&/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)){i+=1;continue}if(token.startsWith("-")){i+=1;continue}break}rest=rest.slice(i)}return rest}function stripStdinPrefixOptions(argv){let VALUE_OPTS=new Set(["-I","-i","-L","-n","-P","-s","-d","-E","-e","-a","--replace","--max-lines","--max-args","--max-procs","--max-chars","--delimiter","--eof","--arg-file"]),i=1;for(;i<argv.length;){let token=argv[i];if(token==="--"){i+=1;break}if(!token.startsWith("-"))break;if(token.includes("=")){i+=1;continue}i+=VALUE_OPTS.has(token)?2:1}return argv.slice(i)}function evaluateSimpleCommand(segment,ctx,payloadCwd,depth=0,stdinFed=!1){let argv=unwrapTransparentPrefixes(segment.argv),executable=argv[0];if(executable===void 0)return{kind:"allow"};if(SHELL_EXECUTABLES.has(path.basename(executable))&&depth<4){let cIndex=argv.findIndex(t=>t==="-c"),inner=cIndex===-1?void 0:argv[cIndex+1];if(inner!==void 0){for(let nested of tokenizeShellCommand(inner)){let decision=evaluateSimpleCommand(nested,ctx,payloadCwd,depth+1,stdinFed);if(decision.kind==="deny")return decision}return{kind:"allow"}}}if(EVAL_BUILTINS.has(path.basename(executable))&&depth<4){for(let nested of tokenizeShellCommand(argv.slice(1).join(" "))){let decision=evaluateSimpleCommand(nested,ctx,payloadCwd,depth+1,stdinFed);if(decision.kind==="deny")return decision}return{kind:"allow"}}if(STDIN_ARGUMENT_PREFIXES.has(path.basename(executable))&&depth<4){let rest=stripStdinPrefixOptions(argv);return rest.length===0?{kind:"allow"}:evaluateSimpleCommand({argv:rest,unsafe:segment.unsafe},ctx,payloadCwd,depth+1,!0)}segment={argv,unsafe:segment.unsafe};let family=classify(executable);if(family===null)return{kind:"allow"};if(stdinFed||segment.unsafe)return unsafeSegmentDecision(family,segment.argv);let cwd=resolveEffectiveCwd(ctx,payloadCwd);if(family==="rm")return evaluateRemove(segment.argv,ctx,cwd);if(family==="database")return evaluateDatabase(segment.argv);if(family==="network")return evaluateNetwork(segment.argv);let git=parseGitInvocation(segment.argv,cwd);return git.subcommand==="push"?git.unsafe?{kind:"deny",category:"git-push",family:"git"}:evaluateGitPush(git.args,ctx):git.subcommand==="reset"?git.unsafe?{kind:"deny",category:"git-reset-hard",family:"git"}:evaluateGitReset(git.args,ctx):git.subcommand==="clean"?git.unsafe?{kind:"deny",category:"filesystem-clean",family:"git"}:evaluateGitClean(git.args,ctx,git.cwd):{kind:"allow"}}function evaluateWorkerGuardPayload(payload,ctx){let root=asRecord(payload);if(root===void 0)return{kind:"malformed"};let toolName=root.tool_name??root.toolName;if(typeof toolName!="string")return{kind:"malformed"};if(toolName!=="Bash")return{kind:"allow"};let input=asRecord(root.tool_input??root.toolInput);if(input===void 0)return{kind:"malformed"};let command=input.command;if(typeof command!="string")return{kind:"malformed"};let rawCwd=root.cwd??input.cwd,payloadCwd=typeof rawCwd=="string"?rawCwd:void 0;for(let segment of tokenizeShellCommand(command)){let decision=evaluateSimpleCommand(segment,ctx,payloadCwd);if(decision.kind==="deny")return decision}return{kind:"allow"}}function evaluateWorkerGuardInput(raw,ctx){if(typeof raw!="string")return{kind:"malformed"};if(raw.length>WORKER_GUARD_MAX_PAYLOAD_BYTES)return{kind:"malformed"};if(raw.trim().length===0)return{kind:"malformed"};let parsed;try{parsed=JSON.parse(raw)}catch{return{kind:"malformed"}}try{return evaluateWorkerGuardPayload(parsed,ctx)}catch{return{kind:"malformed"}}}var WORKER_GUARD_ROOT_FLAG="--worktree-root";function resolveWorkerGuardContext(env,cwd,argv=[]){let ctx={},root;for(let i=0;i<argv.length;i+=1){if(argv[i]===WORKER_GUARD_ROOT_FLAG){root=argv[i+1];break}if(argv[i].startsWith(`${WORKER_GUARD_ROOT_FLAG}=`)){root=argv[i].slice(WORKER_GUARD_ROOT_FLAG.length+1);break}}root===void 0&&typeof cwd=="string"&&(root=cwd),typeof root=="string"&&path.isAbsolute(root)&&(ctx.worktreePath=path.resolve(root));let branch=env.BAPI_WORKER_BRANCH;return typeof branch=="string"&&branch.trim().length>0&&(ctx.workerBranch=branch.trim()),ctx}function readStdinSync(){return readFileSync(0,"utf-8")}var exitCode=0;try{let raw=readStdinSync(),ctx=resolveWorkerGuardContext(process.env,process.cwd(),process.argv.slice(2)),decision=evaluateWorkerGuardInput(raw,ctx);decision.kind==="deny"?(process.stderr.write(`${renderWorkerGuardReason(decision.category,decision.family)}
4
4
  `),exitCode=2):decision.kind==="malformed"&&(process.stderr.write(`${WORKER_GUARD_MALFORMED_LINE}
5
5
  `),exitCode=0)}catch{try{process.stderr.write(`${WORKER_GUARD_MALFORMED_LINE}
6
6
  `)}catch{}exitCode=0}process.exit(exitCode);
package/docs/CONDUCTOR.md CHANGED
@@ -314,6 +314,111 @@ hook presence and managed-snippet status **read-only** (a new `git hooks` sectio
314
314
  `git_hooks` JSON object alongside the ledger report), and the reconciler drives CI
315
315
  polling and gate evaluation regardless of whether hooks are installed.
316
316
 
317
+ ### Durable hook installation (BAPI-1290)
318
+
319
+ A managed hook embeds one absolute entry path and runs it on every commit for as
320
+ long as the hook file lives. So the installer embeds only a **qualified persistent
321
+ runtime** and refuses anything that can disappear under it.
322
+
323
+ **Supported installation.** Install the package persistently, then install the hooks
324
+ from that install:
325
+
326
+ ```bash
327
+ npm i -g @bridge_gpt/mcp-server
328
+ epic-implementer install-git-hooks
329
+ ```
330
+
331
+ A user-owned persistent prefix (`npm i -g --prefix <dir>`, with `<dir>/bin` on PATH)
332
+ works the same way. The hook embeds the fixed package entry
333
+ `<prefix>/lib/node_modules/@bridge_gpt/mcp-server/build/epic-implementer-bin.js`
334
+ (POSIX single-quoted, so spaces and shell metacharacters stay literal) and launches it
335
+ with `node` from PATH. It never embeds npm's `.bin` shim and never pins
336
+ `process.execPath`.
337
+
338
+ Running the installer through `npx` is allowed only as a front door: it looks up an
339
+ **already installed** persistent global package with a bounded, offline
340
+ `npm root -g` (at install time only) and embeds that package's entry. When no
341
+ qualified persistent install exists, it refuses.
342
+
343
+ **What "qualified" means.** Before touching any hook, the installer checks, read-only:
344
+
345
+ | Check | Refusal classification |
346
+ |---|---|
347
+ | The entry is not inside the prunable `_npx` cache | `ephemeral-cache` |
348
+ | The canonical entry is a regular `build/epic-implementer-bin.js` | `missing` |
349
+ | It is not the retired `conductor-bin.js` name | `legacy-name` |
350
+ | It sits at `…/node_modules/@bridge_gpt/mcp-server/build/`, and the adjacent `package.json` has that `name`, a compatible `version` (`>=0.2.0 <1.0.0`), and `bin.epic-implementer` → `./build/epic-implementer-bin.js` | `unqualified-runtime` |
351
+ | The platform is macOS or Linux | `unqualified-runtime` |
352
+ | `node` from PATH launches the entry's private probe within 15 seconds | `node-unavailable` / `unknown` |
353
+ | That Node is an even LTS line ≥ 20 | `unqualified-runtime` |
354
+ | `better-sqlite3` loads for that Node | `native-unavailable` |
355
+
356
+ A refusal exits 1, prints only the classification and a fixed action (never a path or
357
+ error text), and leaves both existing hooks byte-for-byte and mode-for-mode unchanged.
358
+ Hooks are optional observability: a refusal is **not** an engine-admission failure and
359
+ not a setup-permission stop.
360
+
361
+ **Qualified matrix.** Recorded evidence, not inference:
362
+
363
+ | OS | Install | Node | Native | Status |
364
+ |---|---|---|---|---|
365
+ | macOS 26 (Darwin 25.6, arm64) | persistent `npm i -g --prefix` (npm 11.13.0) | v24.16.0 (ABI 137) | better-sqlite3 13.0.3 | qualified by `packaged-git-hooks-lifetime.test.ts` |
366
+ | Linux | persistent `npm i -g` | even LTS ≥ 20 | better-sqlite3 13.x | accepted by policy; **not yet locally qualified** |
367
+ | Windows | any | any | any | **unqualified** — always refused |
368
+ | Any | `npx` cache only, version-manager switches (nvm/fnm/volta), other layouts | — | — | **unqualified** |
369
+
370
+ A package-layout change or a Node/native ABI change must be requalified by re-running
371
+ `npm run test:hooks-lifetime` before this table changes.
372
+
373
+ **Publication.** The installer plans both hooks before writing either. It refuses a
374
+ symlinked, non-regular, unreadable, or binary hook with a per-hook reason and changes
375
+ nothing. Each hook is then written to a same-directory temporary file with its original
376
+ mode plus only the missing execute bits, re-checked against its planned snapshot, and
377
+ renamed atomically over the target. A hook edited in the meantime is left untouched
378
+ (`intervening-edit`). Two renames are not a transaction: if one hook publishes and the
379
+ other does not, the result is `PARTIAL` (exit 1), the published hook is not rolled
380
+ back, and re-running the installer converges to exactly one managed block per hook.
381
+ User content before and after the managed block is preserved.
382
+
383
+ **Behavior changes to note.** A symlinked hook is refused rather than written
384
+ through; one unsafe hook (symlinked, unreadable, binary, or not a regular file)
385
+ blocks installing both; a source-checkout build is refused as
386
+ `unqualified-runtime`; and on Windows `doctor` always reports `unqualified-runtime`.
387
+ A persistent install older than this change cannot answer the runtime probe and is
388
+ reported `unknown` — upgrade it with `npm i -g @bridge_gpt/mcp-server@latest`.
389
+
390
+ **Diagnosis.** `epic-implementer doctor` (and `install doctor`) report each hook's
391
+ `state` and `usable` verdict read-only: no npm lookup, no download, no hook rewrite,
392
+ no service start, no ledger write. Its only subprocess is the bounded runtime probe.
393
+ A hook that exists but points into the npx cache (`ephemeral-cache`), at a removed
394
+ install (`missing`), at the retired name (`legacy-name`), or at a runtime whose native
395
+ binding no longer loads (`native-unavailable`) is **unavailable**, never healthy. The
396
+ raw embedded path appears only in `--json` output (`embedded_bin_path`).
397
+
398
+ **Repairing old cache-backed hooks.** Hooks installed from `npx` before BAPI-1290
399
+ point into the npx cache. Doctor reports them as `ephemeral-cache`. To repair:
400
+ install persistently (`npm i -g @bridge_gpt/mcp-server`), then re-run
401
+ `epic-implementer install-git-hooks` (or `install conductor` and accept the
402
+ local-observability consent). The managed block is replaced in place; user content,
403
+ modes, and the local ledger are preserved.
404
+
405
+ **Upgrades and rollback.** `npm i -g @bridge_gpt/mcp-server@<version>` replaces the
406
+ package in place, so the embedded path is unchanged and hooks keep working **without
407
+ being rewritten**. While the replacement is in progress, or if it is interrupted,
408
+ optional delivery may be temporarily unavailable and doctor reports it as degraded;
409
+ completing the install (or re-running it) restores delivery. To roll back, install the
410
+ previous compatible version the same way, then run `epic-implementer doctor` and, if
411
+ it reports degraded, `epic-implementer install-git-hooks`. Removing the global package
412
+ deliberately is outside the availability guarantee but stays diagnosable (`missing`).
413
+
414
+ **What is not promised.** Events that occur while hook delivery is unavailable are
415
+ **lost and not replayed**; the ledger is not complete for those intervals, and anyone
416
+ relying on a missed observation must reconcile it from Git itself. Git commits and ref
417
+ updates are never blocked or slowed by an unavailable runtime (the hook is detached and
418
+ ends in `|| true`), and engine admission never depends on hooks. No run-speed
419
+ improvement is claimed. Activating a hook build against a live or shared control plane
420
+ remains a separately authorized operation.
421
+
317
422
  ## Supervisor config — where the done gate and auto-merge live
318
423
 
319
424
  > **These are not config fields.** They used to be `conductor_done_gate` and
@@ -327,7 +432,14 @@ Two scopes exist for each: a per-repo **project default**, and a per-epic overri
327
432
  | Setting | Endpoint (project default) |
328
433
  | --- | --- |
329
434
  | `done_gate_config` | `PUT /jira/epic-runs/supervisor-setup/defaults/?repo_name=<repo>` |
330
- | `auto_merge_enabled`, `merge_approval_required` | `PUT /jira/epic-runs/supervisor-config/defaults/?repo_name=<repo>` |
435
+ | `auto_merge_enabled`, `merge_approval_required` | `PUT /jira/epic-runs/supervisor-config/defaults/?repo_name=<repo>` (see note below) |
436
+
437
+ Since BAPI-1288 every project-default write is protected. A write that removes a
438
+ configured requirement needs a preview (`POST …/defaults/preview/`) and the exact
439
+ approval it returns. Turning `auto_merge_enabled` **on**, or dropping a required
440
+ `merge_approval_required`, on the project default is refused (`409 NON_RELAXABLE`)
441
+ whatever the approval. That is an operator decision; see the account-settings
442
+ operator runbook (`docs/claude/account-settings-operator-runbook.md`).
331
443
 
332
444
  Swap `…/defaults/` for `…/runs/{epic_key}/…` to scope a setting to one epic. The
333
445
  matching `GET` on each returns the effective value, with a `source` of `epic`,
@@ -356,7 +468,9 @@ strictly `true`), malformed, empty, or unsupported config emits no `gate.met`.
356
468
 
357
469
  When a worker's PR meets the done gate (`gate.met`), the supervisor can autonomously
358
470
  merge it — but **only** when the repo has explicitly opted in.
359
- `auto_merge_enabled` on the supervisor-config endpoint is the opt-in switch.
471
+ `auto_merge_enabled` on the supervisor-config endpoint is the opt-in switch. On the
472
+ project default it can no longer be turned on through the API (BAPI-1288); an
473
+ operator sets it, as the note above describes. A per-epic override is unchanged.
360
474
 
361
475
  **Auto-merge is disabled by default.** Behavior:
362
476
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge_gpt/mcp-server",
3
- "version": "0.2.62",
3
+ "version": "0.2.65",
4
4
  "description": "Bridge API MCP server — exposes Jira endpoints as MCP tools for Claude Code agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,6 +37,7 @@
37
37
  "test:module-mocks": "node scripts/run-unit-tests.js module-mocks",
38
38
  "test:integration": "node scripts/run-integration-tests.js",
39
39
  "test:smoke": "node --test build/integration/packaged-cli-smoke.test.js",
40
+ "test:hooks-lifetime": "node --test build/integration/packaged-git-hooks-lifetime.test.js",
40
41
  "canary:agent-capabilities": "npm run build && node scripts/agent-capabilities-canary.mjs",
41
42
  "prepublishOnly": "node scripts/bundle-assets.js && npm run build && node scripts/verify-shebang.cjs && npm run assert:release-identity"
42
43
  },