@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.
- package/README.md +456 -370
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/claude-review-workflow.js +264 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +4 -4
- package/build/conductor/bridge-api-client.js +215 -0
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +30 -9
- package/build/doctor.js +234 -1
- package/build/executor/cli.js +32 -5
- package/build/executor/credentials.js +45 -11
- package/build/executor/deps.js +14 -0
- package/build/executor/env.js +23 -6
- package/build/executor/index.js +4 -0
- package/build/executor/job-runner.js +119 -9
- package/build/executor/permissions.js +12 -2
- package/build/executor/preflight.js +95 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +15 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +0 -17
- package/build/executor/worker-command.js +62 -9
- package/build/index.js +575 -143
- package/build/init.js +153 -51
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +628 -175
- package/build/install-reexec.js +233 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-provisioning.js +22 -6
- package/build/pipelines.generated.js +14 -8
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +257 -0
- package/build/setup-epic.js +117 -8
- package/build/upgrade-cli.js +1 -15
- package/build/version.generated.js +1 -1
- package/docs/CONDUCTOR.md +115 -4
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +8 -5
- package/pipelines/implement-ticket.json +6 -1
- package/build/conductor/supervisor-judgment-python.js +0 -141
- 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
|
+
}
|
package/build/setup-epic.js
CHANGED
|
@@ -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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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;
|
package/build/upgrade-cli.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|