@bridge_gpt/mcp-server 0.2.34 → 0.2.36

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 (49) hide show
  1. package/README.md +456 -370
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/claude-review-workflow.js +264 -0
  6. package/build/cli-release.js +53 -0
  7. package/build/commands.generated.js +4 -4
  8. package/build/conductor/bridge-api-client.js +215 -0
  9. package/build/conductor/deny-enforcement-preflight.js +1 -0
  10. package/build/conductor/done-gate.js +44 -5
  11. package/build/conductor/epic-reconcile.js +6 -0
  12. package/build/conductor/install-doctor.js +462 -0
  13. package/build/conductor-bin.js +3 -3
  14. package/build/conductor-bundle-artifacts.js +30 -9
  15. package/build/doctor.js +234 -1
  16. package/build/executor/cli.js +32 -5
  17. package/build/executor/credentials.js +45 -11
  18. package/build/executor/deps.js +14 -0
  19. package/build/executor/env.js +23 -6
  20. package/build/executor/index.js +4 -0
  21. package/build/executor/job-runner.js +119 -9
  22. package/build/executor/permissions.js +12 -2
  23. package/build/executor/preflight.js +95 -8
  24. package/build/executor/prompt-spec.js +51 -0
  25. package/build/executor/runner.js +15 -2
  26. package/build/executor/service-unit.js +876 -0
  27. package/build/executor/test-clock.js +8 -0
  28. package/build/executor/types.js +0 -17
  29. package/build/executor/worker-command.js +62 -9
  30. package/build/index.js +575 -143
  31. package/build/init.js +153 -51
  32. package/build/install-bridge-conductor.js +491 -0
  33. package/build/install-bridge.js +628 -175
  34. package/build/install-reexec.js +233 -0
  35. package/build/mcp-host-config.js +11 -1
  36. package/build/mcp-install-state.js +32 -0
  37. package/build/mcp-provisioning.js +22 -6
  38. package/build/pipelines.generated.js +14 -8
  39. package/build/readme.generated.js +1 -1
  40. package/build/run-unit-tests-launcher.js +257 -0
  41. package/build/setup-epic.js +117 -8
  42. package/build/upgrade-cli.js +1 -15
  43. package/build/version.generated.js +1 -1
  44. package/docs/CONDUCTOR.md +115 -4
  45. package/docs/install/mcp-tool-integrations.md +29 -21
  46. package/package.json +8 -5
  47. package/pipelines/implement-ticket.json +6 -1
  48. package/build/conductor/supervisor-judgment-python.js +0 -141
  49. package/build/conductor/supervisor-judgment.js +0 -215
@@ -0,0 +1,257 @@
1
+ /**
2
+ * BAPI-683 — bounded, cross-platform launcher for the MCP server's compiled
3
+ * `node:test` suite.
4
+ *
5
+ * The previous `scripts.test` invocation enumerated every compiled test file
6
+ * as an explicit `node --test` command-line argument. Windows' CreateProcess
7
+ * command-line length limit (~8191 chars) made that argv too long once the
8
+ * suite grew past ~200 files (`The command line is too long.`, exit 1, before
9
+ * any test ran) — Linux/macOS have a much larger limit, so the same script
10
+ * kept passing there. This module discovers compiled test files at runtime
11
+ * and spawns them in size-bounded batches instead of expanding them ahead of
12
+ * time in package.json, so no single child invocation can approach the
13
+ * Windows ceiling regardless of how many test files exist.
14
+ *
15
+ * `scripts/run-unit-tests.js` is the thin CLI entrypoint that imports this
16
+ * compiled module; the logic lives here (under src/, built by tsc) so it can
17
+ * be unit-tested with node:test like every other module in this package.
18
+ */
19
+ import { readdirSync } from "fs";
20
+ import { spawnSync } from "child_process";
21
+ import path from "path";
22
+ const DEFAULT_BUILD_DIR = "build";
23
+ // The Windows CreateProcess command-line limit is ~8191 characters. This
24
+ // budget stays well under it (accounting for execPath + node options +
25
+ // separators) so a single batch can never approach the real ceiling even on a
26
+ // longer install path. Kept OS-independent so batching is deterministic and
27
+ // stable across every CI runner and locally.
28
+ export const MAX_COMMAND_LENGTH = 6000;
29
+ // Explicit manifest: only tests that call node:test's `mock.module()` API
30
+ // need --experimental-test-module-mocks. Everything else discovered under
31
+ // build/ is eligible for the "normal" group automatically — new tests do NOT
32
+ // need to be registered anywhere else. Add an entry here only when a new test
33
+ // needs module mocking.
34
+ export const MODULE_MOCK_MANIFEST = [
35
+ "build/attachment-download.test.js",
36
+ "build/attachment-upload.test.js",
37
+ "build/automation-progress.test.js",
38
+ "build/conductor/cli-git-hooks.test.js",
39
+ "build/conductor/cli.test.js",
40
+ "build/conductor/git-inspection.test.js",
41
+ "build/conductor/paths.test.js",
42
+ "build/conductor/pr-ci-producer-emit-seam.test.js",
43
+ "build/conductor/security-regressions.test.js",
44
+ "build/conductor/store-lifecycle.test.js",
45
+ "build/conductor/store-queries.test.js",
46
+ "build/conductor/tools-done-gate.test.js",
47
+ "build/conductor/tools.test.js",
48
+ "build/connect-github-api.test.js",
49
+ "build/connect-github-dispatch.static.test.js",
50
+ "build/connect-github-handoff.test.js",
51
+ "build/connect-github.test.js",
52
+ "build/council-wait-recovery.test.js",
53
+ "build/index-artifacts.test.js",
54
+ "build/index-brainstorm-filenames.test.js",
55
+ "build/index-generate-decision-page.integration.test.js",
56
+ "build/index-generate-decision-page.test.js",
57
+ "build/index-heavy-read-truncation.test.js",
58
+ "build/index-output-path.test.js",
59
+ "build/index.review-rounds.test.js",
60
+ "build/recovery-formatting.test.js",
61
+ "build/sfcc/client.test.js",
62
+ "build/sfcc/permissions.test.js",
63
+ "build/sfcc/reads-custom-object-def.test.js",
64
+ "build/sfcc/reads-site-preference.test.js",
65
+ "build/sfcc/reads-system-object.test.js",
66
+ "build/sfcc/register.test.js",
67
+ "build/sfcc/setup-status.test.js",
68
+ "build/sfcc/tool-wrapper.test.js",
69
+ "build/sfcc/writes-custom-object-def.test.js",
70
+ "build/sfcc/writes-site-preference.test.js",
71
+ "build/sfcc/writes-system-object.test.js",
72
+ "build/ticket-wait-recovery.test.js",
73
+ "build/visual-diff.attachment-adapter.test.js",
74
+ "build/visual-diff.registration.test.js",
75
+ "build/wait-for-result.test.js",
76
+ ];
77
+ // Files that must run as their own single-file `node --test --test-force-exit`
78
+ // invocation instead of joining a multi-file batch. --test-force-exit makes
79
+ // the runner exit before it finishes aggregating per-subprocess summaries, so
80
+ // a multi-file batch run with it reports a nondeterministic (undercounted)
81
+ // test tally — failures still surface and still set exit 1, but the count
82
+ // cannot be trusted as a completeness signal. Batched invocations therefore
83
+ // run WITHOUT the flag; only the files listed here get it, each alone, where
84
+ // the tally stays exact. build/secret-safety.test.js is quarantined because it
85
+ // leaks a handle that holds the event loop ~60s after its tests finish (they
86
+ // run in ~147ms). Do NOT add --test-force-exit to batched invocations, and do
87
+ // NOT remove a file from this quarantine, until its underlying handle leak is
88
+ // fixed.
89
+ export const FORCE_EXIT_QUARANTINE = ["build/secret-safety.test.js"];
90
+ // build/integration/** is the separately-invoked MCP_INTEGRATION=1 suite (run
91
+ // via `npm run test:integration`) plus the packaged-CLI smoke test (`npm run
92
+ // test:smoke`) — neither runs as part of `npm test`. `measure-tools.test.js`
93
+ // is the one file under that directory that has always run as part of the
94
+ // normal unit suite.
95
+ const INTEGRATION_DIR_PREFIX = "build/integration/";
96
+ const INTEGRATION_DIR_ALLOWLIST = new Set(["build/integration/measure-tools.test.js"]);
97
+ // Pre-existing compiled test files that were not wired into ANY script before
98
+ // this launcher existed (discovered while building it). BAPI-683 preserves
99
+ // the exact current running test set rather than silently picking these up —
100
+ // see docs/BAPI-683-mcp-ci-windows-npm-test-launcher.md for the follow-up.
101
+ const KNOWN_UNREGISTERED_ORPHANS = new Set([
102
+ "build/conductor/conductor-runtime.integration.test.js",
103
+ "build/sfcc/register-site-preference.integration.test.js",
104
+ ]);
105
+ /** Recursively lists compiled `*.test.js` files under `<root>/<buildDir>`, sorted and package-relative. */
106
+ export function discoverTestFiles(options) {
107
+ const { root, buildDir = DEFAULT_BUILD_DIR, fsImpl = { readdirSync } } = options;
108
+ const absoluteBuildDir = path.join(root, buildDir);
109
+ const results = [];
110
+ const walk = (dir) => {
111
+ let entries;
112
+ try {
113
+ entries = fsImpl.readdirSync(dir, { withFileTypes: true });
114
+ }
115
+ catch (err) {
116
+ if (err?.code === "ENOENT")
117
+ return;
118
+ throw err;
119
+ }
120
+ for (const entry of entries) {
121
+ const full = path.join(dir, entry.name);
122
+ if (entry.isDirectory()) {
123
+ walk(full);
124
+ }
125
+ else if (entry.name.endsWith(".test.js")) {
126
+ results.push(path.relative(root, full).split(path.sep).join("/"));
127
+ }
128
+ }
129
+ };
130
+ walk(absoluteBuildDir);
131
+ results.sort();
132
+ return results;
133
+ }
134
+ /** Throws if the module-mock or force-exit-quarantine manifest has drifted from the discovered build output. */
135
+ export function validateManifest(discovered) {
136
+ const discoveredSet = new Set(discovered);
137
+ const seen = new Set();
138
+ for (const entry of MODULE_MOCK_MANIFEST) {
139
+ if (seen.has(entry)) {
140
+ throw new Error(`run-unit-tests: duplicate module-mock manifest entry: ${entry}`);
141
+ }
142
+ seen.add(entry);
143
+ if (!discoveredSet.has(entry)) {
144
+ throw new Error(`run-unit-tests: module-mock manifest entry not found in build output: ${entry} ` +
145
+ `(rebuild with 'npm run build', or remove it from MODULE_MOCK_MANIFEST if the file was deleted/renamed)`);
146
+ }
147
+ }
148
+ for (const entry of FORCE_EXIT_QUARANTINE) {
149
+ if (seen.has(entry)) {
150
+ throw new Error(`run-unit-tests: quarantine entry duplicated or also present in the module-mock manifest: ${entry}`);
151
+ }
152
+ seen.add(entry);
153
+ if (!discoveredSet.has(entry)) {
154
+ throw new Error(`run-unit-tests: force-exit quarantine entry not found in build output: ${entry} ` +
155
+ `(rebuild with 'npm run build', or remove it from FORCE_EXIT_QUARANTINE if the file was deleted/renamed)`);
156
+ }
157
+ }
158
+ }
159
+ /** Selects and validates the requested group's file list from the discovered inventory. */
160
+ export function selectTestGroup(mode, discovered) {
161
+ if (mode !== "normal" && mode !== "module-mocks") {
162
+ throw new Error(`run-unit-tests: unknown group "${mode}" (expected "normal" or "module-mocks")`);
163
+ }
164
+ validateManifest(discovered);
165
+ const moduleMockSet = new Set(MODULE_MOCK_MANIFEST);
166
+ const normal = discovered.filter((file) => {
167
+ if (moduleMockSet.has(file))
168
+ return false;
169
+ if (file.startsWith(INTEGRATION_DIR_PREFIX) && !INTEGRATION_DIR_ALLOWLIST.has(file))
170
+ return false;
171
+ if (KNOWN_UNREGISTERED_ORPHANS.has(file))
172
+ return false;
173
+ return true;
174
+ });
175
+ const overlap = normal.filter((file) => moduleMockSet.has(file));
176
+ if (overlap.length > 0) {
177
+ throw new Error(`run-unit-tests: file(s) present in both normal and module-mock groups: ${overlap.join(", ")}`);
178
+ }
179
+ const selected = mode === "normal" ? normal : [...MODULE_MOCK_MANIFEST];
180
+ if (selected.length === 0) {
181
+ throw new Error(`run-unit-tests: selected group "${mode}" is empty — refusing to run zero tests`);
182
+ }
183
+ return selected;
184
+ }
185
+ /**
186
+ * Greedily packs sorted file paths into batches whose serialized
187
+ * `execPath ...nodeOptions ...files` command length stays within `maxLength`.
188
+ * A single file that cannot fit even alone is rejected rather than spawned.
189
+ */
190
+ export function partitionTestBatches(files, options = {}) {
191
+ const { execPath = process.execPath, nodeOptions = [], maxLength = MAX_COMMAND_LENGTH } = options;
192
+ const prefixLength = [execPath, ...nodeOptions].join(" ").length;
193
+ const batches = [];
194
+ let current = [];
195
+ let currentLength = prefixLength;
196
+ for (const file of files) {
197
+ const addition = 1 + file.length; // separating space + path
198
+ if (prefixLength + addition > maxLength) {
199
+ throw new Error(`run-unit-tests: "${file}" alone exceeds the command-length budget ` +
200
+ `(${prefixLength + addition} > ${maxLength} chars) — cannot batch it safely`);
201
+ }
202
+ if (current.length > 0 && currentLength + addition > maxLength) {
203
+ batches.push(current);
204
+ current = [];
205
+ currentLength = prefixLength;
206
+ }
207
+ current.push(file);
208
+ currentLength += addition;
209
+ }
210
+ if (current.length > 0) {
211
+ batches.push(current);
212
+ }
213
+ return batches;
214
+ }
215
+ /** Discovers, selects, batches, and spawns the requested test group; returns an aggregate exit code. */
216
+ export function runSelectedGroup(mode, options) {
217
+ // Validate the mode before touching the filesystem or spawning anything —
218
+ // an unknown group must fail fast with no discovery/spawn side effects.
219
+ if (mode !== "normal" && mode !== "module-mocks") {
220
+ throw new Error(`run-unit-tests: unknown group "${mode}" (expected "normal" or "module-mocks")`);
221
+ }
222
+ const { root, execPath = process.execPath, spawnFn = spawnSync, discoverFn = (opts) => discoverTestFiles(opts), log = console.log, } = options;
223
+ const discovered = discoverFn({ root });
224
+ const selected = selectTestGroup(mode, discovered);
225
+ // Split out the force-exit quarantine: those files run last, each as its own
226
+ // single-file spawn WITH --test-force-exit (exact tally, prompt exit despite
227
+ // the leaked handle). Every batched spawn runs WITHOUT the flag so its test
228
+ // tally stays deterministic — see FORCE_EXIT_QUARANTINE above.
229
+ const quarantineSet = new Set(FORCE_EXIT_QUARANTINE);
230
+ const batchable = selected.filter((file) => !quarantineSet.has(file));
231
+ const quarantined = selected.filter((file) => quarantineSet.has(file));
232
+ const extraFlags = mode === "module-mocks" ? ["--experimental-test-module-mocks"] : [];
233
+ const nodeOptions = [...extraFlags, "--test"];
234
+ const batches = partitionTestBatches(batchable, { execPath, nodeOptions });
235
+ log(`run-unit-tests: group="${mode}" files=${selected.length} batches=${batches.length}` +
236
+ (quarantined.length > 0 ? ` quarantined=${quarantined.length}` : ""));
237
+ let aggregateExit = 0;
238
+ const spawn = (args) => {
239
+ const result = spawnFn(execPath, args, {
240
+ cwd: root,
241
+ stdio: "inherit",
242
+ shell: false,
243
+ });
244
+ if ((result.status ?? 1) !== 0) {
245
+ aggregateExit = 1;
246
+ }
247
+ };
248
+ batches.forEach((batch, index) => {
249
+ log(`run-unit-tests: batch ${index + 1}/${batches.length} (${batch.length} file(s))`);
250
+ spawn([...nodeOptions, ...batch]);
251
+ });
252
+ quarantined.forEach((file, index) => {
253
+ log(`run-unit-tests: quarantined ${index + 1}/${quarantined.length} ${file} (single-file, --test-force-exit)`);
254
+ spawn([...nodeOptions, "--test-force-exit", file]);
255
+ });
256
+ return aggregateExit;
257
+ }
@@ -28,6 +28,12 @@ import readline from "node:readline";
28
28
  import { approveEpicPlan, createEpicRun, fetchEpicRunState, resolveConductorBridgeApiAccess, storeEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
29
29
  import { validateBranchName } from "./base-ref.js";
30
30
  import { hashPlan } from "./conductor/plan.js";
31
+ /** Accepted `policy_json.review_policy.source` values (the `ReviewPolicy` surface). */
32
+ export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
33
+ "verdict_protocol",
34
+ "native_review_decision",
35
+ "none",
36
+ ];
31
37
  /** Echoed single-line prompt on stderr (mirrors connect-github's helper). */
32
38
  function defaultPromptLine(promptText) {
33
39
  return new Promise((resolve) => {
@@ -82,6 +88,12 @@ export function getSetupEpicUsage() {
82
88
  " on origin, and every child-ticket PR targets it.",
83
89
  " Omit (the default) to continue on the repository base",
84
90
  " branch. Interactive runs are offered a proposal.",
91
+ " --review-policy <src> PER-RUN review policy source, one of:",
92
+ ` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
93
+ " Composed into policy_json.review_policy on create.",
94
+ " This setting is per-run: repository-level review-policy",
95
+ " defaults are NOT persisted in supervisor project",
96
+ " defaults yet — that is BAPI-694.",
85
97
  " --dry-run Validate and preview; make no mutating calls",
86
98
  " --json Emit a single JSON result object on stdout",
87
99
  " -h, --help Show this help",
@@ -115,6 +127,25 @@ function parseFeatureBranchValue(raw) {
115
127
  return { ok: false, error: `Invalid --feature-branch value: ${reason}` };
116
128
  return { ok: true, value: trimmed };
117
129
  }
130
+ /**
131
+ * Validate a `--review-policy` value against the existing `ReviewPolicy`
132
+ * vocabulary. Rejects BEFORE any network access so an unknown source can never
133
+ * reach a create request (BAPI-679). An explicitly blank value normalizes to
134
+ * absent, matching `--feature-branch`.
135
+ */
136
+ function parseReviewPolicyValue(raw) {
137
+ const trimmed = raw.trim();
138
+ if (trimmed === "")
139
+ return { ok: true, value: undefined };
140
+ if (!SETUP_EPIC_REVIEW_POLICY_SOURCES.includes(trimmed)) {
141
+ return {
142
+ ok: false,
143
+ error: `Invalid --review-policy value '${trimmed}'. Expected one of: ` +
144
+ SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", "),
145
+ };
146
+ }
147
+ return { ok: true, value: trimmed };
148
+ }
118
149
  export function parseSetupEpicArgs(argv) {
119
150
  if (argv.includes("-h") || argv.includes("--help")) {
120
151
  return { status: "help", usage: getSetupEpicUsage() };
@@ -124,6 +155,7 @@ export function parseSetupEpicArgs(argv) {
124
155
  let repo;
125
156
  let planVersion;
126
157
  let featureBranch;
158
+ let reviewPolicy;
127
159
  let dryRun = false;
128
160
  let json = false;
129
161
  for (let i = 0; i < argv.length; i++) {
@@ -136,6 +168,14 @@ export function parseSetupEpicArgs(argv) {
136
168
  featureBranch = parsedFb.value;
137
169
  continue;
138
170
  }
171
+ // `--review-policy=<source>` inline form (BAPI-679).
172
+ if (arg.startsWith("--review-policy=")) {
173
+ const parsedRp = parseReviewPolicyValue(arg.slice("--review-policy=".length));
174
+ if (!parsedRp.ok)
175
+ return { status: "error", message: parsedRp.error };
176
+ reviewPolicy = parsedRp.value;
177
+ continue;
178
+ }
139
179
  switch (arg) {
140
180
  case "--feature-branch": {
141
181
  // Do not consume a following flag as the value (Step 2.5).
@@ -149,6 +189,17 @@ export function parseSetupEpicArgs(argv) {
149
189
  i++;
150
190
  break;
151
191
  }
192
+ case "--review-policy": {
193
+ const v = takeValue(argv, i, arg);
194
+ if (v === null)
195
+ return { status: "error", message: "--review-policy requires a value." };
196
+ const parsedRp = parseReviewPolicyValue(v);
197
+ if (!parsedRp.ok)
198
+ return { status: "error", message: parsedRp.error };
199
+ reviewPolicy = parsedRp.value;
200
+ i++;
201
+ break;
202
+ }
152
203
  case "--epic-key": {
153
204
  const v = takeValue(argv, i, arg);
154
205
  if (v === null)
@@ -206,7 +257,7 @@ export function parseSetupEpicArgs(argv) {
206
257
  return { status: "error", message: "setup-epic requires --plan-file <path>." };
207
258
  return {
208
259
  status: "ok",
209
- options: { epicKey, planFile, repo, planVersion, featureBranch, dryRun, json },
260
+ options: { epicKey, planFile, repo, planVersion, featureBranch, reviewPolicy, dryRun, json },
210
261
  };
211
262
  }
212
263
  /**
@@ -246,11 +297,19 @@ export function validateEpicPlanSidecar(parsed) {
246
297
  return { ok: false, error: `Duplicate ticket_key in plan: ${key}.` };
247
298
  keys.add(key);
248
299
  if (node.touched_files === undefined) {
249
- // Only fatal when the repo has file-overlap serialization enabled, in
250
- // which case the server 400s. Warn either way it is the difference
251
- // between siblings serializing and colliding.
252
- warnings.push(`Node ${key} has no touched_files. File-overlap serialization cannot ` +
253
- `protect it; if the repo has that flag on, the server will reject this plan.`);
300
+ // BAPI-722: the server ACCEPTS an omitted touched_files it is an explicit
301
+ // opt-out of preemptive overlap serialization, not a rejection. This stays a
302
+ // warning because it is a genuine planning-QUALITY signal (an undeclared node
303
+ // is never pre-serialized against its siblings, so it relies entirely on the
304
+ // reactive merge-conflict/rebase lane), but it must not claim a hard failure
305
+ // the server will not produce — a false hard-rejection warning trains authors
306
+ // to distrust the whole validator. Reported for EVERY offending node in one
307
+ // pass rather than stopping at the first.
308
+ warnings.push(`Node ${key} has no touched_files, so it opts out of preemptive ` +
309
+ `file-overlap serialization: the server accepts the plan but will not ` +
310
+ `pre-serialize ${key} against overlapping siblings (a conflict would ` +
311
+ `instead be caught reactively and rebased). Add touched_files to ${key} ` +
312
+ `to get that scheduling protection (use [] when no files are predicted).`);
254
313
  }
255
314
  }
256
315
  // Build one adjacency in a consistent predecessor -> successor direction, the
@@ -468,6 +527,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
468
527
  let existingRunId = null;
469
528
  let existingStatus = null;
470
529
  let existingBaseBranch = null;
530
+ let existingReviewPolicy = null;
471
531
  try {
472
532
  const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
473
533
  existingRunId = state.epic_run?.epic_run_id ?? null;
@@ -479,6 +539,19 @@ export async function runSetupEpicCli(argv, overrides = {}) {
479
539
  existingBaseBranch = typeof existingBase === "string" && existingBase.trim() !== ""
480
540
  ? existingBase
481
541
  : null;
542
+ // BAPI-679: read the existing run's review policy for the conflict guard
543
+ // below. Absent/malformed reads as null — "the resolver's default applies" —
544
+ // which is deliberately NOT the same as an explicitly selected source.
545
+ const existingReview = existingPolicy && typeof existingPolicy === "object"
546
+ ? existingPolicy.review_policy
547
+ : undefined;
548
+ const existingReviewSource = existingReview && typeof existingReview === "object"
549
+ ? existingReview.source
550
+ : undefined;
551
+ existingReviewPolicy =
552
+ typeof existingReviewSource === "string" && existingReviewSource.trim() !== ""
553
+ ? existingReviewSource
554
+ : null;
482
555
  }
483
556
  catch (err) {
484
557
  if (err instanceof ConductorBridgeApiError && err.status === 404) {
@@ -511,6 +584,25 @@ export async function runSetupEpicCli(argv, overrides = {}) {
511
584
  `it unchanged, or abandon the run to start over on a new branch.`);
512
585
  return 1;
513
586
  }
587
+ // --- Review-policy conflict guard against an existing live run ----------
588
+ // BAPI-679, analogous to the feature-branch guard above. An explicitly selected
589
+ // policy that disagrees with a live run's effective policy must fail closed:
590
+ // setup-epic never retargets an existing run, and silently proceeding would let
591
+ // an operator believe the run is being reviewed under a policy it is not.
592
+ // Selecting nothing preserves the existing run's policy untouched.
593
+ if (existingRunId &&
594
+ opts.reviewPolicy !== undefined &&
595
+ existingReviewPolicy !== opts.reviewPolicy) {
596
+ deps.errorLog(`Epic ${opts.epicKey} already has a live run (${existingRunId}) whose review ` +
597
+ `policy is ${existingReviewPolicy ? `'${existingReviewPolicy}'` : "unset (resolver default)"}, ` +
598
+ `which conflicts with the requested '${opts.reviewPolicy}'. setup-epic will not ` +
599
+ `retarget an existing run. Re-run without --review-policy to reuse it unchanged, ` +
600
+ `or abandon the run to start over.`);
601
+ return 1;
602
+ }
603
+ if (opts.reviewPolicy !== undefined) {
604
+ say(`Review: ${opts.reviewPolicy} (per-run; repository defaults pending BAPI-694)`);
605
+ }
514
606
  if (opts.dryRun) {
515
607
  say("");
516
608
  say("[dry-run] No changes made. Would:");
@@ -523,6 +615,9 @@ export async function runSetupEpicCli(argv, overrides = {}) {
523
615
  if (featureBranch !== undefined) {
524
616
  say(` - feature branch: ${featureBranch} (create from repository base branch; no request made in dry-run)`);
525
617
  }
618
+ if (opts.reviewPolicy !== undefined) {
619
+ say(` - review policy: ${opts.reviewPolicy} (per-run policy_json.review_policy; not persisted as a repository default until BAPI-694)`);
620
+ }
526
621
  say(` - POST /jira/epic-runs/runs/${opts.epicKey}/plan (v${plan.plan_version})`);
527
622
  say(` - POST /jira/epic-runs/runs/${opts.epicKey}/approve-plan (v${plan.plan_version})`);
528
623
  if (opts.json) {
@@ -535,6 +630,11 @@ export async function runSetupEpicCli(argv, overrides = {}) {
535
630
  existing_run_id: existingRunId,
536
631
  // Only present for a feature-branch run — no-feature JSON is unchanged.
537
632
  ...(featureBranch !== undefined ? { feature_branch: featureBranch } : {}),
633
+ // Only present when a policy was explicitly selected — the
634
+ // no-policy JSON shape is unchanged.
635
+ ...(opts.reviewPolicy !== undefined
636
+ ? { review_policy: { source: opts.reviewPolicy }, review_policy_scope: "per-run" }
637
+ : {}),
538
638
  warnings,
539
639
  }, null, 2));
540
640
  }
@@ -563,8 +663,17 @@ export async function runSetupEpicCli(argv, overrides = {}) {
563
663
  try {
564
664
  // Persist the confirmed feature branch as policy_json.base_branch ONLY when
565
665
  // one was selected; otherwise keep the exact legacy create request shape.
566
- const createRequest = featureBranch !== undefined
567
- ? { epicKey: opts.epicKey, policyJson: { base_branch: featureBranch } }
666
+ // BAPI-679: base_branch and review_policy COMPOSE — neither replaces the
667
+ // other, and when neither is selected the legacy create request shape is
668
+ // preserved byte-for-byte so the pre-feature contract is unchanged.
669
+ const policyJson = {};
670
+ if (featureBranch !== undefined)
671
+ policyJson.base_branch = featureBranch;
672
+ if (opts.reviewPolicy !== undefined) {
673
+ policyJson.review_policy = { source: opts.reviewPolicy };
674
+ }
675
+ const createRequest = Object.keys(policyJson).length > 0
676
+ ? { epicKey: opts.epicKey, policyJson }
568
677
  : { epicKey: opts.epicKey };
569
678
  const run = await createEpicRun(access, createRequest, deps.fetch);
570
679
  result.epic_run_id = run.epic_run_id;
@@ -20,23 +20,9 @@ import path from "path";
20
20
  import { VERSION } from "./version.generated.js";
21
21
  import { runInit } from "./init.js";
22
22
  import { isNewerVersion } from "./update-check.js";
23
+ import { fetchLatestVersion } from "./cli-release.js";
23
24
  import { buildGenericAgentShellCommand, detectTerminal, getDefaultSpawnTerminalTabForPlatform, createDefaultStartTicketsDeps, } from "./start-tickets.js";
24
25
  import { AGENT_REGISTRY } from "./agent-registry.js";
25
- async function fetchLatestVersion() {
26
- try {
27
- const res = await fetch("https://registry.npmjs.org/@bridge_gpt/mcp-server/latest", {
28
- signal: AbortSignal.timeout(3000),
29
- });
30
- if (res.ok) {
31
- const data = (await res.json());
32
- return data.version || null;
33
- }
34
- }
35
- catch {
36
- return null;
37
- }
38
- return null;
39
- }
40
26
  export async function runUpgradeCli(argv) {
41
27
  const cwd = process.cwd();
42
28
  const isDryRun = argv.includes("--dry-run");
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED — do not edit manually. Regenerate with: npm run build
2
- export const VERSION = "0.2.34";
2
+ export const VERSION = "0.2.36";
package/docs/CONDUCTOR.md CHANGED
@@ -25,6 +25,23 @@ v2 splits the old local tick into two halves:
25
25
  | **Reconciler** | **Server-side**, on the Bridge API worker dyno, every 30s | Selects every epic run whose status is `active`, evaluates gates, and enqueues executor jobs. Nothing to install or schedule. |
26
26
  | **Executor** | **Locally**, on your machine | Polls for jobs, claims them, spawns worker agents, heartbeats. This is the only piece you run. |
27
27
 
28
+ > **⚠️ For a run against a LOCAL server, do NOT use `npx`.** The npm-published
29
+ > package routinely lags `main` — that is the normal steady state right after any
30
+ > conductor ticket merges. When the server is built from a newer `main` it mints
31
+ > `prompt_spec` placeholders the older published executor rejects, and the job dies
32
+ > instantly with `ContractError.Prompt` (`prompt_spec for job N declares unsupported
33
+ > placeholder '…'`). Confirmed on the BAPI-716 run: published `0.2.34` vs. local
34
+ > `0.2.35`, over BAPI-699's `RELATED_CONTEXT`. Build and run the local tree so the
35
+ > executor and server come from the same commit:
36
+ >
37
+ > ```
38
+ > cd mcp_server && npm run build
39
+ > node mcp_server/build/index.js executor --repo <name>
40
+ > ```
41
+
42
+ For a run against a deployed server whose version you match, the published CLI is
43
+ fine:
44
+
28
45
  ```
29
46
  npx -y @bridge_gpt/mcp-server executor --repo <name>
30
47
  ```
@@ -54,10 +71,104 @@ preview the calls without mutating anything.
54
71
  Once the plan is approved the run becomes `active`, the server-side reconciler
55
72
  picks it up within ~30s, and your local `executor` starts claiming jobs.
56
73
 
57
- > **A caveat worth knowing before you plan an epic.** Conductor has no
58
- > merge-conflict handling, and plan-time file-overlap serialization is currently
59
- > dark (the planner does not yet emit the `touched_files` metadata it needs). If
60
- > two sibling tickets touch the same files, do not let them dispatch in parallel.
74
+ ### `touched_files` optional, but declare it anyway
75
+
76
+ **Plan-time file-overlap serialization is live and on by default.**
77
+ `planner_file_overlap_serialization_enabled` (BAPI-495, defaulted on by BAPI-677 /
78
+ migration 0126, on `config_code_repositories`) makes overlapping siblings gain a
79
+ `depends_on`/edge on each other *before* the DAG is validated, hashed, and stored,
80
+ while siblings with disjoint files stay parallel.
81
+
82
+ `touched_files` is the plan-time metadata that pass reads. It is a **best-effort
83
+ prediction, never reconciled against the real diff**, so BAPI-722 made it optional.
84
+ Three outcomes, and the difference between the first two is the point
85
+ (`_extract_predicted_touched_files`, `api/library/db/epic_runs.py:3195`):
86
+
87
+ | In the node | Meaning | Result |
88
+ |---|---|---|
89
+ | Field **absent** | The node **opts out** of preemptive serialization | **Accepted.** Every pair involving it is skipped (`undeclared_pairs_skipped`); no edge is added in either direction |
90
+ | `"touched_files": []` | A deliberate "this ticket predicts no file overlap" | Accepted as a *declared* node with an empty set |
91
+ | Field present but **malformed** | A non-list, or a list holding a blank, absolute, or non-string path | **400** — `EpicRunConflictError(code="VALIDATION")` |
92
+
93
+ Only omission is relaxed. Data you *did* supply must be well-formed. Paths are
94
+ repo-root-relative.
95
+
96
+ ```json
97
+ {
98
+ "plan_version": 1,
99
+ "nodes": [
100
+ { "ticket_key": "BAPI-405", "status": "planned", "depends_on": [],
101
+ "touched_files": ["api/routes/jira_api.py"] }
102
+ ],
103
+ "edges": []
104
+ }
105
+ ```
106
+
107
+ > **`setup-epic --dry-run` warns on a missing `touched_files`, and that warning is
108
+ > a planning-quality signal, not a predicted failure.** The server accepts the
109
+ > plan. What you lose is scheduling protection: an undeclared node is never
110
+ > pre-serialized against its siblings.
111
+
112
+ Declare it anyway. Serialization only ever **adds** edges — it never removes an
113
+ authored one — so hand-authored `depends_on` and `touched_files` compose safely.
114
+ Author both: the edges as your reviewable statement of intent, `touched_files` so
115
+ the scheduler can catch overlaps you did not anticipate.
116
+
117
+ > **Serialization is a scheduling optimization, not a safety mechanism.** It avoids
118
+ > *dispatching* file-overlapping siblings in parallel; it does not resolve a
119
+ > conflict once one exists. The safety net is reactive and independent of any
120
+ > prediction: a PR that a sibling's merge turns `CONFLICTING` is observed live and
121
+ > routed to `JOB_TYPE_REBASE` (`_merge_entry_failure_requires_rebase` in the
122
+ > reconciler). That backstop covers declared and undeclared nodes alike — which is
123
+ > exactly why omitting `touched_files` is safe to accept.
124
+
125
+ ## The conductor CI gate — `conductor-ci / gate` (BAPI-695)
126
+
127
+ **Before driving a run you want CI-gated, wire its policy.** The reconciler's
128
+ per-ticket `ci` gate reads **`policy_json.required_checks`** (a declared
129
+ `RunPolicy` key) and observes those check-run conclusions at the ticket PR's
130
+ **current head SHA**. If that list is empty, the observer short-circuits to
131
+ `PASSED` with `{"no_required_checks": true}` and **runs no CI at all** — the loop
132
+ merges on hand-verification only. `.github/workflows/conductor-ci.yml` publishes
133
+ the real, always-reporting required context **`conductor-ci / gate`**; set
134
+
135
+ ```json
136
+ "policy_json": { "required_checks": ["conductor-ci / gate"] }
137
+ ```
138
+
139
+ on every run you want gated. The workflow existing is not enough — the context has
140
+ to be in the run policy.
141
+
142
+ > **`setup-epic` has no flag for this.** There is no `--required-checks` option, so a
143
+ > run created by `setup-epic` starts with an **empty** `required_checks` and its `ci`
144
+ > gate short-circuits to `PASSED` until you wire it. Because `setup-epic` also
145
+ > approves the plan — making the run `active` and visible to the reconciler within
146
+ > ~30s — wire the policy by PATCH **immediately** after setup, before any ticket can
147
+ > reach its `ci` gate.
148
+
149
+ > **This is NOT `done_gate_config`.** The per-run `policy_json.required_checks`
150
+ > read by the reconciler `ci` gate is a *different* surface from the supervisor
151
+ > `done_gate_config.required_ci_checks_green` documented under "Supervisor config"
152
+ > below. BAPI-695 wires only the former, per run; it does **not** change
153
+ > `done_gate_config` defaults, repo-default policy, or GitHub branch protection.
154
+
155
+ **Once gated, expect this behavior:**
156
+
157
+ - The `ci` gate now waits for `conductor-ci / gate` on the ticket PR's current head.
158
+ A complete **red** aggregator enters the bounded `ci_fix` remediation path; a
159
+ **missing or still-running** current-head context stays pending (never a hard
160
+ fail).
161
+ - A **green from an older head** can never satisfy the gate after a rebase or
162
+ force-push — the poll is bound to the current head SHA, and the workflow's per-PR
163
+ concurrency cancels the superseded run so the new head publishes a fresh context.
164
+ - Out-of-scope PRs (forks, `dependabot/*`, non-`feature/BAPI-*`) get an intentional
165
+ fast green — cost control, **not** proof the suites ran.
166
+
167
+ For the exact new-run/PATCH procedure (and the **whole-object `policy_json`
168
+ replacement** caution — GET, merge `required_checks` locally, PATCH the whole
169
+ object), and the immutable case-sensitive matching of the context string, see the
170
+ operator runbook, **§4a "Wire the conductor CI required check"**
171
+ ([`docs/claude/epic-conductor-v2-operator-runbook.md`](../../docs/claude/epic-conductor-v2-operator-runbook.md)).
61
172
 
62
173
  ## Conductor observability (opt-in via `--conductor`, BAPI-394)
63
174