@bridge_gpt/mcp-server 0.2.41 → 0.2.43

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 (88) hide show
  1. package/README.md +330 -191
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/agents.generated.js +2 -2
  5. package/build/claude-review-workflow.js +510 -45
  6. package/build/claude-user-config-doctor.js +42 -11
  7. package/build/cli-release.js +2 -1
  8. package/build/commands.generated.js +6 -5
  9. package/build/conduct-epic/bridge-client.js +354 -113
  10. package/build/conduct-epic/checkpoint-store.js +17 -0
  11. package/build/conduct-epic/cli.js +947 -99
  12. package/build/conduct-epic/cut-protocol.js +327 -0
  13. package/build/conduct-epic/spawn.js +14 -2
  14. package/build/conductor/bridge-api-client.js +148 -1
  15. package/build/conductor/cli.js +109 -1
  16. package/build/conductor/doctor.js +101 -16
  17. package/build/conductor/epic-reconcile.js +72 -19
  18. package/build/conductor/epic-runtime.js +15 -3
  19. package/build/conductor/errors.js +47 -0
  20. package/build/conductor/git-hooks.js +205 -11
  21. package/build/conductor/install-doctor.js +230 -1
  22. package/build/conductor/local-merge.js +130 -28
  23. package/build/conductor/recovery-cli.js +313 -0
  24. package/build/conductor/recovery-operations.js +219 -0
  25. package/build/conductor/tools.js +32 -3
  26. package/build/conductor/worker-ledger-cli.js +27 -1
  27. package/build/conductor-bin.js +20 -16
  28. package/build/credentials-cli.js +3 -2
  29. package/build/docs.generated.js +2 -1
  30. package/build/doctor.js +120 -44
  31. package/build/drive-epic.js +375 -0
  32. package/build/executor/cli.js +48 -1
  33. package/build/executor/env.js +21 -0
  34. package/build/executor/http-client.js +71 -3
  35. package/build/executor/index-scope.js +39 -0
  36. package/build/executor/job-errors.js +9 -0
  37. package/build/executor/job-log-registry.js +69 -0
  38. package/build/executor/job-runner.js +198 -29
  39. package/build/executor/live-worker-registry.js +83 -0
  40. package/build/executor/observation.js +259 -6
  41. package/build/executor/platform.js +147 -3
  42. package/build/executor/process.js +58 -14
  43. package/build/executor/runner.js +454 -48
  44. package/build/executor/test-clock.js +3 -2
  45. package/build/executor/worker-finalization.js +233 -56
  46. package/build/executor/worktree.js +8 -1
  47. package/build/index-scope-contract.js +96 -0
  48. package/build/index.js +2277 -270
  49. package/build/init.js +83 -22
  50. package/build/install-bridge-conductor.js +323 -14
  51. package/build/install-bridge.js +225 -47
  52. package/build/install-doctor.js +23 -9
  53. package/build/install-reexec.js +2 -1
  54. package/build/launcher-config-inspection.js +83 -22
  55. package/build/mcp-host-config.js +331 -67
  56. package/build/mcp-host-targets.js +45 -21
  57. package/build/mcp-identity.js +92 -0
  58. package/build/mcp-install-state.js +94 -1
  59. package/build/mcp-invoke.js +2 -1
  60. package/build/mcp-provisioning.js +45 -12
  61. package/build/mcp-registration-doctor.js +35 -13
  62. package/build/mcp-server-invocation.js +4 -2
  63. package/build/merge-pull-request.js +208 -9
  64. package/build/pipelines.generated.js +305 -15
  65. package/build/plane/cli.js +73 -7
  66. package/build/plane/defaults.js +18 -5
  67. package/build/plane/manifest.js +90 -0
  68. package/build/plane/preflight.js +100 -10
  69. package/build/plane/shutdown.js +71 -3
  70. package/build/plane/test-fakes.js +9 -1
  71. package/build/readme.generated.js +1 -1
  72. package/build/regression-check.js +3 -2
  73. package/build/review-tickets.js +8 -7
  74. package/build/run-unit-tests-launcher.js +149 -6
  75. package/build/schedule-run.js +3 -2
  76. package/build/setup-epic.js +531 -82
  77. package/build/sfcc/tool-wrapper.js +15 -0
  78. package/build/start-tickets-prereqs.js +11 -6
  79. package/build/start-tickets.js +91 -85
  80. package/build/update-check.js +3 -2
  81. package/build/upgrade-advice.js +2 -1
  82. package/build/upgrade-cli.js +50 -18
  83. package/build/version.generated.js +2 -1
  84. package/build/worktree-core.js +31 -17
  85. package/docs/CONDUCTOR.md +22 -0
  86. package/docs/install/mcp-tool-integrations.md +19 -3
  87. package/package.json +2 -2
  88. package/pipelines/greenfield-setup.json +286 -0
@@ -18,7 +18,7 @@ import { ConductorValidationError, ConductorEpicTickV1FrozenError, toConductorEr
18
18
  import { emitConductorEvent, purgeConductorLedger, sendWorkerMessage, checkWorkerMessages, } from "./store.js";
19
19
  import { isDuplicateConstraintError } from "./producer-ledger.js";
20
20
  import { SEMANTIC_EVENT_TYPES } from "./taxonomy.js";
21
- import { installConductorGitHooks } from "./git-hooks.js";
21
+ import { installConductorGitHooks, resolveConductorHookBin } from "./git-hooks.js";
22
22
  import { runFileScopeGuardCli } from "./file-scope-guard.js";
23
23
  import { runPostCommitHookProducer, runReferenceTransactionHookProducer } from "./git-producer.js";
24
24
  import { buildConductorDoctorReport, formatConductorDoctorReport } from "./doctor.js";
@@ -65,6 +65,15 @@ export function getConductorUsage() {
65
65
  " Run the reference-transaction producer (invoked by the hook)",
66
66
  " file-scope-guard Warn-only: compare the branch diff against the declared",
67
67
  " touched-file set (always exits 0; never blocks a PR)",
68
+ " stop-run Stop an epic run: block dispatch and cancel queued work",
69
+ " (the run record is preserved). Idempotent.",
70
+ " abandon-run Abandon an epic run — TERMINAL and IRREVERSIBLE.",
71
+ " Idempotent on an already-abandoned run.",
72
+ " unpark Move a parked (needs_human) ticket back into its gate",
73
+ " machine. Retries internally on a concurrent change.",
74
+ " adopt-current-head-and-unpark",
75
+ " Recover a ticket parked by a PR-head drift: adopt the",
76
+ " CURRENT PR head and unpark in one step.",
68
77
  "",
69
78
  "supervise options:",
70
79
  " --run-id <id> Run/session identifier to supervise (required)",
@@ -157,8 +166,47 @@ export function getConductorUsage() {
157
166
  " conductor approve-plan EPIC-405 --plan-version 2 --json",
158
167
  " conductor epic-status --epic-key EPIC-405",
159
168
  " conductor epic-status --epic-key EPIC-405 --json",
169
+ "",
170
+ "stop-run / abandon-run options:",
171
+ " --epic-run-id <id> Epic run identifier (required)",
172
+ " --json Print compact JSON result",
173
+ " --help Print this usage message",
174
+ " stop-run BLOCKS new dispatch and CANCELS queued work; the run record is",
175
+ " preserved and the run cannot resume on its own — it is an emergency brake,",
176
+ " not a pause. abandon-run is the SAME plus a TERMINAL, IRREVERSIBLE close-out:",
177
+ " once abandoned, the run can never resume or be reused. Both are idempotent —",
178
+ " repeating either is a safe no-op once the run is already stopped/abandoned.",
179
+ "",
180
+ "unpark / adopt-current-head-and-unpark options:",
181
+ " --epic-run-id <id> Epic run identifier (required)",
182
+ " --ticket-key <key> Ticket key (required)",
183
+ " --json Print compact JSON result",
184
+ " --help Print this usage message",
185
+ " unpark resumes a ticket PARKED with needs_human, once the underlying cause",
186
+ " is resolved. adopt-current-head-and-unpark additionally REBINDS the ticket's",
187
+ " anchored PR head to whatever the PR's head currently is, for a ticket parked",
188
+ " by a human/external push. Both retry internally on a concurrent row change —",
189
+ " neither accepts, nor ever asks for, a version counter.",
190
+ "",
191
+ "Incident examples:",
192
+ " # A run is misbehaving and must stop immediately:",
193
+ " conductor stop-run --epic-run-id 3f9c2b7e-...",
194
+ " # The run is done for good — close it out:",
195
+ " conductor abandon-run --epic-run-id 3f9c2b7e-...",
196
+ " # A ticket parked itself (needs_human) and the cause is now fixed:",
197
+ " conductor unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
198
+ " # A human pushed directly to the PR and the ticket parked on head drift:",
199
+ " conductor adopt-current-head-and-unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
160
200
  ].join("\n");
161
201
  }
202
+ /**
203
+ * Private diagnostic action (BAPI-772): prints the `conductor-bin.js` path the
204
+ * EXECUTING artifact resolves for hook installation. It exists so a test can run
205
+ * `node build/conductor-bin.js __hook-bin` against the real esbuild bundle and
206
+ * assert the printed path exists — a tsc-emit unit test structurally cannot catch
207
+ * the bundled-layout bug this ticket fixes. Mirrors `plane __entrypoint`.
208
+ */
209
+ export const CONDUCTOR_HOOK_BIN_ACTION = "__hook-bin";
162
210
  const VALID_COMMANDS = new Set([
163
211
  "emit-event",
164
212
  "supervise",
@@ -172,6 +220,15 @@ const VALID_COMMANDS = new Set([
172
220
  "install-git-hooks",
173
221
  "git-hook",
174
222
  "file-scope-guard",
223
+ // BAPI-872: CLI-only operator recovery verbs — never registered as MCP tools.
224
+ "stop-run",
225
+ "abandon-run",
226
+ "unpark",
227
+ "adopt-current-head-and-unpark",
228
+ // Private, and deliberately ABSENT from the usage text: `__hook-bin` exists as
229
+ // the bundled-artifact regression guard for BAPI-772 (mirroring `plane
230
+ // __entrypoint`), not as a supported operator workflow.
231
+ CONDUCTOR_HOOK_BIN_ACTION,
175
232
  ]);
176
233
  /**
177
234
  * Parse the top-level conductor argv into a subcommand (without a CLI
@@ -583,6 +640,11 @@ export async function runDoctorCommand(argv, deps = {}) {
583
640
  * Run `install-git-hooks`: install/update the local managed `post-commit` and
584
641
  * `reference-transaction` hooks. Returns 0 even when the directory is not a git
585
642
  * worktree (degraded optional capability) — never a fatal failure.
643
+ *
644
+ * BAPI-772: an unresolvable `conductor-bin.js` IS fatal (exit 1). Writing a hook
645
+ * that points at a nonexistent binary produced a hook that silently did nothing
646
+ * on every commit, so the installer now refuses and this command reports the
647
+ * refusal with the resolver's searched-layout remediation.
586
648
  */
587
649
  export function runInstallGitHooksCommand(argv) {
588
650
  const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
@@ -591,6 +653,14 @@ export function runInstallGitHooksCommand(argv) {
591
653
  return 0;
592
654
  }
593
655
  const result = installConductorGitHooks();
656
+ if (!result.ok) {
657
+ if (bools.has("--json")) {
658
+ console.error(JSON.stringify({ error: result.error, reason: result.reason }));
659
+ return 1;
660
+ }
661
+ console.error(`Error: conductor git hooks were NOT installed (${result.error}): ${result.reason}`);
662
+ return 1;
663
+ }
594
664
  if (bools.has("--json")) {
595
665
  console.log(JSON.stringify(result));
596
666
  return 0;
@@ -600,6 +670,7 @@ export function runInstallGitHooksCommand(argv) {
600
670
  "───────────────────────────",
601
671
  `is git worktree: ${result.is_worktree}`,
602
672
  `hooks dir: ${result.hooks_dir ?? "n/a"}`,
673
+ `conductor bin: ${result.conductor_bin}`,
603
674
  ];
604
675
  for (const hook of result.installed) {
605
676
  lines.push(` ${hook.name}: ${hook.action}${hook.warning ? ` (${hook.warning})` : ""}`);
@@ -612,6 +683,21 @@ export function runInstallGitHooksCommand(argv) {
612
683
  console.log(lines.join("\n"));
613
684
  return 0;
614
685
  }
686
+ /**
687
+ * Run the private `__hook-bin` diagnostic: resolve the hook binary from the
688
+ * executing artifact and print ONLY that canonical path to stdout, so artifact-
689
+ * level automation can consume it directly. A failed resolution prints the
690
+ * sanitized resolver reason to stderr and returns 1 — never a stack trace.
691
+ */
692
+ export function runHookBinDiagnosticCommand() {
693
+ const resolution = resolveConductorHookBin();
694
+ if (!resolution.ok) {
695
+ console.error(`conductor hook bin UNRESOLVED: ${resolution.reason}`);
696
+ return 1;
697
+ }
698
+ console.log(resolution.path);
699
+ return 0;
700
+ }
615
701
  const GIT_HOOK_VALUE_FLAGS = new Set(["--phase", "--stdin-file"]);
616
702
  const GIT_HOOK_BOOL_FLAGS = new Set(["--help"]);
617
703
  /**
@@ -1104,9 +1190,31 @@ export async function runConductorCli(argv) {
1104
1190
  return runInstallGitHooksCommand(parsed.argv);
1105
1191
  case "git-hook":
1106
1192
  return await runGitHookCommand(parsed.argv);
1193
+ case CONDUCTOR_HOOK_BIN_ACTION:
1194
+ // Dispatched before any MCP server construction or store access: the
1195
+ // guard must be able to run against a bundle whose ledger is absent.
1196
+ return runHookBinDiagnosticCommand();
1107
1197
  case "file-scope-guard":
1108
1198
  // BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
1109
1199
  return runFileScopeGuardCli();
1200
+ case "stop-run": {
1201
+ // BAPI-872: lazily imported so the Bridge credential/HTTP graph never
1202
+ // loads for a local-only command (doctor, emit-event, etc.).
1203
+ const { runStopRunCommand } = await import("./recovery-cli.js");
1204
+ return await runStopRunCommand(parsed.argv);
1205
+ }
1206
+ case "abandon-run": {
1207
+ const { runAbandonRunCommand } = await import("./recovery-cli.js");
1208
+ return await runAbandonRunCommand(parsed.argv);
1209
+ }
1210
+ case "unpark": {
1211
+ const { runUnparkCommand } = await import("./recovery-cli.js");
1212
+ return await runUnparkCommand(parsed.argv);
1213
+ }
1214
+ case "adopt-current-head-and-unpark": {
1215
+ const { runAdoptCurrentHeadAndUnparkCommand } = await import("./recovery-cli.js");
1216
+ return await runAdoptCurrentHeadAndUnparkCommand(parsed.argv);
1217
+ }
1110
1218
  default:
1111
1219
  console.error('Error: Unknown command. Run "conductor --help" for usage.');
1112
1220
  return 1;
@@ -15,6 +15,10 @@ import { doctorConductorLedger } from "./store.js";
15
15
  import { detectClaudeLogin, formatClaudeLoginAdvisory } from "../claude-login.js";
16
16
  import { inspectConductorGitHooks } from "./git-hooks.js";
17
17
  import { resolveMcpShimInvocationForRuntime, resolvePackageRootFromModuleUrl, } from "../mcp-server-invocation.js";
18
+ import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
19
+ import { resolveProfiles } from "../mcp-profile.js";
20
+ /** Explicit display value for an unset `BRIDGE_MCP_PROFILE`. */
21
+ export const MCP_PROFILE_UNSET = "(unset)";
18
22
  /**
19
23
  * Explicit not-run state for the `deny_enforcement` section when the probe is
20
24
  * skipped via `--no-deny-probe`. Enforcement is UNVERIFIED, so the state is
@@ -115,6 +119,66 @@ export async function inspectNativeLedger(nativeLoad, deps = {}) {
115
119
  warnings,
116
120
  };
117
121
  }
122
+ /**
123
+ * Collect the ledger doctor result and its native-ledger inspection together.
124
+ *
125
+ * READ-ONLY and diagnostic only: it never installs or rebuilds
126
+ * `better-sqlite3`, opens a write transaction, creates or migrates the ledger,
127
+ * or alters package state. It may THROW when an injected `doctorLedger` seam
128
+ * throws — {@link collectConductorNativeLedgerSafe} is the containment wrapper
129
+ * for callers that must not see an exception.
130
+ */
131
+ export async function collectConductorNativeLedger(deps = {}) {
132
+ const doctorLedger = deps.doctorLedger ?? doctorConductorLedger;
133
+ const ledger = await doctorLedger();
134
+ const native_ledger = await inspectNativeLedger(ledger.native_load, deps);
135
+ return { ledger, native_ledger };
136
+ }
137
+ /** Map a native-ledger inspection onto the reportable availability shape. */
138
+ export function describeNativeLedgerAvailability(inspection) {
139
+ if (inspection.binding_loads)
140
+ return { status: "available" };
141
+ return {
142
+ status: "degraded",
143
+ module: inspection.module,
144
+ nodeModulesAbi: inspection.node_modules_abi,
145
+ failureKind: inspection.failure_kind,
146
+ };
147
+ }
148
+ /**
149
+ * Sanitized failure-kind token used when the collection itself could not run.
150
+ *
151
+ * A collection failure and a load failure are different facts, so this is its
152
+ * own token rather than a reused one.
153
+ */
154
+ export const NATIVE_LEDGER_UNCOLLECTED_FAILURE_KIND = "inspection_unavailable";
155
+ /**
156
+ * Collect the native-ledger inspection, containing every failure.
157
+ *
158
+ * A thrown collection error yields a GENERIC degraded inspection — no exception
159
+ * message, no absolute package path, no stack trace. An operator learns that the
160
+ * probe could not answer, which is the actionable fact; the raw error is
161
+ * discarded because it can carry a filesystem path.
162
+ */
163
+ export async function collectConductorNativeLedgerSafe(deps = {}) {
164
+ try {
165
+ return (await collectConductorNativeLedger(deps)).native_ledger;
166
+ }
167
+ catch {
168
+ return {
169
+ cli_node_version: process.version,
170
+ engines_node: null,
171
+ mcp_shim_node: null,
172
+ mcp_registration_form: "unknown",
173
+ module: "better-sqlite3",
174
+ node_modules_abi: process.versions.modules,
175
+ binding_loads: false,
176
+ failure_kind: NATIVE_LEDGER_UNCOLLECTED_FAILURE_KIND,
177
+ degraded: true,
178
+ warnings: ["the native ledger inspection could not be collected on this host"],
179
+ };
180
+ }
181
+ }
118
182
  /**
119
183
  * Inspect the local schedule metadata store for a registered epic-tick schedule.
120
184
  * Strictly read-only: composes orchestrateScheduleList (read-only list path) and
@@ -174,10 +238,10 @@ export async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
174
238
  warnings: [
175
239
  "An epic-tick schedule is registered, but the v1 `conductor epic-tick` " +
176
240
  "path is frozen (EPIC_TICK_V1_FROZEN) — it advances nothing. Cancel it: " +
177
- "`npx -y @bridge_gpt/mcp-server schedule-run cancel --id " +
241
+ `\`npx -y ${MCP_PACKAGE_NAME} schedule-run cancel --id ` +
178
242
  `${entry.metadata.id ?? "<id>"}\`. ` +
179
243
  "Epic Conductor v2 reconciles server-side; run jobs locally with " +
180
- "`npx -y @bridge_gpt/mcp-server executor --repo <name>`.",
244
+ `\`npx -y ${MCP_PACKAGE_NAME} executor --repo <name>\`.`,
181
245
  ],
182
246
  };
183
247
  }
@@ -195,22 +259,35 @@ export async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
195
259
  }
196
260
  /**
197
261
  * Inspect the BRIDGE_MCP_PROFILE environment variable and warn when a
198
- * conductor/epic context is detected but the profile is not "conductor".
262
+ * conductor/epic context is detected but the resolved groups omit `conductor`.
199
263
  * Strictly read-only, never throws.
264
+ *
265
+ * BAPI-772: health is judged from {@link resolveProfiles} — the SAME resolver the
266
+ * MCP server registers tools with — instead of comparing the raw string to
267
+ * `"conductor"`. The old equality check called a worker running
268
+ * `BRIDGE_MCP_PROFILE=conductor,sfcc` (exactly what `buildConductorWorkerEnv`
269
+ * produces on SFCC projects) or `full` degraded, and its remediation told the
270
+ * operator to REPLACE the variable — which would have destroyed the worker's
271
+ * other groups. The remediation now says ADD.
200
272
  */
201
273
  export function inspectMcpProfile(env, epicTick) {
202
- const resolved_profile = env.BRIDGE_MCP_PROFILE || "core";
274
+ const raw = env.BRIDGE_MCP_PROFILE;
275
+ const raw_profile = raw === undefined ? MCP_PROFILE_UNSET : raw;
276
+ const groups = resolveProfiles(raw);
277
+ const active_groups = Array.from(groups);
203
278
  const conductor_context_detected = env.BAPI_CONDUCTOR_ENABLED === "1" ||
204
279
  env.BAPI_CONDUCTOR_ENABLED === "true" ||
205
280
  epicTick.registered;
206
- const degraded = conductor_context_detected && resolved_profile !== "conductor";
281
+ const degraded = conductor_context_detected && !groups.has("conductor");
207
282
  const warnings = [];
208
283
  if (degraded) {
209
- warnings.push(`BRIDGE_MCP_PROFILE is "${resolved_profile}" but a conductor context is active. ` +
210
- `Expected "conductor" conductor/event/supervisor tools may be missing. ` +
211
- `Ensure the spawner injects BRIDGE_MCP_PROFILE=conductor into the worker shell environment.`);
284
+ warnings.push(`BRIDGE_MCP_PROFILE is ${raw_profile} (active groups: ${active_groups.join(", ")}) ` +
285
+ `but a conductor context is active. ADD \`conductor\` to BRIDGE_MCP_PROFILE ` +
286
+ `(e.g. \`${raw_profile === MCP_PROFILE_UNSET ? "conductor" : `${raw_profile},conductor`}\`) ` +
287
+ `do NOT replace the value, which would drop the worker's other groups. ` +
288
+ `Without it the conductor/event/supervisor tools are not registered.`);
212
289
  }
213
- return { resolved_profile, conductor_context_detected, degraded, warnings };
290
+ return { raw_profile, active_groups, conductor_context_detected, degraded, warnings };
214
291
  }
215
292
  /**
216
293
  * Read-only probe of the local-merge (F4) capability. Runs `gh --version` and
@@ -277,13 +354,13 @@ export function inspectLocalMerge(runCommand) {
277
354
  * Never mutates the ledger, the hooks, the schema, or the OS scheduler.
278
355
  */
279
356
  export async function buildConductorDoctorReport(deps = {}) {
280
- const doctorLedger = deps.doctorLedger ?? doctorConductorLedger;
281
357
  const inspectHooks = deps.inspectHooks ?? inspectConductorGitHooks;
282
358
  const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
283
359
  const mcp_profile = inspectMcpProfile(deps.env ?? process.env, epicTick);
284
- // Resolve the ledger once so the native-load probe reflects the same report.
285
- const ledger = await doctorLedger();
286
- const native_ledger = await inspectNativeLedger(ledger.native_load, deps);
360
+ // Resolve the ledger once so the native-load probe reflects the same report
361
+ // through the SHARED collector (BAPI-775), so the installer's read-only probe
362
+ // and this report cannot diverge in their interpretation of the binding.
363
+ const { ledger, native_ledger } = await collectConductorNativeLedger(deps);
287
364
  const deny_enforcement = deps.skipDenyProbe
288
365
  ? skippedDenyEnforcementResult()
289
366
  : await inspectDenyEnforcementSafe(deps.inspectDenyEnforcement);
@@ -369,7 +446,11 @@ export function formatConductorDoctorReport(report) {
369
446
  lines.push(`hooks dir: ${git_hooks.hooks_dir ?? "n/a"}`);
370
447
  lines.push(`degraded: ${git_hooks.degraded}`);
371
448
  for (const hook of git_hooks.hooks) {
372
- lines.push(` ${hook.name}: exists=${hook.exists} executable=${hook.executable} managed=${hook.managed_block_present}`);
449
+ // BAPI-772: the embedded target is shown alongside the structural fields —
450
+ // a managed block whose binary no longer exists is a DEAD hook, and this line
451
+ // is where that becomes visible. Advisory only: never changes the exit code.
452
+ lines.push(` ${hook.name}: exists=${hook.exists} executable=${hook.executable} managed=${hook.managed_block_present} ` +
453
+ `bin=${hook.embedded_bin_path ?? "n/a"} bin_is_file=${hook.embedded_bin_is_file}`);
373
454
  }
374
455
  if (git_hooks.warnings.length > 0) {
375
456
  lines.push("git hook warnings:");
@@ -399,12 +480,16 @@ export function formatConductorDoctorReport(report) {
399
480
  lines.push("");
400
481
  lines.push("Epic Conductor v2 reconciles epics server-side — nothing to schedule");
401
482
  lines.push("locally. To execute claimed jobs on this machine, run:");
402
- lines.push(" npx -y @bridge_gpt/mcp-server executor --repo <name>");
483
+ lines.push(` npx -y ${MCP_PACKAGE_NAME} executor --repo <name>`);
403
484
  lines.push("");
404
485
  lines.push("MCP Profile (optional, local)");
405
486
  lines.push("─────────────────────────────");
487
+ // The tag belongs to the RESOLVED interpretation, not the raw string: a raw
488
+ // value of `full` is healthy precisely because it resolves to a set containing
489
+ // `conductor`.
406
490
  const profileTag = mcp_profile.degraded ? "[WARNING] degraded" : "[OK]";
407
- lines.push(`resolved profile: ${mcp_profile.resolved_profile} ${profileTag}`);
491
+ lines.push(`raw profile: ${mcp_profile.raw_profile}`);
492
+ lines.push(`active groups: ${mcp_profile.active_groups.join(", ")} ${profileTag}`);
408
493
  lines.push(`conductor context: ${mcp_profile.conductor_context_detected}`);
409
494
  lines.push(`degraded: ${mcp_profile.degraded}`);
410
495
  if (mcp_profile.warnings.length > 0) {
@@ -19,6 +19,19 @@
19
19
  */
20
20
  import { computeReadySet, decideRemediation, DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS } from "./epic-state.js";
21
21
  import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
22
+ /**
23
+ * The canonical park reason for a stale hold that ran out its deadline.
24
+ *
25
+ * Byte-identical to the Python reconciler's `SHADOW_STALE_DEADLINE_REASON`, and
26
+ * that is the point: both conductors park for the same cause, so an operator
27
+ * greps one string. This introduces no `epic_ticket_status` value — the ticket
28
+ * moves to the EXISTING `needs_human`, whose vocabulary is closed (R14).
29
+ */
30
+ export const SHADOW_STALE_DEADLINE_REASON = "shadow_stale_deadline";
31
+ /** Normalize either accepted seam shape into the richer one. */
32
+ function normalizeShadowDecision(raw) {
33
+ return typeof raw === "string" ? { verdict: raw } : raw;
34
+ }
22
35
  /**
23
36
  * Sanitize a thrown value into a single-line, bounded diagnostic string for a
24
37
  * tick warning (BAPI-487 Requirement 2). Surfaces the real `err.message` (the
@@ -37,6 +50,31 @@ export function safeDiagnosticMessage(err, fallback) {
37
50
  // ---------------------------------------------------------------------------
38
51
  // reconcileEpic
39
52
  // ---------------------------------------------------------------------------
53
+ /**
54
+ * Surface an elapsed scope-freshness deadline without transitioning the ticket.
55
+ *
56
+ * Deliberately diagnostic. The `epic_ticket_status` vocabulary — `parsing`,
57
+ * `needs_human` and the rest — is owned by the SERVER-side reconciler
58
+ * (`api/library/epic_conductor/reconciler.py`), which is the single transition
59
+ * authority for it and already parks this exact condition through its ordinary
60
+ * `_park_needs_human` commit point under the same `shadow_stale_deadline` reason.
61
+ * This pass runs the legacy supervisor's own, narrower status vocabulary, and
62
+ * minting a park from here would give one ticket two transition authorities —
63
+ * the failure mode R14 exists to prevent.
64
+ *
65
+ * So what this does is the honest half: it stops the dispatch (the caller
66
+ * `continue`s), and it makes the expiry and its cause visible in the tick's
67
+ * warnings so an operator watching this side is not left wondering why a ticket
68
+ * sat still. The durable park is observed, not raced for.
69
+ */
70
+ function reportShadowDeadlineExpired(deps, result, ticketKey, verdict, blockedAdvanceReason) {
71
+ const reason = blockedAdvanceReason
72
+ ? `${SHADOW_STALE_DEADLINE_REASON}:${blockedAdvanceReason}`
73
+ : SHADOW_STALE_DEADLINE_REASON;
74
+ result.warnings.push(`shadow-freshness deadline elapsed for ${ticketKey}: ${reason}`);
75
+ deps.log(`[epic-reconcile] ${ticketKey} shadow watermark ${verdict} past deadline (${reason}); ` +
76
+ `holding dispatch — the server reconciler owns the needs_human park`);
77
+ }
40
78
  /**
41
79
  * Execute the deterministic observed→desired reconciliation pass. All I/O is
42
80
  * behind injected seams; the ready-set is computed by pure code (no LLM).
@@ -158,32 +196,47 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
158
196
  // not claim a dispatch key until the backend proves the run's shadow index
159
197
  // covers every declared predecessor's merge watermark. Dependency-free nodes
160
198
  // bypass the gate entirely so independent tickets keep their parallel behavior.
199
+ // BAPI-845: the EXPLICIT non-epic bypass. A ticket that declares no
200
+ // predecessors is not a member of an active epic/scope workflow, so it is
201
+ // exempted here — before any scope-status lookup and before any ticket-status
202
+ // mutation. Stating it rather than letting it fall out of a compound
203
+ // condition is what makes "a scope problem never blocks a non-epic ticket" a
204
+ // property this file asserts instead of one it happens to have: a stale,
205
+ // blocked, or failed scope cannot reach an independent ticket's dispatch at
206
+ // all, because the scope is never consulted for one.
161
207
  const declaredDeps = plan.tickets.find((t) => t.ticket_key === ticketKey)?.depends_on ?? [];
162
- if (declaredDeps.length > 0 && deps.checkShadowFreshness) {
163
- let verdict;
208
+ const participatesInScopeWorkflow = declaredDeps.length > 0;
209
+ if (participatesInScopeWorkflow && deps.checkShadowFreshness) {
210
+ let decision;
164
211
  try {
165
- verdict = await deps.checkShadowFreshness(ticketKey);
212
+ decision = normalizeShadowDecision(await deps.checkShadowFreshness(ticketKey));
166
213
  }
167
214
  catch (err) {
168
215
  // Fail closed: a freshness API error / malformed response never opens the
169
- // gate. Hold this tick without claiming a key or consuming any counter.
170
- verdict = "stale";
216
+ // gate, and never parks either an unread deadline is not an elapsed one.
217
+ // Hold this tick without claiming a key or consuming any counter.
218
+ decision = { verdict: "stale" };
171
219
  deps.log(`[epic-reconcile] shadow-freshness error for ${ticketKey}: ${safeDiagnosticMessage(err, "freshness error")}; holding`);
172
220
  }
173
- if (verdict === "stale") {
174
- // BAPI-678: this covers ordinary unproven freshness AND the backend's
175
- // warn-only downgrade of a `failed` shadow lifecycle. Identical handling
176
- // is intentional either way the watermark is unproven, so hold before
177
- // claiming a dispatch key. The backend clocks the hold and parks the
178
- // ticket if its own stale deadline expires, so "hold" is bounded there
179
- // rather than repeating forever here.
180
- deps.log(`[epic-reconcile] holding ${ticketKey}: shadow watermark stale`);
181
- continue;
182
- }
183
- if (verdict === "failed") {
184
- // Terminal shadow failure the backend has already persisted the run's
185
- // blocked state; emit a diagnostic and skip dispatch (no key claimed).
186
- deps.log(`[epic-reconcile] shadow index failed for ${ticketKey}; run blocked, skipping dispatch`);
221
+ const { verdict } = decision;
222
+ if (verdict === "stale" || verdict === "failed") {
223
+ // BAPI-678: `stale` covers ordinary unproven freshness AND the backend's
224
+ // warn-only downgrade of a `failed` shadow lifecycle; `failed` is the
225
+ // terminal one the backend has already blocked the run for. Both hold.
226
+ //
227
+ // BAPI-845 bounds that hold. While the backend reports the typed
228
+ // `shadow.stale_deadline_seconds` as unexpired the ticket simply waits —
229
+ // it is never dispatched and never routed to the canonical index, so a
230
+ // held ticket cannot quietly research against an index that does not
231
+ // contain its predecessors' merges. A ticket already in `parsing` is left
232
+ // exactly where it is; nothing on this path advances it.
233
+ const blocked = decision.blockedAdvanceReason ?? null;
234
+ const because = blocked ? ` (${blocked})` : "";
235
+ if (decision.deadlineExpired !== true) {
236
+ deps.log(`[epic-reconcile] holding ${ticketKey}: shadow watermark ${verdict}${because}`);
237
+ continue;
238
+ }
239
+ reportShadowDeadlineExpired(deps, result, ticketKey, verdict, blocked);
187
240
  continue;
188
241
  }
189
242
  // "covered" | "not_applicable" fall through to the normal claim/dispatch.
@@ -27,7 +27,7 @@ import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.j
27
27
  import { getHeadSha, getPrNumber } from "./event-accessors.js";
28
28
  import { emitConductorEventIfNew } from "./producer-ledger.js";
29
29
  import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
30
- import { reconcileEpic } from "./epic-reconcile.js";
30
+ import { reconcileEpic, } from "./epic-reconcile.js";
31
31
  import { normalizeDeclaredTouchedFiles } from "./file-scope-guard.js";
32
32
  import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
33
33
  import { sendWorkerMessage } from "./store.js";
@@ -1953,8 +1953,20 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
1953
1953
  // BAPI-654 (T2): backend-authoritative shadow ops. Like fetchPlan, they
1954
1954
  // receive `access` as a parameter, so they close over no credentials.
1955
1955
  reconcileShadowMerge: (acc, ek, mergedTicketKey) => reconcileShadowMerge(acc, { epicKey: ek, mergedTicketKey }),
1956
- checkShadowFreshness: async (acc, ek, tk) => (await fetchShadowDispatchFreshness(acc, { epicKey: ek, ticketKey: tk }))
1957
- .verdict,
1956
+ // BAPI-845: forward the WHOLE decision, not just the verdict. Dropping the
1957
+ // deadline state here is what left the reconcile pass able only to hold
1958
+ // forever; the reason token is what lets it say why.
1959
+ checkShadowFreshness: async (acc, ek, tk) => {
1960
+ const freshness = await fetchShadowDispatchFreshness(acc, {
1961
+ epicKey: ek,
1962
+ ticketKey: tk,
1963
+ });
1964
+ return {
1965
+ verdict: freshness.verdict,
1966
+ deadlineExpired: freshness.deadlineExpired,
1967
+ blockedAdvanceReason: freshness.blockedAdvanceReason,
1968
+ };
1969
+ },
1958
1970
  // BAPI-442 seams are wired at the reconcileDeps level inside runEpicTick
1959
1971
  // (they need the per-tick `access` and `prBindings` closure). The factory
1960
1972
  // returns the dispatchSeam with isReReview support; the other two seams are
@@ -100,6 +100,37 @@ export class ConductorLedgerSubprocessRuntimeError extends Error {
100
100
  this.envVar = envVar;
101
101
  }
102
102
  }
103
+ /** Plain-session tools that answer the same questions without worker context. */
104
+ const WORKER_CONTEXT_ALTERNATIVES = "get_epic_snapshot, poll_events";
105
+ /**
106
+ * Raised (BAPI-772) when a worker-SCOPED operation is called from a plain MCP
107
+ * session — one that was never spawned by the conductor, so the worker
108
+ * environment is ABSENT rather than broken.
109
+ *
110
+ * This is a guidance envelope, not a fault: `check_messages` and the
111
+ * `wait_for_done_gate` emit leg used to surface an opaque validation error or a
112
+ * 503 in an ordinary session, leaving the caller to guess that the tool is
113
+ * worker-scoped at all. The message states where the context comes from and what
114
+ * to call instead, and is explicit that NO functional fallback was performed.
115
+ *
116
+ * Absence only. Worker env that is present but empty, relative, inaccessible, or
117
+ * otherwise invalid keeps the fail-loud
118
+ * {@link ConductorLedgerSubprocessRuntimeError} path — a corrupted runtime must
119
+ * never be relabeled as "you are just in a normal session".
120
+ */
121
+ export class ConductorWorkerContextRequiredError extends Error {
122
+ missingEnvVars;
123
+ constructor(missingEnvVars) {
124
+ // Single-line, path-free, secret-free (the committed CLI-stderr invariant in
125
+ // security-regressions.test.ts requires it). Names only fixed env-var tokens.
126
+ super("This operation is available only inside a conductor-spawned worker session " +
127
+ "(created by `start-tickets --conductor` or an executor job). Missing worker " +
128
+ `context: ${missingEnvVars.join(", ")}. No fallback was performed. In a plain ` +
129
+ `session use ${WORKER_CONTEXT_ALTERNATIVES} instead.`);
130
+ this.name = "ConductorWorkerContextRequiredError";
131
+ this.missingEnvVars = missingEnvVars;
132
+ }
133
+ }
103
134
  /**
104
135
  * Structural guard for a native-module-load failure raised by the conductor
105
136
  * store (BAPI-526). Detected structurally (by `name` + a valid `details` object)
@@ -225,6 +256,22 @@ export function toConductorErrorEnvelope(error) {
225
256
  details: { env_var: error.envVar, reason: error.reason },
226
257
  };
227
258
  }
259
+ // Worker-context guidance (BAPI-772): the caller is in a plain MCP session and
260
+ // the worker environment is ABSENT. A 400 (the module's context-required
261
+ // convention, shared with EPIC_TICK_V1_FROZEN) rather than a 503: nothing is
262
+ // broken and nothing becomes available by retrying. `details` carries only the
263
+ // fixed env-var NAMES — never their values, a path, argv, stderr, or a stack.
264
+ if (error instanceof ConductorWorkerContextRequiredError) {
265
+ return {
266
+ error: "WORKER_CONTEXT_REQUIRED",
267
+ status: 400,
268
+ message: error.message,
269
+ details: {
270
+ missing_env_vars: error.missingEnvVars.join(","),
271
+ alternatives: WORKER_CONTEXT_ALTERNATIVES,
272
+ },
273
+ };
274
+ }
228
275
  if (error instanceof ConductorValidationError) {
229
276
  // Validation messages are conductor-authored and name the offending field,
230
277
  // never its value — but redact defensively so an adversarial/secret-bearing