@bridge_gpt/mcp-server 0.2.39 → 0.2.42

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 (74) hide show
  1. package/README.md +10 -10
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/claude-user-config-doctor.js +42 -11
  5. package/build/cli-release.js +2 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conduct-epic/bridge-client.js +354 -113
  8. package/build/conduct-epic/checkpoint-store.js +75 -2
  9. package/build/conduct-epic/cli.js +795 -109
  10. package/build/conduct-epic/cut-protocol.js +327 -0
  11. package/build/conduct-epic/pr-state.js +113 -24
  12. package/build/conduct-epic/spawn.js +14 -2
  13. package/build/conductor/bridge-api-client.js +27 -1
  14. package/build/conductor/cli.js +46 -1
  15. package/build/conductor/doctor.js +101 -16
  16. package/build/conductor/epic-reconcile.js +72 -19
  17. package/build/conductor/epic-runtime.js +15 -3
  18. package/build/conductor/errors.js +47 -0
  19. package/build/conductor/git-hooks.js +205 -11
  20. package/build/conductor/install-doctor.js +230 -1
  21. package/build/conductor/local-merge.js +130 -28
  22. package/build/conductor/tools.js +32 -3
  23. package/build/conductor/worker-ledger-cli.js +27 -1
  24. package/build/conductor-bin.js +15 -15
  25. package/build/credentials-cli.js +3 -2
  26. package/build/doctor.js +107 -41
  27. package/build/executor/cli.js +48 -1
  28. package/build/executor/env.js +21 -0
  29. package/build/executor/index-scope.js +39 -0
  30. package/build/executor/job-log-registry.js +69 -0
  31. package/build/executor/job-runner.js +148 -26
  32. package/build/executor/live-worker-registry.js +83 -0
  33. package/build/executor/observation.js +167 -6
  34. package/build/executor/platform.js +147 -3
  35. package/build/executor/process.js +58 -14
  36. package/build/executor/runner.js +235 -48
  37. package/build/executor/test-clock.js +3 -2
  38. package/build/index-scope-contract.js +96 -0
  39. package/build/index.js +153 -204
  40. package/build/init.js +83 -22
  41. package/build/install-bridge-conductor.js +323 -14
  42. package/build/install-bridge.js +202 -38
  43. package/build/install-doctor.js +23 -9
  44. package/build/install-reexec.js +2 -1
  45. package/build/launcher-config-inspection.js +83 -22
  46. package/build/mcp-host-config.js +331 -67
  47. package/build/mcp-host-targets.js +45 -21
  48. package/build/mcp-identity.js +92 -0
  49. package/build/mcp-install-state.js +94 -1
  50. package/build/mcp-invoke.js +2 -1
  51. package/build/mcp-provisioning.js +45 -12
  52. package/build/mcp-registration-doctor.js +35 -13
  53. package/build/mcp-server-invocation.js +4 -2
  54. package/build/merge-pull-request.js +208 -9
  55. package/build/pipelines.generated.js +3 -3
  56. package/build/plane/defaults.js +4 -1
  57. package/build/plane/preflight.js +81 -10
  58. package/build/plane/test-fakes.js +9 -1
  59. package/build/readme.generated.js +1 -1
  60. package/build/regression-check.js +3 -2
  61. package/build/review-tickets.js +8 -7
  62. package/build/run-unit-tests-launcher.js +74 -1
  63. package/build/schedule-run.js +3 -2
  64. package/build/setup-epic.js +453 -78
  65. package/build/sfcc/tool-wrapper.js +15 -0
  66. package/build/start-tickets-prereqs.js +11 -6
  67. package/build/start-tickets.js +91 -85
  68. package/build/update-check.js +3 -2
  69. package/build/upgrade-advice.js +2 -1
  70. package/build/upgrade-cli.js +50 -18
  71. package/build/version.generated.js +1 -1
  72. package/docs/CONDUCTOR.md +22 -0
  73. package/docs/install/mcp-tool-integrations.md +19 -3
  74. package/package.json +2 -2
@@ -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";
@@ -159,6 +159,14 @@ export function getConductorUsage() {
159
159
  " conductor epic-status --epic-key EPIC-405 --json",
160
160
  ].join("\n");
161
161
  }
162
+ /**
163
+ * Private diagnostic action (BAPI-772): prints the `conductor-bin.js` path the
164
+ * EXECUTING artifact resolves for hook installation. It exists so a test can run
165
+ * `node build/conductor-bin.js __hook-bin` against the real esbuild bundle and
166
+ * assert the printed path exists — a tsc-emit unit test structurally cannot catch
167
+ * the bundled-layout bug this ticket fixes. Mirrors `plane __entrypoint`.
168
+ */
169
+ export const CONDUCTOR_HOOK_BIN_ACTION = "__hook-bin";
162
170
  const VALID_COMMANDS = new Set([
163
171
  "emit-event",
164
172
  "supervise",
@@ -172,6 +180,10 @@ const VALID_COMMANDS = new Set([
172
180
  "install-git-hooks",
173
181
  "git-hook",
174
182
  "file-scope-guard",
183
+ // Private, and deliberately ABSENT from the usage text: `__hook-bin` exists as
184
+ // the bundled-artifact regression guard for BAPI-772 (mirroring `plane
185
+ // __entrypoint`), not as a supported operator workflow.
186
+ CONDUCTOR_HOOK_BIN_ACTION,
175
187
  ]);
176
188
  /**
177
189
  * Parse the top-level conductor argv into a subcommand (without a CLI
@@ -583,6 +595,11 @@ export async function runDoctorCommand(argv, deps = {}) {
583
595
  * Run `install-git-hooks`: install/update the local managed `post-commit` and
584
596
  * `reference-transaction` hooks. Returns 0 even when the directory is not a git
585
597
  * worktree (degraded optional capability) — never a fatal failure.
598
+ *
599
+ * BAPI-772: an unresolvable `conductor-bin.js` IS fatal (exit 1). Writing a hook
600
+ * that points at a nonexistent binary produced a hook that silently did nothing
601
+ * on every commit, so the installer now refuses and this command reports the
602
+ * refusal with the resolver's searched-layout remediation.
586
603
  */
587
604
  export function runInstallGitHooksCommand(argv) {
588
605
  const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
@@ -591,6 +608,14 @@ export function runInstallGitHooksCommand(argv) {
591
608
  return 0;
592
609
  }
593
610
  const result = installConductorGitHooks();
611
+ if (!result.ok) {
612
+ if (bools.has("--json")) {
613
+ console.error(JSON.stringify({ error: result.error, reason: result.reason }));
614
+ return 1;
615
+ }
616
+ console.error(`Error: conductor git hooks were NOT installed (${result.error}): ${result.reason}`);
617
+ return 1;
618
+ }
594
619
  if (bools.has("--json")) {
595
620
  console.log(JSON.stringify(result));
596
621
  return 0;
@@ -600,6 +625,7 @@ export function runInstallGitHooksCommand(argv) {
600
625
  "───────────────────────────",
601
626
  `is git worktree: ${result.is_worktree}`,
602
627
  `hooks dir: ${result.hooks_dir ?? "n/a"}`,
628
+ `conductor bin: ${result.conductor_bin}`,
603
629
  ];
604
630
  for (const hook of result.installed) {
605
631
  lines.push(` ${hook.name}: ${hook.action}${hook.warning ? ` (${hook.warning})` : ""}`);
@@ -612,6 +638,21 @@ export function runInstallGitHooksCommand(argv) {
612
638
  console.log(lines.join("\n"));
613
639
  return 0;
614
640
  }
641
+ /**
642
+ * Run the private `__hook-bin` diagnostic: resolve the hook binary from the
643
+ * executing artifact and print ONLY that canonical path to stdout, so artifact-
644
+ * level automation can consume it directly. A failed resolution prints the
645
+ * sanitized resolver reason to stderr and returns 1 — never a stack trace.
646
+ */
647
+ export function runHookBinDiagnosticCommand() {
648
+ const resolution = resolveConductorHookBin();
649
+ if (!resolution.ok) {
650
+ console.error(`conductor hook bin UNRESOLVED: ${resolution.reason}`);
651
+ return 1;
652
+ }
653
+ console.log(resolution.path);
654
+ return 0;
655
+ }
615
656
  const GIT_HOOK_VALUE_FLAGS = new Set(["--phase", "--stdin-file"]);
616
657
  const GIT_HOOK_BOOL_FLAGS = new Set(["--help"]);
617
658
  /**
@@ -1104,6 +1145,10 @@ export async function runConductorCli(argv) {
1104
1145
  return runInstallGitHooksCommand(parsed.argv);
1105
1146
  case "git-hook":
1106
1147
  return await runGitHookCommand(parsed.argv);
1148
+ case CONDUCTOR_HOOK_BIN_ACTION:
1149
+ // Dispatched before any MCP server construction or store access: the
1150
+ // guard must be able to run against a bundle whose ledger is absent.
1151
+ return runHookBinDiagnosticCommand();
1107
1152
  case "file-scope-guard":
1108
1153
  // BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
1109
1154
  return runFileScopeGuardCli();
@@ -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