@bridge_gpt/mcp-server 0.2.50 → 0.2.52
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 +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +65 -0
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +230 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +290 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +137 -29
- package/build/executor/merge-job.js +102 -6
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +535 -95
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +10 -2
- package/build/plan-epic-conductor-eligibility.js +213 -0
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +592 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +5 -3
- package/pipelines/plan-epic.json +5 -0
package/build/drive-epic.js
CHANGED
|
@@ -51,8 +51,13 @@
|
|
|
51
51
|
* ratcheted down, and a tool would be useless in a bare-chat session anyway,
|
|
52
52
|
* where the model has no tool call to make.
|
|
53
53
|
*/
|
|
54
|
+
import { readFile } from "node:fs/promises";
|
|
55
|
+
import { dirname, join } from "node:path";
|
|
54
56
|
import { resolveConductorBridgeApiAccess, fetchConductorReadiness, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
|
|
55
57
|
import { runSetupEpicCli } from "./setup-epic.js";
|
|
58
|
+
import { formatPlaneDiagnostic, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
59
|
+
import { PLANE_SERVER_PORT_ENV_VAR } from "./plane/types.js";
|
|
60
|
+
import { validateBranchName } from "./base-ref.js";
|
|
56
61
|
// BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
|
|
57
62
|
// the printed invocation interpolates it rather than repeating it.
|
|
58
63
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
@@ -106,6 +111,37 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
106
111
|
satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
|
|
107
112
|
},
|
|
108
113
|
];
|
|
114
|
+
/**
|
|
115
|
+
* The readiness facts a `plane up` can actually repair (BAPI-1054).
|
|
116
|
+
*
|
|
117
|
+
* Both are liveness of a process this machine starts. The other three —
|
|
118
|
+
* `supervisor_setup`, `supervisor_config`, `github_credentials` — are stored
|
|
119
|
+
* server-side state or credentials, and no amount of starting processes creates
|
|
120
|
+
* them. Naming the repairable set here, once, is what keeps the composition
|
|
121
|
+
* from ever attempting a bring-up that could not have helped.
|
|
122
|
+
*/
|
|
123
|
+
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
|
|
124
|
+
/**
|
|
125
|
+
* Classify the SERVER's readiness report into the composition's three cases.
|
|
126
|
+
*
|
|
127
|
+
* Pure, and it re-derives no fact: it filters {@link V2_READINESS_REQUIREMENTS}
|
|
128
|
+
* with the same predicates {@link selectConductor} uses, so the router and the
|
|
129
|
+
* composition can never disagree about whether v2 can run.
|
|
130
|
+
*
|
|
131
|
+
* `operator_blocked` names the FIRST unmet non-runtime requirement in the stable
|
|
132
|
+
* declaration order. One deterministic reason, not a list: an operator handed
|
|
133
|
+
* three competing actions has to work out which to do first, and the order here
|
|
134
|
+
* already encodes that (setup, then config, then credentials).
|
|
135
|
+
*/
|
|
136
|
+
export function classifyV2Readiness(readiness) {
|
|
137
|
+
const unmet = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
|
|
138
|
+
if (unmet.length === 0)
|
|
139
|
+
return { kind: "green" };
|
|
140
|
+
const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
|
|
141
|
+
if (blocker)
|
|
142
|
+
return { kind: "operator_blocked", id: blocker.id, describe: blocker.describe };
|
|
143
|
+
return { kind: "runtime_only", unmetIds: unmet.map((req) => req.id) };
|
|
144
|
+
}
|
|
109
145
|
export function getDriveEpicUsage() {
|
|
110
146
|
return [
|
|
111
147
|
"Usage: mcp-server drive-epic [options] <EPIC>",
|
|
@@ -117,11 +153,44 @@ export function getDriveEpicUsage() {
|
|
|
117
153
|
"Arguments:",
|
|
118
154
|
" <EPIC> Jira epic key, matches [A-Z]+-[0-9]+ (e.g. BAPI-885)",
|
|
119
155
|
"",
|
|
156
|
+
"Branch strategy (v2): a plan with two or more nodes defaults to a dedicated",
|
|
157
|
+
"epic/<KEY> branch — child PRs target it, one draft integration PR targets the",
|
|
158
|
+
"repository base branch. Both flags below only override that default; the",
|
|
159
|
+
"strategy itself is decided by the bootstrap this command delegates to, never",
|
|
160
|
+
"here.",
|
|
161
|
+
"",
|
|
162
|
+
"One command (BAPI-1054): with --plan-file, drive-epic starts the conductor",
|
|
163
|
+
"runtime for you when readiness is missing ONLY the reconciler/executor facts,",
|
|
164
|
+
"then creates and approves the run — one invocation, no manual `plane up` first.",
|
|
165
|
+
"A report missing supervisor setup, supervisor configuration, or GitHub App",
|
|
166
|
+
"credentials is NOT started: those are operator-owned, and drive-epic exits with",
|
|
167
|
+
"that one named reason instead.",
|
|
168
|
+
"",
|
|
169
|
+
"Preconditions are validated, never repaired: the current directory must be a Git",
|
|
170
|
+
"work tree, and the local server port must be free. No worktree is created for",
|
|
171
|
+
"you and no replacement port is chosen for you — each failure names the fix (for",
|
|
172
|
+
`the port, set ${PLANE_SERVER_PORT_ENV_VAR} to a free one). Fix it and rerun.`,
|
|
173
|
+
"",
|
|
174
|
+
"Manual fallback (still supported): run `plane up` yourself, then rerun this",
|
|
175
|
+
"command with the same --plan-file against the now-live plane.",
|
|
176
|
+
"",
|
|
120
177
|
"Options:",
|
|
121
178
|
" --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
|
|
122
179
|
" selected, drive-epic runs that bootstrap directly",
|
|
123
180
|
" instead of printing the command to run.",
|
|
124
181
|
" --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
|
|
182
|
+
" --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
|
|
183
|
+
" verbatim, never interpreted here.",
|
|
184
|
+
" --into-base OPT OUT of the epic-branch default: children target the",
|
|
185
|
+
" repository base branch (the pre-BAPI-1009 behavior).",
|
|
186
|
+
" Contradictory with --feature-branch; passing both is a",
|
|
187
|
+
" parse error.",
|
|
188
|
+
"",
|
|
189
|
+
"Policy (forwarded verbatim; drive-epic interprets none of it):",
|
|
190
|
+
" --policy-file <path> JSON file holding the COMPLETE run policy.",
|
|
191
|
+
" --review-policy <src> PER-RUN review policy source.",
|
|
192
|
+
" --replace-policy Authorize replacing a LIVE run's stored policy.",
|
|
193
|
+
"",
|
|
125
194
|
" -h, --help Show this help",
|
|
126
195
|
].join("\n");
|
|
127
196
|
}
|
|
@@ -136,28 +205,88 @@ export function parseDriveEpicArgs(argv) {
|
|
|
136
205
|
const positionals = [];
|
|
137
206
|
let planFile;
|
|
138
207
|
let repo;
|
|
208
|
+
let featureBranch;
|
|
209
|
+
// Tracked separately from the value so a blank `--feature-branch=` still
|
|
210
|
+
// registers as a stated branch-strategy intent for the contradiction guard.
|
|
211
|
+
let featureBranchSupplied = false;
|
|
212
|
+
let intoBase = false;
|
|
213
|
+
// BAPI-1054 policy pass-throughs. Captured, never interpreted: `--review-policy`
|
|
214
|
+
// is NOT validated against the vocabulary here, because setup-epic already
|
|
215
|
+
// refuses an unknown value before any I/O and a second copy of that list is
|
|
216
|
+
// exactly the drift the single-entry-point rule exists to prevent.
|
|
217
|
+
let policyFile;
|
|
218
|
+
let reviewPolicy;
|
|
219
|
+
let replacePolicy = false;
|
|
220
|
+
// BAPI-1009: validated with the SHARED validator setup-epic uses, so a name
|
|
221
|
+
// drive-epic accepts is exactly a name setup-epic accepts. A second rule here
|
|
222
|
+
// would let a name pass the router and fail the delegate.
|
|
223
|
+
const takeFeatureBranch = (raw) => {
|
|
224
|
+
featureBranchSupplied = true;
|
|
225
|
+
const trimmed = raw.trim();
|
|
226
|
+
if (trimmed === "")
|
|
227
|
+
return null;
|
|
228
|
+
if (validateBranchName(trimmed) !== null)
|
|
229
|
+
return null;
|
|
230
|
+
featureBranch = trimmed;
|
|
231
|
+
return trimmed;
|
|
232
|
+
};
|
|
139
233
|
for (let i = 0; i < argv.length; i++) {
|
|
140
234
|
const arg = argv[i];
|
|
141
|
-
if (arg === "--
|
|
235
|
+
if (arg === "--into-base") {
|
|
236
|
+
intoBase = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (arg === "--replace-policy") {
|
|
240
|
+
replacePolicy = true;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (arg === "--plan-file" ||
|
|
244
|
+
arg === "--repo" ||
|
|
245
|
+
arg === "--feature-branch" ||
|
|
246
|
+
arg === "--policy-file" ||
|
|
247
|
+
arg === "--review-policy") {
|
|
142
248
|
const value = argv[i + 1];
|
|
143
249
|
if (value === undefined || value.startsWith("-")) {
|
|
144
250
|
return { status: "error", message: `${arg} requires a value.` };
|
|
145
251
|
}
|
|
146
252
|
if (arg === "--plan-file")
|
|
147
253
|
planFile = value;
|
|
148
|
-
else
|
|
254
|
+
else if (arg === "--repo")
|
|
149
255
|
repo = value;
|
|
256
|
+
else if (arg === "--policy-file")
|
|
257
|
+
policyFile = value;
|
|
258
|
+
else if (arg === "--review-policy")
|
|
259
|
+
reviewPolicy = value;
|
|
260
|
+
else if (takeFeatureBranch(value) === null) {
|
|
261
|
+
return {
|
|
262
|
+
status: "error",
|
|
263
|
+
message: `--feature-branch requires a valid, nonblank branch name, got '${value}'.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
150
266
|
i++;
|
|
151
267
|
continue;
|
|
152
268
|
}
|
|
153
|
-
const eq = arg.match(/^(--plan-file|--repo)=(.*)$/);
|
|
269
|
+
const eq = arg.match(/^(--plan-file|--repo|--feature-branch|--policy-file|--review-policy)=(.*)$/);
|
|
154
270
|
if (eq) {
|
|
155
271
|
const value = eq[2];
|
|
272
|
+
if (eq[1] === "--feature-branch") {
|
|
273
|
+
if (takeFeatureBranch(value) === null) {
|
|
274
|
+
return {
|
|
275
|
+
status: "error",
|
|
276
|
+
message: `--feature-branch requires a valid, nonblank branch name, got '${value}'.`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
156
281
|
if (value.trim().length === 0) {
|
|
157
282
|
return { status: "error", message: `${eq[1]} requires a value.` };
|
|
158
283
|
}
|
|
159
284
|
if (eq[1] === "--plan-file")
|
|
160
285
|
planFile = value;
|
|
286
|
+
else if (eq[1] === "--policy-file")
|
|
287
|
+
policyFile = value;
|
|
288
|
+
else if (eq[1] === "--review-policy")
|
|
289
|
+
reviewPolicy = value;
|
|
161
290
|
else
|
|
162
291
|
repo = value;
|
|
163
292
|
continue;
|
|
@@ -167,6 +296,18 @@ export function parseDriveEpicArgs(argv) {
|
|
|
167
296
|
}
|
|
168
297
|
positionals.push(arg);
|
|
169
298
|
}
|
|
299
|
+
// BAPI-1009: refused HERE — before access resolution, before the readiness
|
|
300
|
+
// request, and before any delegation — with the same corrective wording
|
|
301
|
+
// setup-epic uses, so the two entry points explain one mistake one way.
|
|
302
|
+
if (featureBranchSupplied && intoBase) {
|
|
303
|
+
return {
|
|
304
|
+
status: "error",
|
|
305
|
+
message: "--feature-branch and --into-base select contradictory branch strategies: " +
|
|
306
|
+
"--feature-branch runs the epic on a named epic branch, --into-base runs " +
|
|
307
|
+
"children directly into the repository base branch. Only one branch strategy " +
|
|
308
|
+
"may be selected — remove whichever one you did not mean.",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
170
311
|
if (positionals.length === 0) {
|
|
171
312
|
return { status: "error", message: "Missing required epic key." };
|
|
172
313
|
}
|
|
@@ -183,7 +324,16 @@ export function parseDriveEpicArgs(argv) {
|
|
|
183
324
|
if (!EPIC_KEY_PATTERN.test(epicKey)) {
|
|
184
325
|
return { status: "error", message: `Malformed epic key '${epicKey}'. Expected e.g. BAPI-885.` };
|
|
185
326
|
}
|
|
186
|
-
const options = {
|
|
327
|
+
const options = {
|
|
328
|
+
epicKey,
|
|
329
|
+
...(planFile ? { planFile } : {}),
|
|
330
|
+
...(repo ? { repo } : {}),
|
|
331
|
+
...(featureBranch ? { featureBranch } : {}),
|
|
332
|
+
...(intoBase ? { intoBase: true } : {}),
|
|
333
|
+
...(policyFile ? { policyFile } : {}),
|
|
334
|
+
...(reviewPolicy ? { reviewPolicy } : {}),
|
|
335
|
+
...(replacePolicy ? { replacePolicy: true } : {}),
|
|
336
|
+
};
|
|
187
337
|
return { status: "ok", options };
|
|
188
338
|
}
|
|
189
339
|
/**
|
|
@@ -209,6 +359,32 @@ export function selectConductor(readiness) {
|
|
|
209
359
|
reason: `the engine path still needs ${missing.map((m) => m.describe).join("; ")}`,
|
|
210
360
|
};
|
|
211
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Build the exact `setup-epic` argument array (BAPI-1054).
|
|
364
|
+
*
|
|
365
|
+
* Extracted so every delegation path — green, and composed-after-bring-up —
|
|
366
|
+
* forwards byte-identical argv. Two inline copies of this array is how one path
|
|
367
|
+
* silently stops forwarding a flag the other does.
|
|
368
|
+
*
|
|
369
|
+
* Every optional flag is APPENDED only when supplied, so the no-flag array stays
|
|
370
|
+
* byte-identical to the pre-BAPI-1009 one and setup-epic applies its own
|
|
371
|
+
* node-count-aware default. An exact argument array: no shell, no interpolation,
|
|
372
|
+
* no quoting to get wrong.
|
|
373
|
+
*/
|
|
374
|
+
export function buildSetupEpicArgv(options) {
|
|
375
|
+
const { epicKey, planFile, repo, featureBranch, intoBase, policyFile, reviewPolicy, replacePolicy } = options;
|
|
376
|
+
return [
|
|
377
|
+
"--epic-key",
|
|
378
|
+
epicKey,
|
|
379
|
+
...(planFile ? ["--plan-file", planFile] : []),
|
|
380
|
+
...(repo ? ["--repo", repo] : []),
|
|
381
|
+
...(featureBranch ? ["--feature-branch", featureBranch] : []),
|
|
382
|
+
...(intoBase ? ["--into-base"] : []),
|
|
383
|
+
...(policyFile ? ["--policy-file", policyFile] : []),
|
|
384
|
+
...(reviewPolicy ? ["--review-policy", reviewPolicy] : []),
|
|
385
|
+
...(replacePolicy ? ["--replace-policy"] : []),
|
|
386
|
+
];
|
|
387
|
+
}
|
|
212
388
|
/**
|
|
213
389
|
* Render the selected path and only the selected path.
|
|
214
390
|
*
|
|
@@ -281,6 +457,138 @@ export function assertSingleConductorInvocation(text) {
|
|
|
281
457
|
"Exactly one path may ever be presented.");
|
|
282
458
|
}
|
|
283
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Conservative bound on any single advisory field. Exists so malformed or
|
|
462
|
+
* adversarial artifact content cannot turn into multiline or oversized
|
|
463
|
+
* terminal output — the advisory is meant to be a few compact scan lines.
|
|
464
|
+
*/
|
|
465
|
+
const MAX_CONTAINMENT_ADVISORY_FIELD_LENGTH = 300;
|
|
466
|
+
function isCompactSingleLineString(value) {
|
|
467
|
+
return (typeof value === "string" &&
|
|
468
|
+
value.trim().length > 0 &&
|
|
469
|
+
value.length <= MAX_CONTAINMENT_ADVISORY_FIELD_LENGTH &&
|
|
470
|
+
!value.includes("\n") &&
|
|
471
|
+
!value.includes("\r"));
|
|
472
|
+
}
|
|
473
|
+
function isCompactHazardEvidenceArray(value) {
|
|
474
|
+
return Array.isArray(value) && value.length > 0 && value.every(isCompactSingleLineString);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Parse unknown JSON into a compact internal containment advisory.
|
|
478
|
+
*
|
|
479
|
+
* Requires `status: "assessed"`, a positive integer `containmentChildren`,
|
|
480
|
+
* and every `affectedChildren` entry with `requiresContainmentReview: true`
|
|
481
|
+
* to carry valid compact `id`/`title`/`matchedHazards` fields. The number of
|
|
482
|
+
* such flagged records must agree with `containmentChildren` exactly —
|
|
483
|
+
* disagreement, an invalid evidence array, or an incomplete flagged-child
|
|
484
|
+
* record is treated as malformed and yields no advisory (`null`), never a
|
|
485
|
+
* partial or best-effort one. Never reimplements the Python hazard allowlist
|
|
486
|
+
* — this function only validates shape, not hazard content.
|
|
487
|
+
*/
|
|
488
|
+
export function parseContainmentEligibilityArtifact(raw) {
|
|
489
|
+
if (raw === null || typeof raw !== "object")
|
|
490
|
+
return null;
|
|
491
|
+
const artifact = raw;
|
|
492
|
+
if (artifact.status !== "assessed")
|
|
493
|
+
return null;
|
|
494
|
+
const containmentChildren = artifact.containmentChildren;
|
|
495
|
+
if (!Number.isInteger(containmentChildren) || containmentChildren <= 0) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
if (!Array.isArray(artifact.affectedChildren))
|
|
499
|
+
return null;
|
|
500
|
+
const flagged = [];
|
|
501
|
+
for (const entry of artifact.affectedChildren) {
|
|
502
|
+
if (entry === null || typeof entry !== "object")
|
|
503
|
+
return null;
|
|
504
|
+
const child = entry;
|
|
505
|
+
if (child.requiresContainmentReview !== true)
|
|
506
|
+
continue;
|
|
507
|
+
if (!isCompactSingleLineString(child.id) || !isCompactSingleLineString(child.title))
|
|
508
|
+
return null;
|
|
509
|
+
if (!isCompactHazardEvidenceArray(child.matchedHazards))
|
|
510
|
+
return null;
|
|
511
|
+
flagged.push({ id: child.id, title: child.title, matchedHazards: [...child.matchedHazards] });
|
|
512
|
+
}
|
|
513
|
+
if (flagged.length !== containmentChildren)
|
|
514
|
+
return null;
|
|
515
|
+
if (flagged.length === 0)
|
|
516
|
+
return null;
|
|
517
|
+
return { containmentChildren: containmentChildren, flagged };
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Locate and read the containment-eligibility sidecar for a supplied plan
|
|
521
|
+
* file, then parse it. `conductor-eligibility.json` is always a sibling of
|
|
522
|
+
* the plan file — `dirname(planFile)/conductor-eligibility.json` — the same
|
|
523
|
+
* directory `assess-conductor-eligibility` writes it to. Fails open on every
|
|
524
|
+
* boundary: a resolution failure, a rejected read (missing file, permission
|
|
525
|
+
* error), invalid JSON, or a malformed/inconsistent artifact all resolve to
|
|
526
|
+
* `null`, never a thrown error.
|
|
527
|
+
*/
|
|
528
|
+
export async function readContainmentEligibilityAdvisory(planFile, readEligibilityArtifact) {
|
|
529
|
+
try {
|
|
530
|
+
const artifactPath = join(dirname(planFile), "conductor-eligibility.json");
|
|
531
|
+
const raw = await readEligibilityArtifact(artifactPath);
|
|
532
|
+
const parsed = JSON.parse(raw);
|
|
533
|
+
return parseContainmentEligibilityArtifact(parsed);
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Render a validated advisory as compact, factual, advisory-only text.
|
|
541
|
+
*
|
|
542
|
+
* Contains no remediation instructions and no conductor-invocation wording —
|
|
543
|
+
* guarded against both the module's own static heading text and any
|
|
544
|
+
* artifact-supplied title/evidence value that would introduce a
|
|
545
|
+
* {@link CONDUCTOR_INVOCATION_TOKENS} value. When safe rendering cannot be
|
|
546
|
+
* proved, this suppresses the advisory (`null`) rather than throwing or
|
|
547
|
+
* emitting unsafe text; the caller must never let this fail the CLI.
|
|
548
|
+
*/
|
|
549
|
+
export function renderContainmentEligibilityAdvisory(advisory) {
|
|
550
|
+
const lines = [
|
|
551
|
+
"Review containment hazard before dispatch:",
|
|
552
|
+
...advisory.flagged.map((child) => ` - ${child.id}: ${child.title} (${child.matchedHazards.join(", ")})`),
|
|
553
|
+
];
|
|
554
|
+
const text = lines.join("\n");
|
|
555
|
+
const named = CONDUCTOR_INVOCATION_TOKENS.filter((token) => text.includes(token));
|
|
556
|
+
if (named.length > 0)
|
|
557
|
+
return null;
|
|
558
|
+
return text;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The blocking checks the COMPOSITION owns, in refusal order (BAPI-1054).
|
|
562
|
+
*
|
|
563
|
+
* `plane up`'s own preflight deliberately aggregates every blocking diagnostic —
|
|
564
|
+
* build freshness, credentials, Alembic head, an existing plane — and reports
|
|
565
|
+
* them together, because five separate one-minute retries is the failure mode
|
|
566
|
+
* that convention exists to prevent. That aggregate stays exactly as it is.
|
|
567
|
+
*
|
|
568
|
+
* These two are different: they are the ticket's named composition
|
|
569
|
+
* PRECONDITIONS, checked before anything is started, and each carries a single
|
|
570
|
+
* concrete remediation the operator performs by hand. "Exactly one named reason"
|
|
571
|
+
* is therefore a property of this narrow set, never of `plane up`'s refusal.
|
|
572
|
+
*/
|
|
573
|
+
export const DRIVE_EPIC_PRECONDITION_CHECKS = [
|
|
574
|
+
"worktree-presence",
|
|
575
|
+
"server-port",
|
|
576
|
+
];
|
|
577
|
+
/**
|
|
578
|
+
* The first composition-owned blocking diagnostic, or `null`.
|
|
579
|
+
*
|
|
580
|
+
* Order comes from {@link DRIVE_EPIC_PRECONDITION_CHECKS}, not from the order
|
|
581
|
+
* preflight happened to append them, so the reason an operator sees for a given
|
|
582
|
+
* pair of failures is stable.
|
|
583
|
+
*/
|
|
584
|
+
export function firstPreconditionRefusal(preflight) {
|
|
585
|
+
for (const check of DRIVE_EPIC_PRECONDITION_CHECKS) {
|
|
586
|
+
const found = preflight.diagnostics.find((d) => d.check === check && d.severity === "blocking");
|
|
587
|
+
if (found)
|
|
588
|
+
return found;
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
284
592
|
export function createDefaultDriveEpicDeps() {
|
|
285
593
|
return {
|
|
286
594
|
resolveAccess: async (repo) => {
|
|
@@ -289,6 +597,10 @@ export function createDefaultDriveEpicDeps() {
|
|
|
289
597
|
},
|
|
290
598
|
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
291
599
|
runSetupEpic: (argv) => runSetupEpicCli(argv),
|
|
600
|
+
readEligibilityArtifact: (path) => readFile(path, "utf8"),
|
|
601
|
+
runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
|
|
602
|
+
runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
|
|
603
|
+
cwd: () => process.cwd(),
|
|
292
604
|
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
293
605
|
// construction, so nothing has claimed stdout for the protocol transport.
|
|
294
606
|
log: (message) => console.log(message),
|
|
@@ -314,7 +626,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
314
626
|
deps.errorLog(getDriveEpicUsage());
|
|
315
627
|
return 1;
|
|
316
628
|
}
|
|
317
|
-
const { epicKey, planFile, repo } = parsed.options;
|
|
629
|
+
const { epicKey, planFile, repo, featureBranch, intoBase } = parsed.options;
|
|
318
630
|
try {
|
|
319
631
|
// --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
|
|
320
632
|
const accessResult = await deps.resolveAccess(repo);
|
|
@@ -331,15 +643,115 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
331
643
|
}
|
|
332
644
|
// --- Route. Exactly one path from here on. -------------------------------
|
|
333
645
|
const selection = selectConductor(readiness);
|
|
646
|
+
// BAPI-1022: surface any containment-hazard advisory before dispatch or
|
|
647
|
+
// handoff. Read only when a plan file was supplied (the sidecar has no
|
|
648
|
+
// other resolvable location). Fail-open: any failure here — missing
|
|
649
|
+
// artifact, bad JSON, malformed/inconsistent shape, an unsafe rendered
|
|
650
|
+
// value — silently yields no advisory and never affects routing, the
|
|
651
|
+
// delegated argv, or the exit code.
|
|
652
|
+
if (planFile) {
|
|
653
|
+
try {
|
|
654
|
+
const advisory = await readContainmentEligibilityAdvisory(planFile, deps.readEligibilityArtifact);
|
|
655
|
+
if (advisory) {
|
|
656
|
+
const rendered = renderContainmentEligibilityAdvisory(advisory);
|
|
657
|
+
if (rendered)
|
|
658
|
+
deps.log(rendered);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
// Fail open — a containment advisory is never allowed to block dispatch.
|
|
663
|
+
}
|
|
664
|
+
}
|
|
334
665
|
// Decide delegation BEFORE rendering. The v2 branch's text is specifically
|
|
335
666
|
// about a missing plan file, and rendering it on the path that has one
|
|
336
667
|
// would produce a message contradicting what is about to happen.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
668
|
+
//
|
|
669
|
+
// BAPI-1054: a supplied plan file is what makes this a BRING-UP decision
|
|
670
|
+
// rather than a routing one. Everything below runs before `selectConductor`'s
|
|
671
|
+
// pilot fallback can be rendered, because a report that is non-green only on
|
|
672
|
+
// the runtime facts is not a "use the other conductor" situation — it is a
|
|
673
|
+
// plane that has not been started yet.
|
|
674
|
+
if (planFile) {
|
|
675
|
+
const classification = classifyV2Readiness(readiness);
|
|
676
|
+
const setupArgv = buildSetupEpicArgv(parsed.options);
|
|
677
|
+
// Green: delegate exactly as before. No plane invocation, no preflight,
|
|
678
|
+
// and byte-identical argv for the no-flag case.
|
|
679
|
+
if (classification.kind === "green") {
|
|
680
|
+
return await deps.runSetupEpic(setupArgv);
|
|
681
|
+
}
|
|
682
|
+
// Operator-blocked: ONE named reason, and nothing is started. `plane up`
|
|
683
|
+
// cannot create stored supervisor state or resolve GitHub App credentials,
|
|
684
|
+
// so bringing it up would leave a running plane behind and change nothing.
|
|
685
|
+
if (classification.kind === "operator_blocked") {
|
|
686
|
+
const refusal = [
|
|
687
|
+
`${epicKey}: the engine path still needs ${classification.describe} ` +
|
|
688
|
+
`(${classification.id}).`,
|
|
689
|
+
"",
|
|
690
|
+
"This is not something starting the runtime can fix, so nothing was started.",
|
|
691
|
+
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
692
|
+
].join("\n");
|
|
693
|
+
// Named as a fixed reason and nothing else: no conductor is offered here,
|
|
694
|
+
// because handing an operator a second authority is the hazard this
|
|
695
|
+
// subcommand exists to remove.
|
|
696
|
+
assertSingleConductorInvocation(refusal);
|
|
697
|
+
deps.errorLog(refusal);
|
|
698
|
+
return 1;
|
|
699
|
+
}
|
|
700
|
+
// Runtime-only: this invocation composes bring-up with run creation.
|
|
701
|
+
const repoRoot = deps.cwd();
|
|
702
|
+
const preflight = await deps.runPlanePreflight(repoRoot);
|
|
703
|
+
// Composition preconditions FIRST, and only these two. Preflight is
|
|
704
|
+
// read-only, so a refusal here provably created no manifest, spawned no
|
|
705
|
+
// process, and issued no run-creating request — AC-1's "no partial run"
|
|
706
|
+
// holds structurally rather than by cleanup.
|
|
707
|
+
const precondition = firstPreconditionRefusal(preflight);
|
|
708
|
+
if (precondition) {
|
|
709
|
+
const refusal = [
|
|
710
|
+
`${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
|
|
711
|
+
formatPlaneDiagnostic(precondition),
|
|
712
|
+
"",
|
|
713
|
+
"Nothing was started. Fix the item above and run drive-epic again.",
|
|
714
|
+
].join("\n");
|
|
715
|
+
assertSingleConductorInvocation(refusal);
|
|
716
|
+
deps.errorLog(refusal);
|
|
717
|
+
return 1;
|
|
718
|
+
}
|
|
719
|
+
deps.log(`${epicKey}: the conductor runtime is not live; starting it before creating the run.`);
|
|
720
|
+
// The SAME preflight result is handed to `plane up` through its existing
|
|
721
|
+
// override seam, so the port that was probed is the port that gets launched
|
|
722
|
+
// and the two cannot disagree. Every blocking failure `plane up` finds
|
|
723
|
+
// beyond the two preconditions above is rendered by its own aggregate
|
|
724
|
+
// refusal, unchanged.
|
|
725
|
+
const planeExit = await deps.runPlane(["up"], { preflight: async () => preflight });
|
|
726
|
+
if (planeExit !== 0) {
|
|
727
|
+
// `plane up` already reported why, in its own words, and it rolls its own
|
|
728
|
+
// startup back. Nothing to add, and nothing was created here.
|
|
729
|
+
return 1;
|
|
730
|
+
}
|
|
731
|
+
// Delegate immediately. The readiness report is NOT re-read as a gate:
|
|
732
|
+
// `plane up` completes only after each member's durable heartbeat is fresh,
|
|
733
|
+
// whereas the readiness report derives reconciler/executor liveness from
|
|
734
|
+
// `epic_runs.last_tick_at` and `executor_jobs` rows — tables that are empty
|
|
735
|
+
// on a repository that has never run an epic. Gating on a re-read would
|
|
736
|
+
// therefore hang the one-command flow permanently on exactly the
|
|
737
|
+
// first-time-operator case it exists to serve. A successful bring-up IS the
|
|
738
|
+
// runtime fact here; re-sourcing that predicate belongs to A1b.4.
|
|
739
|
+
const setupExit = await deps.runSetupEpic(setupArgv);
|
|
740
|
+
if (setupExit !== 0) {
|
|
741
|
+
// The plane is deliberately LEFT RUNNING. `plane down` stops the
|
|
742
|
+
// manifest-bound run before terminating processes, so winding down now
|
|
743
|
+
// would stop the very run setup-epic may have just created, bound, or
|
|
744
|
+
// approved. The live plane is reported rather than abandoned — the
|
|
745
|
+
// operator owns the decision, which is the documented two-step state.
|
|
746
|
+
deps.errorLog([
|
|
747
|
+
"",
|
|
748
|
+
`${epicKey}: the conductor runtime was started by this invocation and is STILL RUNNING.`,
|
|
749
|
+
"It was not wound down automatically, because doing so would stop a run that may",
|
|
750
|
+
"already have been created and approved. Inspect it with `plane status`, and wind it",
|
|
751
|
+
"down with `plane down` once you have decided what to do with the run.",
|
|
752
|
+
].join("\n"));
|
|
753
|
+
}
|
|
754
|
+
return setupExit;
|
|
343
755
|
}
|
|
344
756
|
const handoff = renderConductorHandoff(selection, epicKey);
|
|
345
757
|
const text = handoff.lines.join("\n");
|