@bridge_gpt/mcp-server 0.2.21 → 0.2.24

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 (41) hide show
  1. package/README.md +144 -18
  2. package/build/base-ref.js +151 -0
  3. package/build/commands.generated.js +6 -4
  4. package/build/conductor/bridge-api-client.js +44 -3
  5. package/build/conductor/doctor.js +33 -22
  6. package/build/conductor/epic-runtime.js +101 -5
  7. package/build/conductor/pr-ci-producer.js +21 -2
  8. package/build/conductor/pr-discovery.js +12 -2
  9. package/build/conductor-bin.js +50 -20
  10. package/build/credential-store.js +564 -64
  11. package/build/decision-page-template.js +9 -4
  12. package/build/docs.generated.js +5 -0
  13. package/build/executor/base-branch.js +50 -0
  14. package/build/executor/env.js +12 -1
  15. package/build/executor/job-errors.js +1 -0
  16. package/build/executor/job-runner.js +38 -7
  17. package/build/executor/test-clock.js +6 -1
  18. package/build/executor/worker-finalization.js +88 -1
  19. package/build/executor/worktree.js +21 -1
  20. package/build/index.js +2741 -702
  21. package/build/init.js +29 -0
  22. package/build/install-bridge.js +1076 -114
  23. package/build/pipelines.generated.js +2 -2
  24. package/build/pr-base-contract.js +36 -0
  25. package/build/readme.generated.js +1 -1
  26. package/build/setup-epic.js +483 -0
  27. package/build/sfcc/log-gate.js +85 -0
  28. package/build/sfcc/log-query.js +170 -0
  29. package/build/sfcc/register.js +10 -0
  30. package/build/sfcc/setup-status.js +33 -3
  31. package/build/start-tickets.js +164 -75
  32. package/build/version.generated.js +1 -1
  33. package/build/worktree-core.js +62 -10
  34. package/{CONDUCTOR.md → docs/CONDUCTOR.md} +88 -29
  35. package/docs/install/github-app.md +189 -0
  36. package/docs/install/mcp-tool-integrations.md +305 -0
  37. package/docs/install/sfcc-integration.md +140 -0
  38. package/package.json +5 -5
  39. package/public/js/main.min.js +55 -10
  40. package/public/js/main.min.js.map +1 -1
  41. package/smoke-test/SMOKE-TEST.md +3 -2
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `sfcc_log_query` — gated, on-demand SFCC log-query MCP tool (T7 / BAPI-556).
3
+ *
4
+ * A thin, profile-gated handler that forwards an explicitly bounded query to the
5
+ * Bridge backend, which runs T1 pull → T2 redaction → T3 filtering server-side
6
+ * and returns scoped, redacted findings. `environment` and `time_range` are
7
+ * REQUIRED with no broad defaults; entries, time range, and log-file prefixes are
8
+ * all capped to prevent runaway retrieval. Basic-auth WebDAV credentials never
9
+ * leave the backend — this tool holds none.
10
+ *
11
+ * The backend response has already passed through the `RedactionPort`, so its JSON
12
+ * is returned to the LLM unchanged; there is deliberately NO local retrieval
13
+ * bypass in TypeScript.
14
+ */
15
+ import { z } from "zod";
16
+ import { withSfccLogGate } from "./log-gate.js";
17
+ // ---------------------------------------------------------------------------
18
+ // Bounds — mirror src/python/sfcc_monitor/log_query.py (kept in lock-step).
19
+ // ---------------------------------------------------------------------------
20
+ const MAX_QUERY_RANGE_HOURS = 24;
21
+ const HIGH_VOLUME_MAX_RANGE_HOURS = 6;
22
+ const HIGH_VOLUME_PREFIXES = new Set(["info", "jobs", "debug", "customdebug"]);
23
+ const MAX_SELECTED_PREFIXES = 5;
24
+ const MAX_MAX_ENTRIES = 2000;
25
+ const SUPPORTED_ENVIRONMENTS = ["production", "staging", "development"];
26
+ const PREFIX_RE = /^[a-z][a-z0-9]*$/;
27
+ function jsonResult(payload) {
28
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
29
+ }
30
+ function validationError(message) {
31
+ return jsonResult({ error: "VALIDATION_ERROR", status: 400, message });
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Zod input schema
35
+ // ---------------------------------------------------------------------------
36
+ const inputSchema = z.object({
37
+ environment: z
38
+ .enum(SUPPORTED_ENVIRONMENTS)
39
+ .describe("REQUIRED environment to scope the query to. Deliberately required so a " +
40
+ "query can never fan across every environment at once."),
41
+ time_range: z
42
+ .object({
43
+ start: z.string().datetime({ offset: true }).describe("Inclusive ISO-8601 UTC start."),
44
+ end: z.string().datetime({ offset: true }).describe("Exclusive ISO-8601 UTC end (after start)."),
45
+ })
46
+ .strict()
47
+ .describe("REQUIRED bounded window. No open-ended or inferred period is ever assumed."),
48
+ prefixes: z
49
+ .array(z.string().regex(PREFIX_RE, "prefix must be a letter followed by letters/digits"))
50
+ .max(MAX_SELECTED_PREFIXES)
51
+ .optional()
52
+ .describe(`Optional log-file prefix selection (max ${MAX_SELECTED_PREFIXES}). Empty = the ` +
53
+ "shipped error-class defaults. High-volume prefixes (info/jobs/debug/customdebug) " +
54
+ `impose a stricter ${HIGH_VOLUME_MAX_RANGE_HOURS}h max time range.`),
55
+ max_entries: z
56
+ .number()
57
+ .int()
58
+ .min(1)
59
+ .max(MAX_MAX_ENTRIES)
60
+ .optional()
61
+ .describe(`Optional per-query entry-scan cap (1..${MAX_MAX_ENTRIES}).`),
62
+ });
63
+ // ---------------------------------------------------------------------------
64
+ // Handler
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Perform handler-level semantic checks so agents get a structured
68
+ * VALIDATION_ERROR before any network retrieval where possible.
69
+ */
70
+ function semanticCheck(args) {
71
+ const start = Date.parse(args.time_range.start);
72
+ const end = Date.parse(args.time_range.end);
73
+ if (Number.isNaN(start) || Number.isNaN(end)) {
74
+ return "time_range.start and time_range.end must be valid ISO-8601 timestamps.";
75
+ }
76
+ if (!(start < end)) {
77
+ return "time_range.start must be strictly before time_range.end.";
78
+ }
79
+ const prefixes = args.prefixes ?? [];
80
+ const hasHighVolume = prefixes.some((p) => HIGH_VOLUME_PREFIXES.has(p));
81
+ const maxHours = hasHighVolume ? HIGH_VOLUME_MAX_RANGE_HOURS : MAX_QUERY_RANGE_HOURS;
82
+ const spanHours = (end - start) / 3_600_000;
83
+ if (spanHours > maxHours) {
84
+ return (`time_range spans ${spanHours.toFixed(1)}h but the maximum for this prefix ` +
85
+ `selection is ${maxHours}h. environment and time_range are required, and the ` +
86
+ "range is capped, to prevent broad cross-environment or open-ended log scans.");
87
+ }
88
+ // Uniqueness (Zod validates grammar + count; this catches duplicates).
89
+ if (new Set(prefixes).size !== prefixes.length) {
90
+ return "prefixes must be unique.";
91
+ }
92
+ return null;
93
+ }
94
+ function buildHandler(deps) {
95
+ return async (rawArgs) => {
96
+ const parsed = inputSchema.safeParse(rawArgs);
97
+ if (!parsed.success) {
98
+ return validationError(parsed.error.issues.map((i) => i.message).join("; "));
99
+ }
100
+ const args = parsed.data;
101
+ const semanticFailure = semanticCheck(args);
102
+ if (semanticFailure)
103
+ return validationError(semanticFailure);
104
+ const body = {
105
+ repo_name: deps.repoName,
106
+ environment: args.environment,
107
+ time_range: { start: args.time_range.start, end: args.time_range.end },
108
+ prefixes: args.prefixes ?? [],
109
+ ...(args.max_entries !== undefined ? { max_entries: args.max_entries } : {}),
110
+ };
111
+ let resp;
112
+ try {
113
+ const url = deps.buildGetUrl("/sfcc/logs/query", { repo_name: deps.repoName });
114
+ resp = await fetch(url, {
115
+ method: "POST",
116
+ headers: await deps.getPostHeaders(),
117
+ body: JSON.stringify(body),
118
+ });
119
+ }
120
+ catch {
121
+ return jsonResult({
122
+ error: "BAD_GATEWAY",
123
+ status: 502,
124
+ message: "Could not reach Bridge API to run the SFCC log query. Check that " +
125
+ "BAPI_BASE_URL points to a running Bridge API instance.",
126
+ });
127
+ }
128
+ const text = await resp.text();
129
+ if (!resp.ok) {
130
+ let detail = text;
131
+ try {
132
+ detail = JSON.parse(text);
133
+ }
134
+ catch {
135
+ /* keep raw text detail */
136
+ }
137
+ return jsonResult({
138
+ error: resp.status >= 500 ? "SERVICE_UNAVAILABLE" : "REQUEST_FAILED",
139
+ status: resp.status,
140
+ detail,
141
+ });
142
+ }
143
+ // The backend response has already passed through RedactionPort — return it
144
+ // unchanged as the MCP text payload (bounded server-side; no local persistence).
145
+ return { content: [{ type: "text", text }] };
146
+ };
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // Registration
150
+ // ---------------------------------------------------------------------------
151
+ /**
152
+ * Register the `sfcc_log_query` tool through the host's registerTool wrapper.
153
+ * Called only inside the `deps.includeReadTools` SFCC-profile branch.
154
+ */
155
+ export function registerSfccLogQueryTool(registerTool, deps) {
156
+ const gated = withSfccLogGate({ buildGetUrl: deps.buildGetUrl, getGetHeaders: deps.getGetHeaders, repoName: deps.repoName }, buildHandler(deps));
157
+ registerTool("sfcc_log_query", {
158
+ description: "Query redacted, filtered SFCC logs on demand, scoped to a REQUIRED environment " +
159
+ "and time_range. Runs pull→redact→filter on the Bridge backend; entries, time range, " +
160
+ "and log-file prefixes are capped (high-volume prefixes get a stricter range). Returns " +
161
+ "a NOT_CONFIGURED 503 when the log capability isn't set up — run sfcc_setup_status.",
162
+ inputSchema,
163
+ annotations: {
164
+ readOnlyHint: true,
165
+ destructiveHint: false,
166
+ idempotentHint: true,
167
+ openWorldHint: true,
168
+ },
169
+ }, gated);
170
+ }
@@ -14,6 +14,7 @@ import { registerSystemObjectReadTools } from "./reads-system-object.js";
14
14
  import { registerSfccCustomObjectDefReadTools } from "./reads-custom-object-def.js";
15
15
  import { registerSitePreferenceTools } from "./reads-site-preference.js";
16
16
  import { registerSfccWriteTools } from "./writes.js";
17
+ import { registerSfccLogQueryTool } from "./log-query.js";
17
18
  // ---------------------------------------------------------------------------
18
19
  // Registration
19
20
  // ---------------------------------------------------------------------------
@@ -78,5 +79,14 @@ export function registerSfccTools(registerTool, deps) {
78
79
  gateDeps,
79
80
  getDocsDir: deps.getDocsDir,
80
81
  });
82
+ // sfcc_log_query (BAPI-556 T7) — gated, on-demand redacted/filtered log query.
83
+ // Uses its OWN call-time gate (log capability = WebDAV Basic auth), NOT the
84
+ // OCAPI version/dw.json/AM-token gate above.
85
+ registerSfccLogQueryTool(registerTool, {
86
+ buildGetUrl: deps.buildGetUrl,
87
+ getGetHeaders: deps.getGetHeaders,
88
+ getPostHeaders: deps.getPostHeaders,
89
+ repoName: deps.repoName,
90
+ });
81
91
  }
82
92
  }
@@ -19,7 +19,8 @@ import { getAmToken } from "./client.js";
19
19
  * 2. Repo name set (REPO_NAME)
20
20
  * 3. version config field is an SFCC version
21
21
  * 4. dw.json found / instance unambiguous
22
- * 5. AM token acquisition
22
+ * 5. AM token acquisition (OCAPI Account Manager — NOT log/WebDAV access)
23
+ * 6. SFCC Log Query capability (WebDAV Basic auth — independent of steps 3–5)
23
24
  *
24
25
  * Never throws; each check is caught independently so partial states are
25
26
  * always reported. Output is completely secret-free.
@@ -97,8 +98,37 @@ export async function sfccSetupStatusTool(deps) {
97
98
  tokenStatus = `✗ ${msg}`;
98
99
  }
99
100
  }
100
- lines.push(`5. AM Token: ${tokenStatus}`);
101
- lines.push("\nRun `check_permissions` to probe OCAPI access once steps 1–5 are all green.");
101
+ lines.push(`5. AM Token (OCAPI): ${tokenStatus}`);
102
+ // 6. SFCC Log Query capability WebDAV Basic auth, independent of OCAPI/AM.
103
+ // A single Bridge backend probe (secret-free) reports readiness. This is a
104
+ // separate credential surface: an SFCC repo can have OCAPI working (steps 3–5)
105
+ // while log/WebDAV access is not configured, and vice versa.
106
+ let logQueryStatus = "— Skipped (Bridge API not configured)";
107
+ if (apiKeyOk && repoOk) {
108
+ try {
109
+ const url = deps.buildGetUrl("/sfcc/logs/capability", { repo_name: deps.repoName });
110
+ const resp = await fetch(url, { headers: await deps.getGetHeaders() });
111
+ if (!resp.ok) {
112
+ logQueryStatus = `✗ Could not read (Bridge API ${resp.status})`;
113
+ }
114
+ else {
115
+ const body = (await resp.json());
116
+ if (body?.configured === true) {
117
+ logQueryStatus = "✓ Configured (WebDAV log access ready)";
118
+ }
119
+ else {
120
+ const msg = typeof body?.message === "string" ? body.message : "Not configured";
121
+ logQueryStatus = `✗ ${msg}`;
122
+ }
123
+ }
124
+ }
125
+ catch (err) {
126
+ logQueryStatus = `✗ Resolution error: ${err instanceof Error ? err.message : String(err)}`;
127
+ }
128
+ }
129
+ lines.push(`6. SFCC Log Query (WebDAV): ${logQueryStatus}`);
130
+ lines.push("\nRun `check_permissions` to probe OCAPI access once steps 1–5 are all green. " +
131
+ "Step 6 (log/WebDAV access) is independent and gates `sfcc_log_query`.");
102
132
  return {
103
133
  content: [{ type: "text", text: lines.join("\n") }],
104
134
  };
@@ -71,6 +71,7 @@ import { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERS
71
71
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, resolveModelAlias, isValidModelAlias, isModelTier, } from "./agent-registry.js";
72
72
  import { createStartTicketsConductorContext, provisionConductorHooksForRows, emitStartTicketsRunStarted, injectConductorEnvIntoShellCommand, buildSupervisorTabCommand, isSupervisorLaunchEnabled, supervisorSpawnKey, } from "./start-tickets-conductor.js";
73
73
  import { transitionEpicDispatch, resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
74
+ import { PR_BASE_BRANCH_ENV_VAR, buildPrBaseContractLaunchInstruction, } from "./pr-base-contract.js";
74
75
  // Re-export the shared prereq surface (constants, platform helpers, command
75
76
  // probes) so existing import sites that read them from "./start-tickets.js"
76
77
  // keep working unchanged.
@@ -101,6 +102,8 @@ export function getStartTicketsUsage() {
101
102
  "",
102
103
  "Flags:",
103
104
  " --agent claude|cursor-agent Agent command to launch in each worktree (default: claude)",
105
+ " --workflow implement|review-and-implement Slash command each spawned worktree runs (default: implement). review-and-implement runs /review-ticket then, after a per-ticket halt gate, /implement-ticket in the same session; --auto applies to the selected workflow.",
106
+ " --rounds 1|2 Review round count forwarded to the review phase; review-only, valid only with --workflow review-and-implement",
104
107
  " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
105
108
  " --dry-run Print intended actions; creates no worktrees and opens no tabs, but DOES resolve model routing read-only (may compute+cache a ticket's difficulty) to preview the --model each tab would use",
106
109
  " --branch KEY=BRANCH Use BRANCH instead of feature/KEY for that ticket (repeatable)",
@@ -151,6 +154,8 @@ export function parseStartTicketsArgs(argv) {
151
154
  let agentName = DEFAULT_AGENT_NAME;
152
155
  let baseBranch = "main";
153
156
  let conductorEnabled = false;
157
+ let workflow = "implement";
158
+ let reviewRoundsRaw;
154
159
  const branchEntries = [];
155
160
  const keys = [];
156
161
  for (let i = 0; i < argv.length; i++) {
@@ -183,6 +188,49 @@ export function parseStartTicketsArgs(argv) {
183
188
  agentName = value;
184
189
  continue;
185
190
  }
191
+ if (arg === "--workflow" || arg.startsWith("--workflow=")) {
192
+ let value;
193
+ if (arg.startsWith("--workflow=")) {
194
+ value = arg.slice("--workflow=".length);
195
+ }
196
+ else {
197
+ value = takeValue();
198
+ if (value === undefined) {
199
+ return {
200
+ status: "error",
201
+ message: "--workflow requires a value (allowed values: implement, review-and-implement).",
202
+ };
203
+ }
204
+ }
205
+ if (value !== "implement" && value !== "review-and-implement") {
206
+ return {
207
+ status: "error",
208
+ message: `Invalid --workflow value: '${value}' (allowed values: implement, review-and-implement).`,
209
+ };
210
+ }
211
+ workflow = value;
212
+ continue;
213
+ }
214
+ if (arg === "--rounds" || arg.startsWith("--rounds=")) {
215
+ let value;
216
+ if (arg.startsWith("--rounds=")) {
217
+ value = arg.slice("--rounds=".length);
218
+ }
219
+ else {
220
+ value = takeValue();
221
+ if (value === undefined) {
222
+ return { status: "error", message: "--rounds requires a value (allowed values: 1, 2)." };
223
+ }
224
+ }
225
+ if (value !== "1" && value !== "2") {
226
+ return {
227
+ status: "error",
228
+ message: `Invalid --rounds value: '${value}' (allowed values: 1, 2).`,
229
+ };
230
+ }
231
+ reviewRoundsRaw = value;
232
+ continue;
233
+ }
186
234
  if (arg === "--terminal" || arg.startsWith("--terminal=")) {
187
235
  let value;
188
236
  if (arg.startsWith("--terminal=")) {
@@ -337,29 +385,38 @@ export function parseStartTicketsArgs(argv) {
337
385
  }
338
386
  branchOverrides[overrideKey] = branchName;
339
387
  }
388
+ // --- rounds validation: review-only, checked after the full argument scan
389
+ // so flag order (e.g. --rounds before --workflow) never affects validation.
390
+ let reviewRounds;
391
+ if (reviewRoundsRaw !== undefined) {
392
+ if (workflow !== "review-and-implement") {
393
+ return {
394
+ status: "error",
395
+ message: "--rounds is only valid with --workflow review-and-implement.",
396
+ };
397
+ }
398
+ reviewRounds = reviewRoundsRaw === "1" ? 1 : 2;
399
+ }
340
400
  return {
341
401
  status: "ok",
342
- options: { keys, terminal, dryRun, autoApprove, refreshMain, maxParallel, branchOverrides, agentName, baseBranch, conductorEnabled },
402
+ options: {
403
+ keys,
404
+ terminal,
405
+ dryRun,
406
+ autoApprove,
407
+ refreshMain,
408
+ maxParallel,
409
+ branchOverrides,
410
+ agentName,
411
+ baseBranch,
412
+ conductorEnabled,
413
+ workflow,
414
+ reviewRounds,
415
+ },
343
416
  };
344
417
  }
345
- /** Returns an error string for an unsafe branch name, or null when valid. */
346
- export function validateBranchName(branch) {
347
- if (branch.trim().length === 0)
348
- return "branch name must not be empty.";
349
- if (branch.length > 255)
350
- return "branch name must be 255 characters or fewer.";
351
- if (branch.startsWith("-"))
352
- return "branch name must not start with '-'.";
353
- // Reject ASCII control characters (0x00-0x1F and 0x7F) without embedding
354
- // raw control bytes in source.
355
- for (let i = 0; i < branch.length; i++) {
356
- const code = branch.charCodeAt(i);
357
- if (code <= 0x1f || code === 0x7f) {
358
- return "branch name must not contain control characters.";
359
- }
360
- }
361
- return null;
362
- }
418
+ // `validateBranchName` moved to `base-ref.ts` (BAPI-586); imported + re-exported
419
+ // above. See the import block near the worktree-core re-exports.
363
420
  /**
364
421
  * Determine which macOS terminal to drive. An explicit choice wins; otherwise
365
422
  * auto-detect iTerm from `$TERM_PROGRAM` (case-insensitive), defaulting to
@@ -403,19 +460,27 @@ export function getDefaultSpawnTerminalTabForPlatform(platform) {
403
460
  export function resolveStartTicketsPlatformConfig(deps, agent, autoApprove = false, conductorEnabled = false, repoName = null,
404
461
  // BAPI-494: a conductor remediation re-dispatch. Appends the full-suite finalize
405
462
  // instruction to the resume-mode worker's prompt.
406
- resumeMode = false) {
463
+ resumeMode = false, workflow = "implement", reviewRounds,
464
+ // BAPI-586: the effective run base branch (incl. epic.base_branch override).
465
+ // Injected as BAPI_BASE_BRANCH for conductor workers so their PR targets it,
466
+ // and threaded (BAPI-593) into the spawned workflow command.
467
+ baseBranch) {
407
468
  if (!isSupportedStartTicketsPlatform(deps.platform)) {
408
469
  return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
409
470
  }
410
471
  const platform = deps.platform;
472
+ // Only conductor dispatch uses the PR-base contract; interactive dispatch omits
473
+ // the BAPI_BASE_BRANCH assignment so its worker launch is unchanged.
474
+ const prBaseBranch = conductorEnabled ? baseBranch : null;
411
475
  return {
412
476
  ok: true,
413
477
  config: {
414
478
  platform,
415
479
  worktrunkBinary: resolveWorktrunkBinary(platform, deps.env),
416
480
  // Inject the resolved repo identity so the spawned worktree session never
417
- // falls back to the basename-derived repo name (the 403 root cause).
418
- buildAgentShellCommand: (key, worktreePath, modelAlias) => prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode), repoName, platform),
481
+ // falls back to the basename-derived repo name (the 403 root cause), and
482
+ // (BAPI-586) the run base so the conductor worker opens its PR against it.
483
+ buildAgentShellCommand: (key, worktreePath, modelAlias) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch), repoName, platform), prBaseBranch, platform),
419
484
  spawnTerminalTab: deps.spawnTerminalTab,
420
485
  },
421
486
  };
@@ -445,6 +510,24 @@ export function prependRepoNameEnvAssignment(command, repoName, platform = "darw
445
510
  }
446
511
  return `export BAPI_REPO_NAME='${shSquoteInner(repoName)}' && ${command}`;
447
512
  }
513
+ /**
514
+ * BAPI-586: prepend a `BAPI_BASE_BRANCH` environment assignment to a spawned
515
+ * conductor worker's shell command so the worker can open its PR against the run
516
+ * base via `gh pr create --base "$BAPI_BASE_BRANCH"` (paired with the PR-base
517
+ * launch instruction), instead of inferring the repo default branch. Platform
518
+ * correct — `$env:VAR = '…'; …` on PowerShell, `export VAR='…' && …` on POSIX —
519
+ * with the value quoted by the same escaper used for the rest of the command.
520
+ * Fail-open: a null/empty `baseBranch` returns the command unchanged (interactive
521
+ * dispatch does not use the PR-base contract).
522
+ */
523
+ export function prependBaseBranchEnvAssignment(command, baseBranch, platform = "darwin") {
524
+ if (!baseBranch)
525
+ return command;
526
+ if (platform === "win32") {
527
+ return `$env:${PR_BASE_BRANCH_ENV_VAR} = ${powershellSquote(baseBranch)}; ${command}`;
528
+ }
529
+ return `export ${PR_BASE_BRANCH_ENV_VAR}='${shSquoteInner(baseBranch)}' && ${command}`;
530
+ }
448
531
  /**
449
532
  * Escape a string for inclusion inside a single-quoted shell context: each
450
533
  * embedded single-quote becomes the sequence `'\''`. Returns only the inner
@@ -725,42 +808,10 @@ export async function refreshBaseBranch(deps, options) {
725
808
  export async function refreshMainBranch(deps, options) {
726
809
  return refreshBaseBranch(deps, { refreshMain: options.refreshMain, baseBranch: "main" });
727
810
  }
728
- /**
729
- * Non-mutating counterpart to {@link refreshBaseBranch}: fetches
730
- * `origin/<baseBranch>` and resolves the fetched tip to an immutable commit
731
- * SHA, WITHOUT ever fast-forwarding or force-moving any local branch ref (no
732
- * `git merge --ff-only`, no `git branch --force`). Used by review-grounding
733
- * (BAPI-474), which must read a pinned base tree without touching the user's
734
- * working tree, index, stash, or local branch refs.
735
- *
736
- * Validates `baseBranch` with {@link validateBranchName} first so an
737
- * injection-shaped ref name is rejected before any git invocation. Every git
738
- * call uses an argv array (never a shell string), consistent with
739
- * {@link refreshBaseBranch}.
740
- */
741
- export async function fetchAndResolveBaseSha(deps, baseBranch) {
742
- const validationError = validateBranchName(baseBranch);
743
- if (validationError) {
744
- return { ok: false, error: `Invalid base branch '${baseBranch}': ${validationError}` };
745
- }
746
- const fetch = await deps.runCommand("git", ["fetch", "origin", baseBranch], {
747
- cwd: deps.cwd,
748
- });
749
- if (!commandSucceeded(fetch)) {
750
- return {
751
- ok: false,
752
- error: `git fetch origin ${baseBranch} failed. Check your network and 'git remote get-url origin', or pass --no-refresh-base to skip.`,
753
- };
754
- }
755
- const resolve = await deps.runCommand("git", ["rev-parse", "--verify", `origin/${baseBranch}^{commit}`], { cwd: deps.cwd });
756
- if (!commandSucceeded(resolve)) {
757
- return {
758
- ok: false,
759
- error: `Failed to resolve origin/${baseBranch} to a commit SHA after fetch (git rev-parse --verify failed).`,
760
- };
761
- }
762
- return { ok: true, base_sha: resolve.stdout.trim() };
763
- }
811
+ // `fetchAndResolveBaseSha` + `FetchAndResolveBaseShaResult` moved to
812
+ // `base-ref.ts` (BAPI-586) and re-exported above. The shared helper now also
813
+ // serializes the fetch per repository via an async mutex so concurrent executor
814
+ // fresh jobs against one clone cannot collide on git lock files.
764
815
  // ---------------------------------------------------------------------------
765
816
  // Concurrency + worktree creation
766
817
  // ---------------------------------------------------------------------------
@@ -798,6 +849,13 @@ export async function runWithConcurrency(items, limit, worker) {
798
849
  // same function references.
799
850
  import { resolveBranchForTicket, branchExists, buildWtSwitchArgs, pathApiForPlatform, extractWorktreePath, isExistingBranchSafeToReuse, createWorktreeForTicket, } from "./worktree-core.js";
800
851
  export { resolveBranchForTicket, branchExists, buildWtSwitchArgs, pathApiForPlatform, extractWorktreePath, isExistingBranchSafeToReuse, createWorktreeForTicket, };
852
+ // BAPI-586: base-ref resolution now lives in the focused `base-ref.ts` module so
853
+ // the executor can depend on it without pulling in this large CLI module. We
854
+ // import the VALUES here (so internal uses keep working) and re-export the same
855
+ // references so existing importers of `./start-tickets.js` (index.ts,
856
+ // review-tickets.ts) and the pinned start-tickets tests are unaffected.
857
+ import { validateBranchName, fetchAndResolveBaseSha } from "./base-ref.js";
858
+ export { validateBranchName, fetchAndResolveBaseSha };
801
859
  /**
802
860
  * Create / switch worktrees for every ticket, throttled to `maxParallel`, using
803
861
  * the resolved Worktrunk binary. Returns one row per ticket in original key
@@ -811,7 +869,17 @@ export { resolveBranchForTicket, branchExists, buildWtSwitchArgs, pathApiForPlat
811
869
  * historical branch-name behavior unchanged.
812
870
  */
813
871
  export async function createWorktrees(deps, options, worktrunkBinary, baseStartPoint = options.baseBranch) {
814
- return runWithConcurrency(options.keys, options.maxParallel, (key) => createWorktreeForTicket(deps, key, options.branchOverrides, worktrunkBinary, baseStartPoint, options.guardStaleWorktree === true));
872
+ // BAPI-586: for guard-enabled Conductor fresh dispatch the caller has already
873
+ // resolved `baseStartPoint` to an immutable base SHA (`nonMutatingBase`). Align
874
+ // any safe pre-existing branch exactly to that SHA and verify the resulting
875
+ // head against it, so a dependent ticket starts at fresh `origin/<base>` rather
876
+ // than at a stale ancestor or a sibling seed. Interactive dispatch (a moving
877
+ // branch name, guard off) keeps the historical reuse-as-is behavior.
878
+ const exactBase = options.guardStaleWorktree === true && options.nonMutatingBase === true;
879
+ const behavior = exactBase
880
+ ? { alignExistingBranchTo: baseStartPoint, verifyHeadMatches: baseStartPoint }
881
+ : {};
882
+ return runWithConcurrency(options.keys, options.maxParallel, (key) => createWorktreeForTicket(deps, key, options.branchOverrides, worktrunkBinary, baseStartPoint, options.guardStaleWorktree === true, behavior));
815
883
  }
816
884
  /**
817
885
  * Resume-mode worktree resolution (BAPI-441). Instead of creating worktrees,
@@ -931,10 +999,26 @@ export function buildAgentPrompt(key, opts = {}) {
931
999
  // `modelAlias` is accepted for signature consistency only — the model is
932
1000
  // injected as a `--model` flag (see buildAgentInvocationArgv), never embedded
933
1001
  // in the prompt text.
934
- const command = `/implement-ticket ${key}${opts.autoApprove ? " --auto" : ""}`;
1002
+ const workflow = opts.workflow ?? "implement";
1003
+ const head = workflow === "review-and-implement" ? "/review-and-implement" : "/implement-ticket";
1004
+ let command = `${head} ${key}${opts.autoApprove ? " --auto" : ""}`;
1005
+ // Review-only arguments (`--rounds`, non-default `--base-branch`) must never
1006
+ // leak into the legacy `/implement-ticket` prompt.
1007
+ if (workflow === "review-and-implement") {
1008
+ if (opts.reviewRounds !== undefined) {
1009
+ command += ` --rounds=${opts.reviewRounds}`;
1010
+ }
1011
+ if (opts.baseBranch !== undefined && opts.baseBranch !== "main") {
1012
+ command += ` --base-branch='${shSquoteInner(opts.baseBranch)}'`;
1013
+ }
1014
+ }
935
1015
  const parts = [command];
936
- if (opts.conductorEnabled)
1016
+ if (opts.conductorEnabled) {
937
1017
  parts.push(buildConductorMessageRelayLaunchInstruction());
1018
+ // BAPI-586: conductor implementation workers must open their PR against the
1019
+ // run base (injected as BAPI_BASE_BRANCH), never the repo default branch.
1020
+ parts.push(buildPrBaseContractLaunchInstruction());
1021
+ }
938
1022
  if (opts.resumeMode)
939
1023
  parts.push(buildResumeModeRemediationFinalizeInstruction());
940
1024
  return parts.join(" ");
@@ -976,13 +1060,13 @@ export function buildAgentInvocation(agent, prompt, quote, modelAlias) {
976
1060
  }
977
1061
  }
978
1062
  /** POSIX agent shell command: `cd '<path>' && <agent> [--model '<alias>'] '<prompt>'`. */
979
- export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false) {
980
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode }), (p) => `'${shSquoteInner(p)}'`, modelAlias);
1063
+ export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch) {
1064
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch }), (p) => `'${shSquoteInner(p)}'`, modelAlias);
981
1065
  return `cd '${shSquoteInner(worktreePath)}' && ${invocation}`;
982
1066
  }
983
1067
  /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> [--model '<alias>'] '<prompt>'`. */
984
- export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false) {
985
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode }), powershellSquote, modelAlias);
1068
+ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch) {
1069
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch }), powershellSquote, modelAlias);
986
1070
  return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${invocation}`;
987
1071
  }
988
1072
  /**
@@ -993,10 +1077,10 @@ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoA
993
1077
  * injected as `--model` at the spawn boundary. `conductorEnabled` appends the
994
1078
  * BAPI-397 message-relay instruction to the prompt (opt-in via `--conductor`).
995
1079
  */
996
- export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false) {
1080
+ export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch) {
997
1081
  if (platform === "win32")
998
- return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode);
999
- return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode);
1082
+ return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch);
1083
+ return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch);
1000
1084
  }
1001
1085
  /**
1002
1086
  * Build the shell command run inside a spawned tab/session for an ARBITRARY
@@ -1518,14 +1602,16 @@ export function buildDryRunResults(keys, overrides) {
1518
1602
  * PowerShell on Windows, `wt` + POSIX on macOS/Linux, and a non-throwing `wt` +
1519
1603
  * POSIX fallback for unsupported platforms.
1520
1604
  */
1521
- export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null) {
1605
+ export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null, workflow = "implement", reviewRounds, baseBranch) {
1522
1606
  return {
1523
1607
  worktrunkBinary: resolveWorktrunkBinary(platform, env),
1524
1608
  // The builder accepts an optional resolved modelAlias; the dry-run caller
1525
1609
  // now passes the previewed tier's alias so `--model` shows in the preview.
1526
1610
  // The resolved repo name (when known) is injected as a BAPI_REPO_NAME prefix
1527
- // so the dry-run preview matches the real spawn command exactly.
1528
- buildAgentShellCommand: (key, worktreePath, modelAlias) => prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled), repoName, platform),
1611
+ // so the dry-run preview matches the real spawn command exactly. Reuses the
1612
+ // same buildAgentShellCommand/buildAgentPrompt path as a real spawn dry-run
1613
+ // is never special-cased — so the previewed workflow prompt is exact.
1614
+ buildAgentShellCommand: (key, worktreePath, modelAlias) => prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, false, workflow, reviewRounds, baseBranch), repoName, platform),
1529
1615
  };
1530
1616
  }
1531
1617
  /**
@@ -1563,8 +1649,8 @@ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process
1563
1649
  * the secret-free MCP provisioning preview. Pure platform formatting only — no
1564
1650
  * preflight, no routing failures.
1565
1651
  */
1566
- export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null, mcpServerInvocation) {
1567
- const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove, conductorEnabled, repoName);
1652
+ export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null, mcpServerInvocation, workflow = "implement", reviewRounds) {
1653
+ const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove, conductorEnabled, repoName, workflow, reviewRounds, baseBranch);
1568
1654
  const wtArgs = buildWtSwitchArgs(branch, false, baseBranch);
1569
1655
  const agentInvocation = build(key, "<worktree-path>", modelAlias);
1570
1656
  return [
@@ -2504,7 +2590,10 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
2504
2590
  }
2505
2591
  const platformConfig = resolveStartTicketsPlatformConfig(deps, agent, options.autoApprove, options.conductorEnabled ?? false, resolvedRepoName,
2506
2592
  // BAPI-494: resume-mode dispatches get the full-suite remediation finalize prompt.
2507
- options.resumeMode ?? false);
2593
+ options.resumeMode ?? false, options.workflow, options.reviewRounds,
2594
+ // BAPI-586: the effective run base (already carries any epic.base_branch
2595
+ // override applied above) so conductor workers get BAPI_BASE_BRANCH.
2596
+ options.baseBranch);
2508
2597
  if (!platformConfig.ok)
2509
2598
  return { ok: false, error: platformConfig.error };
2510
2599
  // BAPI-527: resolve the base start point new worktrees are cut from BEFORE any
@@ -2707,7 +2796,7 @@ export async function runStartTicketsCli(argv, overrides = {}) {
2707
2796
  const branch = resolveBranchForTicket(key, options.branchOverrides);
2708
2797
  const routedRow = routedByKey.get(key);
2709
2798
  const modelAlias = routedRow?.modelAlias ?? null;
2710
- for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation)) {
2799
+ for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation, options.workflow, options.reviewRounds)) {
2711
2800
  log(line);
2712
2801
  }
2713
2802
  log(`DRY-RUN: model routing: ${formatModelRoutingLine(routedRow ?? { key, branch, status: "dry-run" }, agent)}`);
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED — do not edit manually. Regenerate with: npm run build
2
- export const VERSION = "0.2.21";
2
+ export const VERSION = "0.2.24";