@bridge_gpt/mcp-server 0.2.16 → 0.2.19

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 (103) hide show
  1. package/CONDUCTOR.md +75 -0
  2. package/README.md +2 -2
  3. package/build/agent-capabilities/probe-context.js +13 -3
  4. package/build/agent-capabilities/probes.js +262 -11
  5. package/build/agent-capabilities/reporter.js +1 -0
  6. package/build/agents.generated.js +3 -3
  7. package/build/backend-warnings.js +44 -0
  8. package/build/claude-settings.js +129 -0
  9. package/build/commands.generated.js +7 -6
  10. package/build/conductor/bridge-api-client.js +198 -18
  11. package/build/conductor/claude-hook.js +22 -4
  12. package/build/conductor/cli.js +76 -25
  13. package/build/conductor/deny-enforcement-preflight.js +96 -0
  14. package/build/conductor/doctor.js +183 -2
  15. package/build/conductor/done-gate.js +5 -0
  16. package/build/conductor/epic-reconcile.js +71 -14
  17. package/build/conductor/epic-runtime.js +839 -67
  18. package/build/conductor/epic-state.js +524 -63
  19. package/build/conductor/errors.js +156 -3
  20. package/build/conductor/event-accessors.js +252 -0
  21. package/build/conductor/file-scope-guard.js +201 -0
  22. package/build/conductor/github-mergeability.js +85 -0
  23. package/build/conductor/local-merge.js +47 -1
  24. package/build/conductor/merge-identity.js +41 -0
  25. package/build/conductor/merge-ledger.js +19 -72
  26. package/build/conductor/plan.js +12 -2
  27. package/build/conductor/pr-ci-producer.js +17 -2
  28. package/build/conductor/pr-discovery.js +11 -1
  29. package/build/conductor/producer-ledger.js +1 -1
  30. package/build/conductor/store.js +161 -18
  31. package/build/conductor/supervisor-config.js +4 -39
  32. package/build/conductor/supervisor-escalation.js +10 -26
  33. package/build/conductor/supervisor-ledger.js +5 -12
  34. package/build/conductor/supervisor-merge.js +32 -5
  35. package/build/conductor/supervisor-message-relay.js +2 -5
  36. package/build/conductor/supervisor-notification.js +1 -1
  37. package/build/conductor/supervisor-runtime.js +12 -54
  38. package/build/conductor/supervisor-state.js +4 -18
  39. package/build/conductor/supervisor-types.js +2 -2
  40. package/build/conductor/taxonomy.js +12 -0
  41. package/build/conductor/tools.js +28 -6
  42. package/build/conductor/worker-ledger-cli.js +244 -0
  43. package/build/conductor-bin.js +1800 -5166
  44. package/build/conductor-claude-hook-bin.js +4 -2
  45. package/build/doctor.js +40 -0
  46. package/build/executor/cli.js +229 -0
  47. package/build/executor/credentials.js +65 -0
  48. package/build/executor/deps.js +117 -0
  49. package/build/executor/env.js +79 -0
  50. package/build/executor/heartbeat.js +59 -0
  51. package/build/executor/http-client.js +131 -0
  52. package/build/executor/index.js +10 -0
  53. package/build/executor/job-errors.js +55 -0
  54. package/build/executor/job-log-registry.js +110 -0
  55. package/build/executor/job-runner.js +688 -0
  56. package/build/executor/job-types.js +60 -0
  57. package/build/executor/merge-job.js +155 -0
  58. package/build/executor/observation.js +123 -0
  59. package/build/executor/permissions.js +79 -0
  60. package/build/executor/preflight.js +144 -0
  61. package/build/executor/process.js +81 -0
  62. package/build/executor/prompt-spec.js +235 -0
  63. package/build/executor/results.js +134 -0
  64. package/build/executor/resume-pre-spawn.js +179 -0
  65. package/build/executor/runner.js +98 -0
  66. package/build/executor/terminal-mutation.js +34 -0
  67. package/build/executor/test-clock.js +109 -0
  68. package/build/executor/types.js +18 -0
  69. package/build/executor/verdict-artifact.js +53 -0
  70. package/build/executor/viewer-tabs.js +78 -0
  71. package/build/executor/watch-cli.js +113 -0
  72. package/build/executor/worker-command.js +106 -0
  73. package/build/executor/worker-finalization.js +97 -0
  74. package/build/executor/worker-log.js +92 -0
  75. package/build/executor/worktree-gc.js +134 -0
  76. package/build/executor/worktree-inspection.js +86 -0
  77. package/build/executor/worktree.js +103 -0
  78. package/build/index.js +13950 -9669
  79. package/build/install-bridge.js +25 -8
  80. package/build/install-doctor.js +387 -0
  81. package/build/mcp-invoke.js +19 -3
  82. package/build/mcp-provisioning.js +31 -25
  83. package/build/mcp-registration-doctor.js +27 -7
  84. package/build/mcp-server-invocation.js +152 -0
  85. package/build/pipelines.generated.js +31 -6
  86. package/build/readme.generated.js +1 -1
  87. package/build/regression-check.js +53 -1
  88. package/build/review-tickets.js +175 -21
  89. package/build/sfcc/reads-site-preference.js +52 -19
  90. package/build/start-tickets-conductor.js +47 -99
  91. package/build/start-tickets-prereqs.js +185 -4
  92. package/build/start-tickets.js +218 -180
  93. package/build/version.generated.js +1 -1
  94. package/build/visual-diff-worker.js +313 -0
  95. package/build/visual-diff.js +632 -0
  96. package/build/worktree-core.js +202 -0
  97. package/package.json +10 -6
  98. package/pipelines/review-ticket.json +24 -2
  99. package/public/css/main.min.css +3311 -1
  100. package/public/css/main.min.css.map +1 -1
  101. package/public/js/main.min.js +7924 -1
  102. package/public/js/main.min.js.map +1 -1
  103. package/smoke-test/SMOKE-TEST.md +5 -2
@@ -15,10 +15,12 @@
15
15
  * This path is worktree-free and base-branch-free; the human CLI path leaves
16
16
  * `epic` unset and retains byte-for-byte identical behavior.
17
17
  */
18
- import { createDefaultStartTicketsDeps, detectTerminal, runWithConcurrency, buildAgentInvocation, shSquoteInner, powershellSquote, isCommandOnPath, resolveFirstCommandOnPath, tmuxSessionNameForTicket, TICKET_KEY_PATTERN, DEFAULT_MAX_PARALLEL, } from "./start-tickets.js";
18
+ import { createDefaultStartTicketsDeps, detectTerminal, runWithConcurrency, buildAgentInvocation, shSquoteInner, powershellSquote, isCommandOnPath, resolveFirstCommandOnPath, tmuxSessionNameForTicket, TICKET_KEY_PATTERN, DEFAULT_MAX_PARALLEL, validateBranchName, fetchAndResolveBaseSha, fetchStartTicketsConfigField, resolveStartTicketsBridgeApiAccess, } from "./start-tickets.js";
19
19
  import { WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, TMUX_COMMAND, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, } from "./start-tickets-prereqs.js";
20
20
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, isValidModelAlias, } from "./agent-registry.js";
21
21
  import { injectConductorEnvIntoShellCommand, buildEpicIdentityEnv, mintStartTicketsRunId, } from "./start-tickets-conductor.js";
22
+ /** Default base context: no flags threaded through (matches pre-BAPI-474 behavior). */
23
+ const NO_BASE_CONTEXT = { noRefreshBase: false };
22
24
  // ---------------------------------------------------------------------------
23
25
  // Usage
24
26
  // ---------------------------------------------------------------------------
@@ -35,6 +37,8 @@ export function getReviewTicketsUsage() {
35
37
  " --model VALUE Model alias to pass as --model to the agent (optional passthrough)",
36
38
  " --max-parallel N Max tabs to spawn concurrently (default: 3)",
37
39
  " --dry-run Print intended commands; creates no tabs and opens no sessions",
40
+ " --base-branch BRANCH Base branch to fetch+pin for grounding (default: config base_branch, else \"main\")",
41
+ " --no-refresh-base Skip the parent-fetch-once base pin; restores git-free, in-place-grounded behavior",
38
42
  " -h, --help Show this help",
39
43
  "",
40
44
  "Examples:",
@@ -42,19 +46,107 @@ export function getReviewTicketsUsage() {
42
46
  " npx -y @bridge_gpt/mcp-server review-tickets --auto --rounds=1 BAPI-1 BAPI-2",
43
47
  " npx -y @bridge_gpt/mcp-server review-tickets --review BAPI-1=auto,rounds=1 --review BAPI-2=rounds=2 BAPI-1 BAPI-2",
44
48
  " npx -y @bridge_gpt/mcp-server review-tickets --dry-run BAPI-1 BAPI-2",
49
+ " npx -y @bridge_gpt/mcp-server review-tickets --base-branch=develop BAPI-1 BAPI-2",
50
+ " npx -y @bridge_gpt/mcp-server review-tickets --no-refresh-base BAPI-1 BAPI-2",
45
51
  "",
46
52
  "Each KEY must match [A-Z]+-[0-9]+ (e.g., BAPI-248).",
47
53
  "",
48
- "Prerequisites (terminal launcher only — no wt, git-wt, git, or Worktrunk required):",
49
- " macOS osascript",
50
- " Windows wt.exe or PowerShell",
51
- " Linux tmux",
54
+ "Prerequisites (terminal launcher, plus git — no wt, git-wt, or Worktrunk required):",
55
+ " macOS osascript, git",
56
+ " Windows wt.exe or PowerShell, git",
57
+ " Linux tmux, git",
52
58
  "",
53
- "review-tickets runs all tabs in the current repository cwd and creates no Worktrunk worktrees.",
54
- "--dry-run works on any platform, including unsupported ones.",
59
+ "review-tickets runs all tabs in the current repository cwd and creates no Worktrunk worktrees. It now",
60
+ "requires git on PATH (BAPI-474): before spawning tabs it fetches origin/<base_branch> once and pins a",
61
+ "single base_sha for the whole batch, so every spawned review grounds against the same base tree even if",
62
+ "origin advances mid-batch. Pass --no-refresh-base to skip this and restore the prior git-free behavior.",
63
+ "--dry-run works on any platform, including unsupported ones, and never performs the base-branch fetch.",
55
64
  ].join("\n");
56
65
  }
57
66
  // ---------------------------------------------------------------------------
67
+ // Fail-loud base-fetch diagnostic (BAPI-474 D-4)
68
+ // ---------------------------------------------------------------------------
69
+ const ANSI_BOLD = "\x1b[1m";
70
+ // Warm Coral — a high-contrast 256-color red/coral used exclusively for this
71
+ // fail-loud alert box so a base-branch fetch failure is unmistakable in a scan
72
+ // of scrollback, without hijacking the terminal's general error-red semantics.
73
+ const ANSI_WARM_CORAL = "\x1b[38;5;203m";
74
+ const ANSI_RESET = "\x1b[0m";
75
+ /**
76
+ * Render a bordered, high-contrast fail-loud alert box for a parent-fetch-once
77
+ * failure (BAPI-474 D-4 "Design Direction Fail-Loud"): an operation-failed
78
+ * header, the target base branch, and an actionable remediation line. Pure
79
+ * formatting (no I/O) so it stays unit-testable; the caller is responsible for
80
+ * writing it to stderr.
81
+ */
82
+ export function formatBaseFetchFailureBox(baseBranch, error) {
83
+ const title = "review-tickets: base-branch fetch failed";
84
+ // The box always shows a dedicated Remediation line below. fetchAndResolveBaseSha's
85
+ // own error text embeds the same remediation clause because it also has to stand
86
+ // alone when surfaced outside a box (e.g. raw JSON from materialize_fresh_base) —
87
+ // strip it here so the boxed rendering doesn't say it twice.
88
+ const reason = error
89
+ .replace(/\s*Check your network and 'git remote get-url origin', or pass --no-refresh-base to skip\.?\s*$/i, "")
90
+ .trim();
91
+ const bodyLines = [
92
+ `Target base branch: origin/${baseBranch}`,
93
+ `Reason: ${reason}`,
94
+ "Remediation: check your network and 'git remote get-url origin', or rerun with --no-refresh-base to evaluate in-place.",
95
+ ];
96
+ const contentWidth = Math.max(title.length + 2, ...bodyLines.map((l) => l.length)) + 2;
97
+ const horizontal = "─".repeat(contentWidth);
98
+ const pad = (s) => `│ ${s}${" ".repeat(contentWidth - s.length - 1)}│`;
99
+ const lines = [
100
+ `╭${horizontal}╮`,
101
+ pad(`✖ ${title}`),
102
+ `├${horizontal}┤`,
103
+ ...bodyLines.map((l) => pad(l)),
104
+ `╰${horizontal}╯`,
105
+ ];
106
+ return `${ANSI_BOLD}${ANSI_WARM_CORAL}${lines.join("\n")}${ANSI_RESET}`;
107
+ }
108
+ /**
109
+ * Resolve ONE base_branch/base_sha pin for the whole review-tickets batch
110
+ * BEFORE any tab is spawned (D-3), so a mid-batch `origin` advance can never
111
+ * mix bases within one run. Skipped entirely (never fetches) when
112
+ * `--no-refresh-base` was passed. base_branch precedence: `--base-branch` flag
113
+ * > config field `base_branch` (BAPI-313) > `"main"`. The config-field lookup
114
+ * is best-effort — any failure there falls through to `"main"`; only the git
115
+ * fetch/resolve itself is a hard failure (the caller applies the D-4 fail-loud
116
+ * response to that).
117
+ */
118
+ export async function resolveBatchBaseContext(deps, options) {
119
+ if (options.noRefreshBase) {
120
+ return { ok: true, context: { noRefreshBase: true } };
121
+ }
122
+ let effectiveBaseBranch = (options.baseBranch ?? "").trim();
123
+ if (effectiveBaseBranch.length === 0) {
124
+ try {
125
+ const accessResult = await resolveStartTicketsBridgeApiAccess(deps);
126
+ if (accessResult.ok) {
127
+ const configValue = await fetchStartTicketsConfigField(accessResult.access, "base_branch");
128
+ if (typeof configValue === "string" && configValue.trim().length > 0) {
129
+ effectiveBaseBranch = configValue.trim();
130
+ }
131
+ }
132
+ }
133
+ catch {
134
+ /* config-field lookup is best-effort; fall through to the "main" default */
135
+ }
136
+ }
137
+ if (effectiveBaseBranch.length === 0) {
138
+ effectiveBaseBranch = "main";
139
+ }
140
+ const fetchResult = await fetchAndResolveBaseSha(deps, effectiveBaseBranch);
141
+ if (!fetchResult.ok) {
142
+ return { ok: false, baseBranch: effectiveBaseBranch, error: fetchResult.error };
143
+ }
144
+ return {
145
+ ok: true,
146
+ context: { baseBranch: effectiveBaseBranch, baseSha: fetchResult.base_sha, noRefreshBase: false },
147
+ };
148
+ }
149
+ // ---------------------------------------------------------------------------
58
150
  // Argument parsing
59
151
  // ---------------------------------------------------------------------------
60
152
  export function parseReviewOverrideEntry(entry, requestedKeys) {
@@ -105,6 +197,8 @@ export function parseReviewTicketsArgs(argv) {
105
197
  let maxParallelRaw;
106
198
  let agentName = DEFAULT_AGENT_NAME;
107
199
  let modelAlias;
200
+ let noRefreshBase = false;
201
+ let baseBranch;
108
202
  const keys = [];
109
203
  const rawReviewEntries = [];
110
204
  for (let i = 0; i < argv.length; i++) {
@@ -123,6 +217,30 @@ export function parseReviewTicketsArgs(argv) {
123
217
  globalAuto = true;
124
218
  continue;
125
219
  }
220
+ if (arg === "--no-refresh-base") {
221
+ noRefreshBase = true;
222
+ continue;
223
+ }
224
+ if (arg === "--base-branch" || arg.startsWith("--base-branch=")) {
225
+ let value;
226
+ if (arg.startsWith("--base-branch=")) {
227
+ value = arg.slice("--base-branch=".length);
228
+ }
229
+ else {
230
+ const next = i + 1 < argv.length ? argv[i + 1] : undefined;
231
+ if (next === undefined || next.startsWith("-")) {
232
+ return { status: "error", message: "--base-branch requires a value (a branch name)." };
233
+ }
234
+ value = takeValue();
235
+ }
236
+ const trimmed = (value ?? "").trim();
237
+ const error = validateBranchName(trimmed);
238
+ if (error) {
239
+ return { status: "error", message: `Invalid --base-branch value: ${error}` };
240
+ }
241
+ baseBranch = trimmed;
242
+ continue;
243
+ }
126
244
  if (arg === "--rounds" || arg.startsWith("--rounds=")) {
127
245
  let value;
128
246
  if (arg.startsWith("--rounds=")) {
@@ -267,6 +385,8 @@ export function parseReviewTicketsArgs(argv) {
267
385
  auto: globalAuto,
268
386
  rounds: globalRounds,
269
387
  reviewOverrides,
388
+ noRefreshBase,
389
+ baseBranch,
270
390
  },
271
391
  };
272
392
  }
@@ -289,24 +409,27 @@ export function resolveEffectiveReviewMode(key, options) {
289
409
  // hard-wired to `/implement-ticket`. Review-aware builders are used instead so
290
410
  // the prompt uses `/review-ticket` and no directory change is prepended.
291
411
  // ---------------------------------------------------------------------------
292
- export function buildReviewTicketPrompt(key, mode) {
412
+ export function buildReviewTicketPrompt(key, mode, base = NO_BASE_CONTEXT) {
293
413
  const autoFlag = mode.auto ? " --auto" : "";
294
- return `/review-ticket ${key}${autoFlag} --rounds=${mode.rounds}`;
414
+ const baseBranchFlag = base.baseBranch ? ` --base-branch=${base.baseBranch}` : "";
415
+ const baseShaFlag = base.baseSha ? ` --base-sha=${base.baseSha}` : "";
416
+ const noRefreshFlag = base.noRefreshBase ? " --no-refresh-base" : "";
417
+ return `/review-ticket ${key}${autoFlag} --rounds=${mode.rounds}${baseBranchFlag}${baseShaFlag}${noRefreshFlag}`;
295
418
  }
296
- export function buildPosixReviewAgentShellCommand(agent, key, mode, cwd, modelAlias) {
297
- const prompt = buildReviewTicketPrompt(key, mode);
419
+ export function buildPosixReviewAgentShellCommand(agent, key, mode, cwd, modelAlias, base = NO_BASE_CONTEXT) {
420
+ const prompt = buildReviewTicketPrompt(key, mode, base);
298
421
  const invocation = buildAgentInvocation(agent, prompt, (p) => `'${shSquoteInner(p)}'`, modelAlias);
299
422
  return `cd '${shSquoteInner(cwd)}' && ${invocation}`;
300
423
  }
301
- export function buildPowerShellReviewAgentShellCommand(agent, key, mode, modelAlias) {
302
- const prompt = buildReviewTicketPrompt(key, mode);
424
+ export function buildPowerShellReviewAgentShellCommand(agent, key, mode, modelAlias, base = NO_BASE_CONTEXT) {
425
+ const prompt = buildReviewTicketPrompt(key, mode, base);
303
426
  return buildAgentInvocation(agent, prompt, powershellSquote, modelAlias);
304
427
  }
305
- export function buildReviewAgentShellCommand(agent, key, mode, platform, cwd, modelAlias) {
428
+ export function buildReviewAgentShellCommand(agent, key, mode, platform, cwd, modelAlias, base = NO_BASE_CONTEXT) {
306
429
  if (platform === "win32") {
307
- return buildPowerShellReviewAgentShellCommand(agent, key, mode, modelAlias);
430
+ return buildPowerShellReviewAgentShellCommand(agent, key, mode, modelAlias, base);
308
431
  }
309
- return buildPosixReviewAgentShellCommand(agent, key, mode, cwd, modelAlias);
432
+ return buildPosixReviewAgentShellCommand(agent, key, mode, cwd, modelAlias, base);
310
433
  }
311
434
  // ---------------------------------------------------------------------------
312
435
  // Unsupported platform message for review-tickets
@@ -325,6 +448,16 @@ export async function runReviewTicketsPreflight(deps, options) {
325
448
  if (!isSupportedStartTicketsPlatform(deps.platform)) {
326
449
  return { ok: false, error: unsupportedReviewTicketsPlatformMessage(deps.platform) };
327
450
  }
451
+ // BAPI-474: the parent-fetch-once base pin adds a git prerequisite unless the
452
+ // caller opted out via --no-refresh-base. Checked here (before any tab is
453
+ // spawned) so a missing git fails loud with a clear, actionable message.
454
+ if (!options.noRefreshBase && !(await isCommandOnPath(deps, "git"))) {
455
+ return {
456
+ ok: false,
457
+ error: "git is required for review-tickets' parent-fetch-once base-branch pin (BAPI-474) but was not " +
458
+ "found on PATH. Install git, or rerun with --no-refresh-base to skip the fetch and evaluate in-place.",
459
+ };
460
+ }
328
461
  if (deps.platform === "darwin") {
329
462
  if (await isCommandOnPath(deps, "osascript"))
330
463
  return { ok: true };
@@ -355,10 +488,10 @@ export async function runReviewTicketsPreflight(deps, options) {
355
488
  // ---------------------------------------------------------------------------
356
489
  // Plan rows + orchestration
357
490
  // ---------------------------------------------------------------------------
358
- export function buildReviewPlanRows(deps, options, agent, status, overrides = {}) {
491
+ export function buildReviewPlanRows(deps, options, agent, status, overrides = {}, baseContext = NO_BASE_CONTEXT) {
359
492
  return options.keys.map((key) => {
360
493
  const mode = resolveEffectiveReviewMode(key, options);
361
- const baseCommand = buildReviewAgentShellCommand(agent, key, mode, deps.platform, deps.cwd, options.modelAlias);
494
+ const baseCommand = buildReviewAgentShellCommand(agent, key, mode, deps.platform, deps.cwd, options.modelAlias, baseContext);
362
495
  let command = baseCommand;
363
496
  let runId;
364
497
  if (options.epic) {
@@ -379,6 +512,14 @@ export function buildReviewPlanRows(deps, options, agent, status, overrides = {}
379
512
  };
380
513
  });
381
514
  }
515
+ /** Build a dry-run preview base context from parsed CLI options only — never fetches. */
516
+ function previewBaseContextFromOptions(options) {
517
+ if (options.noRefreshBase)
518
+ return { noRefreshBase: true };
519
+ if (options.baseBranch)
520
+ return { baseBranch: options.baseBranch, noRefreshBase: false };
521
+ return NO_BASE_CONTEXT;
522
+ }
382
523
  export async function orchestrateReviewTickets(deps, options, overrides = {}) {
383
524
  const agent = resolveAgentSpec(options.agentName);
384
525
  if (!agent) {
@@ -395,7 +536,9 @@ export async function orchestrateReviewTickets(deps, options, overrides = {}) {
395
536
  "and would be re-claimed for each key in the batch. Call orchestrateReviewTickets once per ticket instead.",
396
537
  };
397
538
  }
398
- const rows = buildReviewPlanRows(deps, options, agent, "dry-run", overrides);
539
+ // --dry-run never performs the base-branch fetch (side-effect-free preview);
540
+ // it reflects only what the user explicitly passed on the CLI.
541
+ const rows = buildReviewPlanRows(deps, options, agent, "dry-run", overrides, previewBaseContextFromOptions(options));
399
542
  return { ok: true, rows };
400
543
  }
401
544
  const preflight = await runReviewTicketsPreflight(deps, options);
@@ -411,10 +554,21 @@ export async function orchestrateReviewTickets(deps, options, overrides = {}) {
411
554
  "and would be re-claimed for each key in the batch. Call orchestrateReviewTickets once per ticket instead.",
412
555
  };
413
556
  }
557
+ // BAPI-474 D-3: parent-fetch-once — resolve ONE base_branch/base_sha pin for
558
+ // the whole batch before any tab is spawned. D-4: on failure (and no
559
+ // --no-refresh-base), fail loud with a bordered diagnostic and halt the
560
+ // fan-out entirely rather than silently grounding in place.
561
+ const resolveContext = overrides.resolveBaseContext ?? resolveBatchBaseContext;
562
+ const baseContextResult = await resolveContext(deps, options);
563
+ if (!baseContextResult.ok) {
564
+ console.error(formatBaseFetchFailureBox(baseContextResult.baseBranch, baseContextResult.error));
565
+ return { ok: false, error: baseContextResult.error };
566
+ }
567
+ const baseContext = baseContextResult.context;
414
568
  const terminal = detectTerminal(undefined, deps.env);
415
569
  const rows = await runWithConcurrency(options.keys, options.maxParallel, async (key) => {
416
570
  const mode = resolveEffectiveReviewMode(key, options);
417
- const baseShellCommand = buildReviewAgentShellCommand(agent, key, mode, deps.platform, deps.cwd, options.modelAlias);
571
+ const baseShellCommand = buildReviewAgentShellCommand(agent, key, mode, deps.platform, deps.cwd, options.modelAlias, baseContext);
418
572
  let shellCommand = baseShellCommand;
419
573
  let runId;
420
574
  if (options.epic) {
@@ -565,7 +719,7 @@ export async function runReviewTicketsCli(argv, overrides = {}) {
565
719
  errorLog(`Error: Unknown agent: '${options.agentName}'. Valid agents: ${formatValidAgentNames()}.`);
566
720
  return 1;
567
721
  }
568
- const rows = buildReviewPlanRows(deps, options, agent, "dry-run", overrides.orchestrateOverrides);
722
+ const rows = buildReviewPlanRows(deps, options, agent, "dry-run", overrides.orchestrateOverrides, previewBaseContextFromOptions(options));
569
723
  for (const row of rows) {
570
724
  log(`DRY-RUN: ${row.key} -> ${row.command}`);
571
725
  }
@@ -2,10 +2,11 @@
2
2
  * SFCC site-preference introspection read tools (BAPI-403, T6 — optional).
3
3
  *
4
4
  * Implements:
5
- * site_preference_get — GET /site_preferences/preference_groups/{group}/{instance}
6
- * site_preference_search — POST /site_preferences/preference_groups/{group}/{instance}/preference_search
5
+ * site_preference_group_list — GET /system_object_definitions/SitePreferences/attribute_groups
6
+ * site_preference_get match-all POST /site_preferences/preference_groups/{group}/{instance}/preference_search
7
+ * site_preference_search — POST /site_preferences/preference_groups/{group}/{instance}/preference_search
7
8
  *
8
- * Both tools are read-only, run behind the T1 call-time gate, and route
9
+ * All three tools are read-only, run behind the T1 call-time gate, and route
9
10
  * oversized payloads through the sfcc/output.ts truncate-and-save seam.
10
11
  *
11
12
  * NOTE: SCAPI Preferences API (sfcc.preferences) is intentionally NOT used.
@@ -35,20 +36,24 @@ const INSTANCE_ENUM = z.enum(["staging", "development", "sandbox", "production"]
35
36
  const INSTANCE_DESCRIBE = "OCAPI instance context. v1 supports the 'sandbox' context only; " +
36
37
  "any other value is rejected with a validation error. Defaults to 'sandbox'.";
37
38
  const sitePreferenceGetInput = z.object({
38
- group: z.string().describe("Preference group ID, e.g. 'Account' or 'General'."),
39
+ group: z.string().describe("Custom site preference group ID, e.g. 'LLMIntegration'."),
39
40
  instance: INSTANCE_ENUM.optional().default("sandbox").describe(INSTANCE_DESCRIBE),
40
41
  start: z.number().optional().describe("Zero-based offset for paging."),
41
42
  count: z.number().optional().describe("Maximum number of preferences to return."),
42
43
  });
43
44
  const sitePreferenceSearchInput = z.object({
44
- group: z.string().describe("Preference group ID to search within."),
45
+ group: z.string().describe("Custom site preference group ID to search within, e.g. 'LLMIntegration'."),
45
46
  instance: INSTANCE_ENUM.optional().default("sandbox").describe(INSTANCE_DESCRIBE),
46
- query: z.union([z.string(), z.record(z.string(), z.any())]).describe("Search query. Pass a plain string for text search across preference ids and values, " +
47
+ query: z.union([z.string(), z.record(z.string(), z.any())]).describe("Search query. Pass a plain string for text search across preference ids, " +
47
48
  "or a structured OCAPI query object (term_query, filtered_query, etc.)."),
48
49
  start: z.number().optional().describe("Zero-based offset for paging."),
49
50
  count: z.number().optional().describe("Maximum number of results to return."),
50
51
  sorts: z.array(z.any()).optional().describe("Array of OCAPI sort descriptors."),
51
52
  });
53
+ const sitePreferenceGroupListInput = z.object({
54
+ count: z.number().optional().describe("Maximum number of site preference groups to return."),
55
+ start: z.number().optional().describe("Zero-based offset for paging."),
56
+ });
52
57
  // ---------------------------------------------------------------------------
53
58
  // Helpers
54
59
  // ---------------------------------------------------------------------------
@@ -86,15 +91,14 @@ export function buildSitePreferenceGetHandler(gateDeps, getDocsDir) {
86
91
  if (guard)
87
92
  return guard;
88
93
  const encodedGroup = encodeURIComponent(group);
89
- const queryParams = {};
94
+ const postBody = {
95
+ query: { match_all_query: {} },
96
+ };
90
97
  if (start !== undefined)
91
- queryParams.start = String(start);
98
+ postBody.start = start;
92
99
  if (count !== undefined)
93
- queryParams.count = String(count);
94
- const queryStr = Object.keys(queryParams).length > 0
95
- ? "?" + new URLSearchParams(queryParams).toString()
96
- : "";
97
- const result = await ocapiGet(`/site_preferences/preference_groups/${encodedGroup}/${instance}${queryStr}`, credentials);
100
+ postBody.count = count;
101
+ const result = await ocapiPost(`/site_preferences/preference_groups/${encodedGroup}/${instance}/preference_search`, postBody, credentials);
98
102
  if (!result.ok) {
99
103
  return textResult(JSON.stringify({ error: "OCAPI error", status: result.status, body: result.body }, null, 2));
100
104
  }
@@ -113,7 +117,7 @@ export function buildSitePreferenceSearchHandler(gateDeps, getDocsDir) {
113
117
  const encodedGroup = encodeURIComponent(group);
114
118
  // Coerce plain string queries into OCAPI text_query shape
115
119
  const resolvedQuery = typeof query === "string"
116
- ? { text_query: { fields: ["id", "value"], search_phrase: query } }
120
+ ? { text_query: { fields: ["id"], search_phrase: query } }
117
121
  : query;
118
122
  const postBody = { query: resolvedQuery };
119
123
  if (start !== undefined)
@@ -132,25 +136,54 @@ export function buildSitePreferenceSearchHandler(gateDeps, getDocsDir) {
132
136
  return saveAndReturn(text, dir, `site-preference-search-${safeGroup(group)}-${safeTimestamp()}.json`);
133
137
  });
134
138
  }
139
+ export function buildSitePreferenceGroupListHandler(gateDeps, getDocsDir) {
140
+ return withSfccGate(gateDeps, async (args, credentials) => {
141
+ const { count, start } = sitePreferenceGroupListInput.parse(args);
142
+ const queryParams = {};
143
+ if (count !== undefined)
144
+ queryParams.count = String(count);
145
+ if (start !== undefined)
146
+ queryParams.start = String(start);
147
+ const queryStr = Object.keys(queryParams).length > 0
148
+ ? "?" + new URLSearchParams(queryParams).toString()
149
+ : "";
150
+ const result = await ocapiGet(`/system_object_definitions/SitePreferences/attribute_groups${queryStr}`, credentials);
151
+ if (!result.ok) {
152
+ return textResult(JSON.stringify({ error: "OCAPI error", status: result.status, body: result.body }, null, 2));
153
+ }
154
+ const normalized = normalizeOcapiBody(result.body);
155
+ const text = JSON.stringify(normalized, null, 2);
156
+ const dir = path.join(await getDocsDir(), "sfcc");
157
+ return saveAndReturn(text, dir, `site-preference-group-list-${safeTimestamp()}.json`);
158
+ });
159
+ }
135
160
  /**
136
- * Register the two SFCC site-preference introspection read tools.
161
+ * Register the three SFCC site-preference introspection read tools.
137
162
  *
138
163
  * Called from `registerSfccTools` — no direct `index.ts` edits needed.
139
164
  */
140
165
  export function registerSitePreferenceTools(registerTool, deps) {
141
166
  const { gateDeps, getDocsDir } = deps;
167
+ registerTool("site_preference_group_list", {
168
+ description: "List custom site preference group ids from the developer sandbox via " +
169
+ "GET /system_object_definitions/SitePreferences/attribute_groups. Read-only; " +
170
+ "use this to discover group ids before site_preference_get or site_preference_search. " +
171
+ "Accepts optional `count` and `start` for paging. Oversized outputs are auto-saved locally.",
172
+ inputSchema: sitePreferenceGroupListInput,
173
+ annotations: READ_ANNOTATIONS,
174
+ }, buildSitePreferenceGroupListHandler(gateDeps, getDocsDir));
142
175
  registerTool("site_preference_get", {
143
176
  description: "Read effective preferences for a site preference group from the developer sandbox " +
144
- "via GET /site_preferences/preference_groups/{group}/sandbox. Read-only; v1 supports " +
145
- "sandboxes only (non-sandbox instances are rejected). Accepts optional `start` and " +
146
- "`count` for paging. Oversized payloads are auto-saved locally and previewed inline.",
177
+ "via a match-all POST /site_preferences/preference_groups/{group}/sandbox/preference_search. " +
178
+ "Read-only, sandbox-only. Accepts optional `start`/`count` paging in the POST body. " +
179
+ "Oversized payloads are auto-saved locally.",
147
180
  inputSchema: sitePreferenceGetInput,
148
181
  annotations: READ_ANNOTATIONS,
149
182
  }, buildSitePreferenceGetHandler(gateDeps, getDocsDir));
150
183
  registerTool("site_preference_search", {
151
184
  description: "Search/filter preferences within a site preference group from the developer sandbox " +
152
185
  "via POST /site_preferences/preference_groups/{group}/sandbox/preference_search. " +
153
- "Read-only; v1 sandbox only. Pass a plain string for text search or a structured " +
186
+ "Read-only; v1 sandbox only. Pass a plain string for id-only text search or a structured " +
154
187
  "OCAPI query object. Oversized results are auto-saved locally.",
155
188
  inputSchema: sitePreferenceSearchInput,
156
189
  annotations: READ_ANNOTATIONS,
@@ -20,8 +20,9 @@
20
20
  * metadata, or the shell command string.
21
21
  */
22
22
  import { randomBytes } from "node:crypto";
23
- import path from "node:path";
24
23
  import { fileURLToPath } from "node:url";
24
+ import { mergeClaudeSettingsWithCommandHook, detectExistingPreToolUseMatcher, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "./claude-settings.js";
25
+ import { normalizeDeclaredTouchedFiles } from "./conductor/file-scope-guard.js";
25
26
  import { resolveProfiles } from "./mcp-profile.js";
26
27
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
27
28
  // ---------------------------------------------------------------------------
@@ -54,15 +55,22 @@ export function mintStartTicketsWorkerId(ticketKey, agentName, fragment = random
54
55
  }
55
56
  /**
56
57
  * Build the secret-free epic identity env keys from an {@link EpicDispatchIdentity}.
57
- * Strict allowlist — constructs a FRESH object with exactly three named keys,
58
- * never copies arbitrary parent env so credentials cannot leak.
58
+ * Strict allowlist — constructs a FRESH object with only named conductor keys,
59
+ * never copies arbitrary parent env so credentials cannot leak. The declared
60
+ * touched-file set (BAPI-507 N-2) is included as compact JSON ONLY when it
61
+ * normalizes to a non-empty repo-relative set.
59
62
  */
60
63
  export function buildEpicIdentityEnv(epic) {
61
- return {
64
+ const env = {
62
65
  BAPI_CONDUCTOR_EPIC_KEY: epic.epic_key,
63
66
  BAPI_CONDUCTOR_EPIC_RUN_ID: epic.epic_run_id,
64
67
  BAPI_CONDUCTOR_PLAN_VERSION: String(epic.plan_version),
65
68
  };
69
+ const declared = normalizeDeclaredTouchedFiles(epic.declared_touched_files);
70
+ if (declared.length > 0) {
71
+ env.BAPI_CONDUCTOR_DECLARED_TOUCHED_FILES_JSON = JSON.stringify(declared);
72
+ }
73
+ return env;
66
74
  }
67
75
  // ---------------------------------------------------------------------------
68
76
  // Conductor context
@@ -108,6 +116,10 @@ export async function createStartTicketsConductorContext(options, agent, deps) {
108
116
  supervisorMode,
109
117
  agentName: agent.name,
110
118
  cliFile: resolveBinPath("conductor-bin.js"),
119
+ // Capture the conductor process's Node executable so dispatched workers run
120
+ // conductor-bin.js under THIS runtime (which owns the ledger), never the
121
+ // worker's own Node (BAPI-527).
122
+ conductorNodePath: deps.execPath ?? process.execPath,
111
123
  hookBinPath: resolveBinPath("conductor-claude-hook-bin.js"),
112
124
  };
113
125
  if (options.epic) {
@@ -159,6 +171,10 @@ export function buildConductorWorkerEnv(context, worker, parentEnv) {
159
171
  BAPI_CONDUCTOR_GATE_NAME: context.gateName,
160
172
  BAPI_CONDUCTOR_SUPERVISOR_MODE: context.supervisorMode,
161
173
  BAPI_CONDUCTOR_CLI_FILE: context.cliFile,
174
+ // BAPI-527: the CLI file + Node runtime are a PAIR. The worker runs
175
+ // conductor-bin.js under this captured conductor Node for every worker-side
176
+ // ledger op, so the worker Node never loads better-sqlite3. Non-secret.
177
+ CONDUCTOR_NODE_PATH: context.conductorNodePath,
162
178
  };
163
179
  if (context.repoName) {
164
180
  env.BAPI_CONDUCTOR_REPO_NAME = context.repoName;
@@ -182,15 +198,25 @@ export function buildConductorWorkerEnv(context, worker, parentEnv) {
182
198
  // ---------------------------------------------------------------------------
183
199
  // Claude hook settings merge / provisioning
184
200
  // ---------------------------------------------------------------------------
185
- /** Claude lifecycle events always registered for the conductor hook. */
201
+ /**
202
+ * Claude lifecycle events always registered for the conductor hook.
203
+ *
204
+ * BAPI-507 (N-1): `Stop` and `SubagentStop` are deliberately NOT registered.
205
+ * They are per-turn / per-subagent events (verified against the Claude Code
206
+ * lifecycle-hook contract), so registering them emitted a `run.stopped` on every
207
+ * turn and folded the ticket to `ready_for_review` while it was still being
208
+ * implemented. `SessionEnd` is the one true session-terminal event and is the
209
+ * only session-end signal registered here.
210
+ */
186
211
  export const CONDUCTOR_HOOK_LIFECYCLE_EVENTS = [
187
212
  "SessionStart",
188
- "Stop",
189
- "SubagentStop",
213
+ "SessionEnd",
190
214
  "Notification",
191
215
  ];
192
- /** Broad matcher used for `PreToolUse` when no existing matcher is present. */
193
- export const DEFAULT_PRE_TOOL_USE_MATCHER = "*";
216
+ // BAPI-534: the broad PreToolUse matcher and generic hook-merge machinery now
217
+ // live in `claude-settings.ts` (shared with the executor deny-layer path).
218
+ // Re-exported so existing importers of this name keep resolving.
219
+ export { DEFAULT_PRE_TOOL_USE_MATCHER };
194
220
  /** Shell-quote a path for embedding in a hook command string. */
195
221
  function shellQuotePath(value) {
196
222
  // Single-quote for POSIX safety; embedded single quotes are escaped. Hook
@@ -208,55 +234,15 @@ export function resolveConductorHookCommand(env, hookBinPath, execPath = process
208
234
  }
209
235
  return `${shellQuotePath(execPath)} ${shellQuotePath(hookBinPath)}`;
210
236
  }
211
- function asHookEntries(value) {
212
- return Array.isArray(value) ? value : [];
213
- }
214
- /** Does this event already register `command` as a command hook? */
215
- function entriesContainCommand(entries, command) {
216
- return entries.some((entry) => Array.isArray(entry?.hooks) &&
217
- entry.hooks.some((h) => h && h.type === "command" && h.command === command));
218
- }
219
237
  /**
220
- * Merge conductor lifecycle hooks into an existing Claude settings object
221
- * WITHOUT removing any existing settings or hooks. Idempotent: re-applying the
222
- * same command never duplicates an entry. `PreToolUse` is registered only when
223
- * `enablePreToolUse` is true, mirroring any existing matcher style.
238
+ * Merge conductor lifecycle hooks into an existing Claude settings object.
239
+ * BAPI-534: a thin wrapper over the generic {@link mergeClaudeSettingsWithCommandHook}
240
+ * bound to the conductor lifecycle events preserving the exported signature and
241
+ * behavior (idempotent; `PreToolUse` only when `enablePreToolUse`; mirrors any
242
+ * existing matcher style; never removes existing settings/hooks).
224
243
  */
225
244
  export function mergeClaudeSettingsWithConductorHook(settings, command, options = {}) {
226
- const existingHooks = settings.hooks !== null && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
227
- ? settings.hooks
228
- : {};
229
- const hooks = { ...existingHooks };
230
- const events = [...CONDUCTOR_HOOK_LIFECYCLE_EVENTS];
231
- if (options.enablePreToolUse) {
232
- events.push("PreToolUse");
233
- }
234
- for (const event of events) {
235
- const entries = asHookEntries(hooks[event]);
236
- if (entriesContainCommand(entries, command)) {
237
- // Idempotent: already registered for this event.
238
- hooks[event] = entries;
239
- continue;
240
- }
241
- const newEntry = { hooks: [{ type: "command", command }] };
242
- if (event === "PreToolUse") {
243
- newEntry.matcher = options.preToolUseMatcher ?? DEFAULT_PRE_TOOL_USE_MATCHER;
244
- }
245
- hooks[event] = [...entries, newEntry];
246
- }
247
- return { ...settings, hooks };
248
- }
249
- /** Detect an existing PreToolUse matcher to mirror, if any. */
250
- function detectExistingPreToolUseMatcher(settings) {
251
- const hooks = settings.hooks;
252
- if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks))
253
- return undefined;
254
- const entries = asHookEntries(hooks.PreToolUse);
255
- for (const entry of entries) {
256
- if (typeof entry?.matcher === "string")
257
- return entry.matcher;
258
- }
259
- return undefined;
245
+ return mergeClaudeSettingsWithCommandHook(settings, command, CONDUCTOR_HOOK_LIFECYCLE_EVENTS, options);
260
246
  }
261
247
  /**
262
248
  * Provision the conductor Claude hook into ONE worktree's
@@ -265,52 +251,14 @@ function detectExistingPreToolUseMatcher(settings) {
265
251
  * pretty-printed JSON, mirroring any existing PreToolUse matcher.
266
252
  */
267
253
  export async function provisionConductorHookForWorktree(worktreePath, command, options, deps) {
268
- const claudeDir = path.join(worktreePath, ".claude");
269
- const settingsPath = path.join(claudeDir, "settings.local.json");
270
- let existing = {};
271
- let raw = null;
272
- try {
273
- raw = await deps.readFile(settingsPath);
274
- }
275
- catch {
276
- // Missing file -> treat as empty settings.
277
- raw = null;
278
- }
279
- if (raw !== null) {
280
- try {
281
- const parsed = JSON.parse(raw);
282
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
283
- return {
284
- ok: false,
285
- reason: "malformed",
286
- error: "existing .claude/settings.local.json is not a JSON object",
287
- };
288
- }
289
- existing = parsed;
290
- }
291
- catch {
292
- // Malformed JSON: fail the row rather than clobber the user's file. The
293
- // error is generic — it never echoes the file contents.
294
- return {
295
- ok: false,
296
- reason: "malformed",
297
- error: "existing .claude/settings.local.json contains invalid JSON",
298
- };
299
- }
300
- }
301
- const merged = mergeClaudeSettingsWithConductorHook(existing, command, {
254
+ // BAPI-534: delegate the malformed-safe read/merge/write to the shared
255
+ // `provisionClaudeSettingsForWorktree`, supplying a conductor-hook merge
256
+ // closure. The returned `{ ok, reason, error }` shape is identical to
257
+ // ConductorHookProvisionResult, so the mapping is a pass-through.
258
+ return provisionClaudeSettingsForWorktree(worktreePath, (existing) => mergeClaudeSettingsWithConductorHook(existing, command, {
302
259
  enablePreToolUse: options.enablePreToolUse,
303
260
  preToolUseMatcher: options.preToolUseMatcher ?? detectExistingPreToolUseMatcher(existing),
304
- });
305
- try {
306
- await deps.mkdir(claudeDir, { recursive: true });
307
- await deps.writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
308
- }
309
- catch {
310
- // Best-effort: a write/mkdir failure must not block the actual work.
311
- return { ok: false, reason: "io", error: "failed to write .claude/settings.local.json" };
312
- }
313
- return { ok: true };
261
+ }), deps);
314
262
  }
315
263
  /**
316
264
  * Provision conductor identity across rows. Attaches `runId` to EVERY row (so