@bridge_gpt/mcp-server 0.2.51 → 0.2.53
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 +59 -13
- 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 +7 -5
- package/build/conduct-epic/cut-protocol.js +17 -3
- package/build/conductor/bridge-api-client.js +232 -5
- package/build/conductor/cli.js +23 -0
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/doctor.js +428 -5
- package/build/conductor/install-doctor.js +65 -656
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor/readiness-cli.js +152 -0
- package/build/conductor/readiness-sections.js +666 -0
- package/build/conductor/readiness.js +710 -0
- package/build/conductor/tools.js +56 -3
- package/build/conductor-bin.js +21 -17
- 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 +40 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +10 -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 +130 -28
- package/build/executor/merge-job.js +67 -16
- 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 +4734 -4270
- package/build/install-bridge.js +95 -0
- package/build/install-doctor.js +154 -2
- package/build/pipelines.generated.js +6 -4
- package/build/plan-epic-conductor-eligibility.js +37 -7
- package/build/plane/alembic-head.js +40 -11
- package/build/plane/build-freshness.js +22 -11
- 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 +402 -44
- 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 +118 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readiness-check.js +412 -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 +560 -139
- package/build/sfcc/log-query.js +2 -1
- 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 +4 -3
- package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
|
@@ -304,10 +304,24 @@ export function formatScopeBootstrapHeartbeat(elapsedMs, state) {
|
|
|
304
304
|
// set. Bounding it to the known labels (plus the fixed `unreadable` and a
|
|
305
305
|
// catch-all) keeps an unvalidated server string out of a line that repeats
|
|
306
306
|
// every interval for up to twenty minutes.
|
|
307
|
-
|
|
307
|
+
return `Seeding scope: elapsed=${seconds}s state=${boundScopeLifecycleLabel(state)}`;
|
|
308
|
+
}
|
|
309
|
+
/** The label an unrecognized lifecycle state collapses to. Never `null`, never raw. */
|
|
310
|
+
export const SCOPE_BOOTSTRAP_UNKNOWN_STATE = "unknown";
|
|
311
|
+
/**
|
|
312
|
+
* Collapse any lifecycle state to a bounded, safe label (BAPI-1054).
|
|
313
|
+
*
|
|
314
|
+
* `lifecycle_state` is a required non-empty string on the wire, not a closed set,
|
|
315
|
+
* so an unrecognized value is possible and an error-bearing one
|
|
316
|
+
* (`unreadable (<server error>)`) is routine. Extracted from
|
|
317
|
+
* {@link formatScopeBootstrapHeartbeat}, which has always applied this rule, so
|
|
318
|
+
* every surface that renders a state applies the SAME one — a second spelling of
|
|
319
|
+
* "bounded" is how raw server text eventually reaches a terminal.
|
|
320
|
+
*/
|
|
321
|
+
export function boundScopeLifecycleLabel(state) {
|
|
322
|
+
return state === SCOPE_BOOTSTRAP_UNREADABLE_STATE || state in SCOPE_LIFECYCLE_LABELS
|
|
308
323
|
? state
|
|
309
|
-
:
|
|
310
|
-
return `Seeding scope: elapsed=${seconds}s state=${bounded}`;
|
|
324
|
+
: SCOPE_BOOTSTRAP_UNKNOWN_STATE;
|
|
311
325
|
}
|
|
312
326
|
/**
|
|
313
327
|
* Describe the nominal polling window up front (BAPI-963).
|
|
@@ -769,6 +769,107 @@ export async function fetchEpicRunState(access, epicKey, fetchImpl = globalThis.
|
|
|
769
769
|
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
770
770
|
return parsed;
|
|
771
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Fetch the read-only ExplainRun / ExplainTicket projection (BAPI-1028).
|
|
774
|
+
*
|
|
775
|
+
* One function, two scopes, because they are two selectors over ONE projection:
|
|
776
|
+
* without `ticketKey` it reads the whole run, with it the single ticket. Both
|
|
777
|
+
* responses carry the same `schema_version` and the ticket response's `ticket`
|
|
778
|
+
* is byte-identical to that ticket's entry in the run response.
|
|
779
|
+
*
|
|
780
|
+
* Read-only end to end: the server route composes persisted rows, writes
|
|
781
|
+
* nothing, enqueues nothing, and calls no provider. Non-2xx responses travel
|
|
782
|
+
* through the shared timeout/sanitization helper as a `ConductorBridgeApiError`,
|
|
783
|
+
* so no response body ever reaches a caller.
|
|
784
|
+
*/
|
|
785
|
+
export async function fetchExplainRun(access, epicRunId, ticketKey, fetchImpl = globalThis.fetch) {
|
|
786
|
+
requireNonEmptyString(epicRunId);
|
|
787
|
+
let apiPath = `${epicRunApiPath(epicRunId)}/explain`;
|
|
788
|
+
if (ticketKey !== undefined) {
|
|
789
|
+
requireNonEmptyString(ticketKey);
|
|
790
|
+
// Encoded as ONE path segment: a ticket key carrying a slash would
|
|
791
|
+
// otherwise silently reshape the URL into a different endpoint.
|
|
792
|
+
requireNoSlashPathSegment(ticketKey);
|
|
793
|
+
apiPath = `${epicRunApiPath(epicRunId)}/tickets/${encodeURIComponent(ticketKey)}/explain`;
|
|
794
|
+
}
|
|
795
|
+
const url = buildConductorJiraUrl(access.baseUrl, apiPath, {
|
|
796
|
+
repo_name: access.repoName,
|
|
797
|
+
});
|
|
798
|
+
return fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
799
|
+
}
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
// Executor wind-down: read-only completion-state projection (BAPI-1010)
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
/** Closed runtime vocabulary mirroring the Python `EpicRunStatus` Literal exactly. */
|
|
804
|
+
export const EPIC_RUN_STATUS_VALUES = [
|
|
805
|
+
"planning",
|
|
806
|
+
"pending_approval",
|
|
807
|
+
"active",
|
|
808
|
+
"blocked",
|
|
809
|
+
"abandoned",
|
|
810
|
+
"done",
|
|
811
|
+
];
|
|
812
|
+
function isEpicRunStatus(value) {
|
|
813
|
+
return typeof value === "string" && EPIC_RUN_STATUS_VALUES.includes(value);
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* GET the epic run's completion state (`status` plus the derived feature
|
|
817
|
+
* branch) through the existing {@link fetchEpicRunState} transport, then
|
|
818
|
+
* validate the payload as untrusted boundary input. Never throws — every
|
|
819
|
+
* failure, transport or validation, resolves to the closed failure member of
|
|
820
|
+
* {@link EpicRunCompletionStateResult} rather than propagating a raw error,
|
|
821
|
+
* response body, header, or credential.
|
|
822
|
+
*
|
|
823
|
+
* `epicRunId` may be an epic run id or an epic key — the underlying endpoint
|
|
824
|
+
* accepts either, exactly like {@link fetchEpicRunState}'s existing callers.
|
|
825
|
+
*/
|
|
826
|
+
export async function readEpicRunCompletionState(access, epicRunId, fetchImpl = globalThis.fetch) {
|
|
827
|
+
if (typeof epicRunId !== "string" || epicRunId.trim().length === 0) {
|
|
828
|
+
return { ok: false, reason: "invalid-input" };
|
|
829
|
+
}
|
|
830
|
+
let parsed;
|
|
831
|
+
try {
|
|
832
|
+
parsed = await fetchEpicRunState(access, epicRunId, fetchImpl);
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
if (err instanceof ConductorBridgeApiError) {
|
|
836
|
+
if (err.kind === "http" && err.status === 404)
|
|
837
|
+
return { ok: false, reason: "not-found" };
|
|
838
|
+
if (err.kind === "unauthorized")
|
|
839
|
+
return { ok: false, reason: "unauthorized" };
|
|
840
|
+
if (err.kind === "timeout")
|
|
841
|
+
return { ok: false, reason: "timeout" };
|
|
842
|
+
if (err.kind === "server")
|
|
843
|
+
return { ok: false, reason: "server" };
|
|
844
|
+
if (err.kind === "network")
|
|
845
|
+
return { ok: false, reason: "network" };
|
|
846
|
+
if (err.kind === "invalid-input")
|
|
847
|
+
return { ok: false, reason: "invalid-input" };
|
|
848
|
+
return { ok: false, reason: "malformed" };
|
|
849
|
+
}
|
|
850
|
+
return { ok: false, reason: "network" };
|
|
851
|
+
}
|
|
852
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
853
|
+
return { ok: false, reason: "malformed" };
|
|
854
|
+
}
|
|
855
|
+
const epicRun = parsed["epic_run"];
|
|
856
|
+
if (!epicRun || typeof epicRun !== "object" || Array.isArray(epicRun)) {
|
|
857
|
+
return { ok: false, reason: "malformed" };
|
|
858
|
+
}
|
|
859
|
+
const status = epicRun["status"];
|
|
860
|
+
if (!isEpicRunStatus(status)) {
|
|
861
|
+
return { ok: false, reason: "malformed" };
|
|
862
|
+
}
|
|
863
|
+
const policyJson = epicRun["policy_json"];
|
|
864
|
+
let featureBranch;
|
|
865
|
+
if (policyJson && typeof policyJson === "object" && !Array.isArray(policyJson)) {
|
|
866
|
+
const baseBranch = policyJson["base_branch"];
|
|
867
|
+
if (typeof baseBranch === "string" && baseBranch.trim().length > 0) {
|
|
868
|
+
featureBranch = baseBranch;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return { ok: true, state: featureBranch ? { status, featureBranch } : { status } };
|
|
872
|
+
}
|
|
772
873
|
/**
|
|
773
874
|
* GET `/jira/epic-runs/runs?repo_name=<repo>&status=active` and return the
|
|
774
875
|
* list of active epic runs. Caps at 20 results — enough for typical deployments
|
|
@@ -957,8 +1058,9 @@ function parseAdvanceEpicTicketStatusResult(parsed) {
|
|
|
957
1058
|
* PATCH the per-ticket CAS status endpoint
|
|
958
1059
|
* (`PATCH /runs/{epic_run_id}/tickets/{ticket_key}`). Surfaces structured CAS
|
|
959
1060
|
* conflicts as a distinct non-throwing outcome where the backend returns one;
|
|
960
|
-
* the current backend instead raises a 400
|
|
961
|
-
*
|
|
1061
|
+
* the current backend instead raises a 400 CONFLICT on a stale row_version
|
|
1062
|
+
* (BAPI-1016 — the status stays 400; only the body error_code changed from the
|
|
1063
|
+
* former VALIDATION), which surfaces as a thrown `ConductorBridgeApiError("http", 400)`.
|
|
962
1064
|
* Transport/auth/server failures still throw a sanitized {@link ConductorBridgeApiError}.
|
|
963
1065
|
*/
|
|
964
1066
|
export async function advanceEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
|
|
@@ -1283,6 +1385,8 @@ export async function validateEpicPlan(access, request, fetchImpl = globalThis.f
|
|
|
1283
1385
|
};
|
|
1284
1386
|
if (request.epicKey !== undefined)
|
|
1285
1387
|
body.epic_key = request.epicKey;
|
|
1388
|
+
if (request.trackedPaths !== undefined)
|
|
1389
|
+
body.tracked_paths = request.trackedPaths;
|
|
1286
1390
|
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1287
1391
|
return parseValidateEpicPlanResult(parsed);
|
|
1288
1392
|
}
|
|
@@ -1295,7 +1399,8 @@ function parseValidateEpicPlanResult(parsed) {
|
|
|
1295
1399
|
const planHash = p["plan_hash"];
|
|
1296
1400
|
const serializationEnabled = p["serialization_enabled"];
|
|
1297
1401
|
const insertedEdges = p["inserted_edges"];
|
|
1298
|
-
|
|
1402
|
+
const valid = p["valid"];
|
|
1403
|
+
if (typeof valid !== "boolean" ||
|
|
1299
1404
|
typeof planHash !== "string" ||
|
|
1300
1405
|
planHash.trim() === "" ||
|
|
1301
1406
|
typeof serializationEnabled !== "boolean" ||
|
|
@@ -1304,12 +1409,35 @@ function parseValidateEpicPlanResult(parsed) {
|
|
|
1304
1409
|
insertedEdges < 0) {
|
|
1305
1410
|
throw new ConductorBridgeApiError("server");
|
|
1306
1411
|
}
|
|
1412
|
+
// BAPI-1027: `valid: false` is now a FIRST-CLASS, fully-verified answer rather
|
|
1413
|
+
// than a protocol error. The historical strictness (throw on anything but
|
|
1414
|
+
// `valid === true`) existed because a body claiming a verdict without carrying
|
|
1415
|
+
// one is false assurance — but a rejection body carries its reasons, so the
|
|
1416
|
+
// caller can verify exactly what it is being told. That guard is preserved
|
|
1417
|
+
// where it still applies: a `valid: false` with no parseable rejection asserts
|
|
1418
|
+
// a failure it does not substantiate, and is still a protocol error.
|
|
1419
|
+
const rejections = parsePlanLintRejections(p["rejections"]);
|
|
1420
|
+
const notices = parsePlanLintNotices(p["notices"]);
|
|
1421
|
+
if (!valid && rejections.length === 0) {
|
|
1422
|
+
throw new ConductorBridgeApiError("server");
|
|
1423
|
+
}
|
|
1307
1424
|
// BAPI-848 — the coverage diagnostics are read TOLERANTLY, unlike the fields
|
|
1308
1425
|
// above. A server that predates them is not a protocol error, and the CLI must
|
|
1309
1426
|
// keep working against one; the defaults below are the honest reading of an
|
|
1310
1427
|
// absent field (nothing reported), and the renderer states the scope it was
|
|
1311
1428
|
// actually given rather than inventing coverage it cannot see.
|
|
1312
1429
|
return {
|
|
1430
|
+
valid,
|
|
1431
|
+
rejections,
|
|
1432
|
+
notices,
|
|
1433
|
+
// BAPI-1027: read TOLERANTLY like the coverage fields, but defaulted to the
|
|
1434
|
+
// SAFE reading. A server that predates this field reports nothing about
|
|
1435
|
+
// completeness, and "we cannot tell whether every rule ran" must not render
|
|
1436
|
+
// as a warning on every single validate against an older deployment — so an
|
|
1437
|
+
// absent field means complete, while a present `false` is honored exactly.
|
|
1438
|
+
evaluationComplete: typeof p["evaluation_complete"] === "boolean"
|
|
1439
|
+
? p["evaluation_complete"]
|
|
1440
|
+
: true,
|
|
1313
1441
|
planHash,
|
|
1314
1442
|
serializationEnabled,
|
|
1315
1443
|
insertedEdges,
|
|
@@ -1321,6 +1449,69 @@ function parseValidateEpicPlanResult(parsed) {
|
|
|
1321
1449
|
: "unreported",
|
|
1322
1450
|
};
|
|
1323
1451
|
}
|
|
1452
|
+
/** Strict-but-tolerant parse of the `rejections` array; malformed entries are dropped. */
|
|
1453
|
+
function parsePlanLintRejections(value) {
|
|
1454
|
+
if (!Array.isArray(value))
|
|
1455
|
+
return [];
|
|
1456
|
+
const out = [];
|
|
1457
|
+
for (const raw of value) {
|
|
1458
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
1459
|
+
continue;
|
|
1460
|
+
const entry = raw;
|
|
1461
|
+
const reasonCode = entry["reason_code"];
|
|
1462
|
+
const explanation = entry["explanation"];
|
|
1463
|
+
// A finding without a code or an explanation cannot be rendered usefully and
|
|
1464
|
+
// must not be counted as one: a phantom rejection would block a plan while
|
|
1465
|
+
// telling the operator nothing about why.
|
|
1466
|
+
if (typeof reasonCode !== "string" || reasonCode.trim() === "")
|
|
1467
|
+
continue;
|
|
1468
|
+
if (typeof explanation !== "string" || explanation.trim() === "")
|
|
1469
|
+
continue;
|
|
1470
|
+
const rejection = {
|
|
1471
|
+
reasonCode,
|
|
1472
|
+
explanation,
|
|
1473
|
+
ticketKeys: safeStringArray(entry["ticket_keys"]),
|
|
1474
|
+
};
|
|
1475
|
+
if (typeof entry["path"] === "string" && entry["path"] !== "") {
|
|
1476
|
+
rejection.path = entry["path"];
|
|
1477
|
+
}
|
|
1478
|
+
if (typeof entry["migration_id"] === "string" && entry["migration_id"] !== "") {
|
|
1479
|
+
rejection.migrationId = entry["migration_id"];
|
|
1480
|
+
}
|
|
1481
|
+
out.push(rejection);
|
|
1482
|
+
}
|
|
1483
|
+
return out;
|
|
1484
|
+
}
|
|
1485
|
+
/** Strict-but-tolerant parse of the `notices` array; malformed entries are dropped. */
|
|
1486
|
+
function parsePlanLintNotices(value) {
|
|
1487
|
+
if (!Array.isArray(value))
|
|
1488
|
+
return [];
|
|
1489
|
+
const out = [];
|
|
1490
|
+
for (const raw of value) {
|
|
1491
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
1492
|
+
continue;
|
|
1493
|
+
const entry = raw;
|
|
1494
|
+
const noticeCode = entry["notice_code"];
|
|
1495
|
+
const explanation = entry["explanation"];
|
|
1496
|
+
if (typeof noticeCode !== "string" || noticeCode.trim() === "")
|
|
1497
|
+
continue;
|
|
1498
|
+
if (typeof explanation !== "string" || explanation.trim() === "")
|
|
1499
|
+
continue;
|
|
1500
|
+
out.push({
|
|
1501
|
+
noticeCode,
|
|
1502
|
+
explanation,
|
|
1503
|
+
ticketKeys: safeStringArray(entry["ticket_keys"]),
|
|
1504
|
+
paths: safeStringArray(entry["paths"]),
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
return out;
|
|
1508
|
+
}
|
|
1509
|
+
/** Non-empty strings from an array, or `[]` for anything else. */
|
|
1510
|
+
function safeStringArray(value) {
|
|
1511
|
+
if (!Array.isArray(value))
|
|
1512
|
+
return [];
|
|
1513
|
+
return value.filter((v) => typeof v === "string" && v !== "");
|
|
1514
|
+
}
|
|
1324
1515
|
/** Non-negative safe integer, or 0 for anything else (absent field included). */
|
|
1325
1516
|
function safeCount(value) {
|
|
1326
1517
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
@@ -1341,12 +1532,15 @@ export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetc
|
|
|
1341
1532
|
throw new ConductorValidationError(`planVersion mismatch: request.planVersion=${request.planVersion} but planBlob.plan_version=${blobVersion}`);
|
|
1342
1533
|
}
|
|
1343
1534
|
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/plan`);
|
|
1344
|
-
const
|
|
1535
|
+
const storeBody = {
|
|
1345
1536
|
repo_name: access.repoName,
|
|
1346
1537
|
plan_version: request.planVersion,
|
|
1347
1538
|
plan_blob: request.planBlob,
|
|
1348
1539
|
plan_hash: request.planHash,
|
|
1349
|
-
}
|
|
1540
|
+
};
|
|
1541
|
+
if (request.trackedPaths !== undefined)
|
|
1542
|
+
storeBody.tracked_paths = request.trackedPaths;
|
|
1543
|
+
const body = JSON.stringify(storeBody);
|
|
1350
1544
|
return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1351
1545
|
}
|
|
1352
1546
|
/**
|
|
@@ -1720,6 +1914,39 @@ export function parseConductorReadinessResponse(body) {
|
|
|
1720
1914
|
reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
|
|
1721
1915
|
executor_stale_after_seconds: requireInt(thr, "executor_stale_after_seconds"),
|
|
1722
1916
|
},
|
|
1917
|
+
review_workflow: parseReviewWorkflow(root),
|
|
1918
|
+
conductor_ci_workflow: parseConductorCiWorkflow(root),
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Parse the optional `review_workflow` block.
|
|
1923
|
+
*
|
|
1924
|
+
* Absent (or null) yields `null` — an older server, not a finding. A PRESENT
|
|
1925
|
+
* block is validated strictly and a malformed one throws the same shape error
|
|
1926
|
+
* as every other field, because a malformed body is untrusted input.
|
|
1927
|
+
*/
|
|
1928
|
+
function parseReviewWorkflow(body) {
|
|
1929
|
+
const raw = body.review_workflow;
|
|
1930
|
+
if (raw === undefined || raw === null)
|
|
1931
|
+
return null;
|
|
1932
|
+
const o = requireObject(raw);
|
|
1933
|
+
return {
|
|
1934
|
+
probe_succeeded: requireBool(o, "probe_succeeded"),
|
|
1935
|
+
workflow_present: requireBool(o, "workflow_present"),
|
|
1936
|
+
emits_run_id: requireBool(o, "emits_run_id"),
|
|
1937
|
+
emits_reviewed_sha: requireBool(o, "emits_reviewed_sha"),
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
/** Parse the optional `conductor_ci_workflow` block. Same rule as above. */
|
|
1941
|
+
function parseConductorCiWorkflow(body) {
|
|
1942
|
+
const raw = body.conductor_ci_workflow;
|
|
1943
|
+
if (raw === undefined || raw === null)
|
|
1944
|
+
return null;
|
|
1945
|
+
const o = requireObject(raw);
|
|
1946
|
+
return {
|
|
1947
|
+
probe_succeeded: requireBool(o, "probe_succeeded"),
|
|
1948
|
+
workflow_present: requireBool(o, "workflow_present"),
|
|
1949
|
+
migration_guard_present: requireBool(o, "migration_guard_present"),
|
|
1723
1950
|
};
|
|
1724
1951
|
}
|
|
1725
1952
|
/**
|
package/build/conductor/cli.js
CHANGED
|
@@ -58,6 +58,10 @@ export function getConductorUsage() {
|
|
|
58
58
|
" send-message Enqueue ONE typed supervisor->worker relay message (idempotent)",
|
|
59
59
|
" check-messages Read + ACK pending relay messages for a worker (no redelivery)",
|
|
60
60
|
" doctor Read-only health/diagnostics report (ledger + git hooks)",
|
|
61
|
+
" readiness Read-only ADVISORY report of every conductor prerequisite",
|
|
62
|
+
" (install + conductor doctor + plane preflight + server),",
|
|
63
|
+
" each with pass/warn/fail/skip and one named remediation.",
|
|
64
|
+
" Remediations are NOT run automatically; always exits 0.",
|
|
61
65
|
" purge Delete ALL ledger rows (events, messages, supervisor_projection)",
|
|
62
66
|
" install-git-hooks Install local, opportunistic, non-blocking git hooks",
|
|
63
67
|
" git-hook post-commit Run the post-commit producer (invoked by the installed hook)",
|
|
@@ -126,6 +130,15 @@ export function getConductorUsage() {
|
|
|
126
130
|
" --json Print compact JSON result",
|
|
127
131
|
" Note: returned messages are ACKNOWLEDGED by this call and are not redelivered.",
|
|
128
132
|
"",
|
|
133
|
+
"readiness options:",
|
|
134
|
+
" --json Print the versioned structured report as JSON",
|
|
135
|
+
" --no-deny-probe Accepted for parity with `doctor`; inert here — `readiness`",
|
|
136
|
+
" never spawns the deny-enforcement probe, and reports that",
|
|
137
|
+
" check as an explicit skip naming `conductor doctor`",
|
|
138
|
+
" --help Print the readiness usage message",
|
|
139
|
+
" Note: advisory only. It blocks nothing and changes no exit code; `drive-epic`",
|
|
140
|
+
" remains the sole route-selection gate and server-side admission the sole refusal.",
|
|
141
|
+
"",
|
|
129
142
|
"doctor / purge options:",
|
|
130
143
|
" --json Print machine-readable JSON",
|
|
131
144
|
" --no-deny-probe (doctor only) Skip the deny-enforcement preflight — no headless",
|
|
@@ -216,6 +229,9 @@ const VALID_COMMANDS = new Set([
|
|
|
216
229
|
"send-message",
|
|
217
230
|
"check-messages",
|
|
218
231
|
"doctor",
|
|
232
|
+
// BAPI-1055: the consolidated ADVISORY readiness gate. Read-only, always
|
|
233
|
+
// exits 0, and registers no MCP tool — see `readiness-cli.ts`.
|
|
234
|
+
"readiness",
|
|
219
235
|
"purge",
|
|
220
236
|
"install-git-hooks",
|
|
221
237
|
"git-hook",
|
|
@@ -1183,6 +1199,13 @@ export async function runConductorCli(argv) {
|
|
|
1183
1199
|
return await runCheckMessagesCommand(parsed.argv);
|
|
1184
1200
|
case "doctor":
|
|
1185
1201
|
return await runDoctorCommand(parsed.argv);
|
|
1202
|
+
case "readiness": {
|
|
1203
|
+
// Lazily imported, like the recovery verbs: the readiness gate pulls in
|
|
1204
|
+
// the Bridge HTTP client and the plane preflight graph, and a local-only
|
|
1205
|
+
// command (doctor, emit-event) must not pay for that at load time.
|
|
1206
|
+
const { runConductorReadinessCommand } = await import("./readiness-cli.js");
|
|
1207
|
+
return await runConductorReadinessCommand(parsed.argv);
|
|
1208
|
+
}
|
|
1186
1209
|
case "purge":
|
|
1187
1210
|
return await runPurgeCommand(parsed.argv);
|
|
1188
1211
|
case "install-git-hooks":
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Executor deny-enforcement preflight (TDD §7 / §11, R8).
|
|
3
3
|
*
|
|
4
|
-
* v2's permission model is "
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* v2's permission model since BAPI-1020 is "the `auto` posture + a deterministic
|
|
5
|
+
* deny layer + an always-on argument guard": workers run
|
|
6
|
+
* `claude -p … --permission-mode auto` with a small stable deny set, and
|
|
7
|
+
* `--dangerously-skip-permissions` is the explicit REVERT value rather than the
|
|
8
|
+
* default. Whether `permissions.deny` is actually enforced under a given posture is
|
|
9
|
+
* version-specific and must be PROBED, never assumed — and BOTH postures are probed,
|
|
10
|
+
* for different reasons: `auto` because it is what every worker runs under, and
|
|
11
|
+
* bypass because it stays selectable and must stay guarded. A posture that is not
|
|
12
|
+
* enforced is fatal regardless of which one it is. This module exports the single
|
|
8
13
|
* reusable predicate the T3a executor's claim loop calls at startup/preflight before
|
|
9
14
|
* claiming any job: on a failed deny probe with no working fallback it returns
|
|
10
15
|
* `enforced: false`, and the executor must refuse to claim jobs (a fatal finding).
|
|
@@ -19,15 +24,28 @@ import { resolveAgentSpec } from "../agent-registry.js";
|
|
|
19
24
|
import { createProbeContext } from "../agent-capabilities/probe-context.js";
|
|
20
25
|
import { createDefaultAgentCapabilitiesDeps } from "../agent-capabilities/default-deps.js";
|
|
21
26
|
import { runDenyEnforcementCheck } from "../agent-capabilities/probes.js";
|
|
27
|
+
/**
|
|
28
|
+
* The postures probed when a caller names none (BAPI-1020).
|
|
29
|
+
*
|
|
30
|
+
* `auto` FIRST, deliberately: it is the posture every worker runs under, so when
|
|
31
|
+
* enforcement is broken the run that proves it is the one that matters, and the
|
|
32
|
+
* short-circuit below then skips the second probe entirely rather than spending
|
|
33
|
+
* minutes of real headless Claude runs re-confirming a fatal result.
|
|
34
|
+
*/
|
|
35
|
+
export const REQUIRED_DENY_ENFORCEMENT_POSTURES = [
|
|
36
|
+
"auto",
|
|
37
|
+
"skip_permissions",
|
|
38
|
+
];
|
|
22
39
|
/** Canonical refuse-to-claim directive included in every fatal/degraded-fatal result. */
|
|
23
40
|
const REFUSE_TO_CLAIM_WARNING = "The executor claim loop MUST refuse to claim jobs until settings permissions.deny " +
|
|
24
41
|
"or the PreToolUse fallback enforces the deny layer.";
|
|
25
42
|
/**
|
|
26
|
-
* Run the deny-enforcement preflight and map the shared
|
|
27
|
-
* standard inspection shape. Never throws —
|
|
28
|
-
* `enforced: false` result. Always cleans up
|
|
43
|
+
* Run the deny-enforcement preflight for ONE posture and map the shared
|
|
44
|
+
* deny-check outcome onto the standard inspection shape. Never throws —
|
|
45
|
+
* unexpected exceptions become a fatal `enforced: false` result. Always cleans up
|
|
46
|
+
* the probe context's temp dirs.
|
|
29
47
|
*/
|
|
30
|
-
|
|
48
|
+
async function runDenyEnforcementPreflightForPosture(posture, opts) {
|
|
31
49
|
try {
|
|
32
50
|
const agent = resolveAgentSpec("claude");
|
|
33
51
|
if (!agent) {
|
|
@@ -47,7 +65,7 @@ export async function runDenyEnforcementPreflight(opts = {}) {
|
|
|
47
65
|
const { result, layer } = await runDenyEnforcementCheck(ctx, {
|
|
48
66
|
model: opts.model,
|
|
49
67
|
timeoutMs: opts.timeoutMs,
|
|
50
|
-
permissionPosture:
|
|
68
|
+
permissionPosture: posture,
|
|
51
69
|
});
|
|
52
70
|
if (result.status === "pass" && layer === "settings-deny") {
|
|
53
71
|
return {
|
|
@@ -65,7 +83,8 @@ export async function runDenyEnforcementPreflight(opts = {}) {
|
|
|
65
83
|
layer: "pretooluse-hook",
|
|
66
84
|
degraded: true,
|
|
67
85
|
warnings: [
|
|
68
|
-
|
|
86
|
+
`settings permissions.deny was not enforced under the ${posture ?? "skip_permissions"} ` +
|
|
87
|
+
"posture; relying on PreToolUse fallback.",
|
|
69
88
|
],
|
|
70
89
|
status: result.status,
|
|
71
90
|
detail: result.detail,
|
|
@@ -95,3 +114,81 @@ export async function runDenyEnforcementPreflight(opts = {}) {
|
|
|
95
114
|
};
|
|
96
115
|
}
|
|
97
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Run the deny-enforcement preflight and return the executor's claim gate.
|
|
119
|
+
*
|
|
120
|
+
* With an explicit `permissionPosture`, probes exactly that posture and returns
|
|
121
|
+
* its result unchanged — the single-posture behavior every existing caller and
|
|
122
|
+
* test seam relies on.
|
|
123
|
+
*
|
|
124
|
+
* With no posture named, probes {@link REQUIRED_DENY_ENFORCEMENT_POSTURES} in
|
|
125
|
+
* order and combines (BAPI-1020). Three properties of that combination matter:
|
|
126
|
+
*
|
|
127
|
+
* - FATAL IF EITHER FAILS. `enforced` is the conjunction, so a posture that is
|
|
128
|
+
* not enforced blocks claiming even when the other one is. There is no
|
|
129
|
+
* "mostly enforced".
|
|
130
|
+
* - SHORT-CIRCUITS ON THE FIRST FAILURE. Each posture costs several real
|
|
131
|
+
* headless Claude runs and takes minutes; once the answer is fatal, the
|
|
132
|
+
* remaining postures cannot change it, and burning the time to re-confirm it
|
|
133
|
+
* would delay every claim on a machine that is already refusing to claim.
|
|
134
|
+
* - REPORTS EACH POSTURE SEPARATELY. `postures` carries every probed posture's
|
|
135
|
+
* own layer and detail. The top-level `layer` is the FIRST required posture's
|
|
136
|
+
* (`auto`, the one workers run under) rather than a merged string, so the
|
|
137
|
+
* existing consumers — which read `layer` as "what enforced for a worker" —
|
|
138
|
+
* keep reading a true answer, and the per-posture facts stay available beside
|
|
139
|
+
* it instead of being collapsed into it.
|
|
140
|
+
*/
|
|
141
|
+
export async function runDenyEnforcementPreflight(opts = {}) {
|
|
142
|
+
if (opts.permissionPosture !== undefined) {
|
|
143
|
+
return runDenyEnforcementPreflightForPosture(opts.permissionPosture, opts);
|
|
144
|
+
}
|
|
145
|
+
const postures = [];
|
|
146
|
+
const warnings = [];
|
|
147
|
+
let combined;
|
|
148
|
+
let degraded = false;
|
|
149
|
+
for (const posture of REQUIRED_DENY_ENFORCEMENT_POSTURES) {
|
|
150
|
+
const result = await runDenyEnforcementPreflightForPosture(posture, opts);
|
|
151
|
+
postures.push({
|
|
152
|
+
posture,
|
|
153
|
+
enforced: result.enforced,
|
|
154
|
+
layer: result.layer,
|
|
155
|
+
...(result.status === undefined ? {} : { status: result.status }),
|
|
156
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
157
|
+
});
|
|
158
|
+
for (const warning of result.warnings) {
|
|
159
|
+
if (!warnings.includes(warning))
|
|
160
|
+
warnings.push(warning);
|
|
161
|
+
}
|
|
162
|
+
if (result.degraded)
|
|
163
|
+
degraded = true;
|
|
164
|
+
if (combined === undefined)
|
|
165
|
+
combined = result;
|
|
166
|
+
if (!result.enforced) {
|
|
167
|
+
// Fatal already. Naming the posture is the diagnostic that matters: "deny is
|
|
168
|
+
// unenforced" is not actionable until an operator knows WHICH posture, since
|
|
169
|
+
// the fix for the worker default and the fix for the revert value differ.
|
|
170
|
+
return {
|
|
171
|
+
enforced: false,
|
|
172
|
+
layer: result.layer,
|
|
173
|
+
degraded: true,
|
|
174
|
+
warnings: [
|
|
175
|
+
`Deny-layer enforcement is not verified for the '${posture}' permission posture.`,
|
|
176
|
+
...warnings,
|
|
177
|
+
],
|
|
178
|
+
...(result.status === undefined ? {} : { status: result.status }),
|
|
179
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
180
|
+
postures,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const primary = combined;
|
|
185
|
+
return {
|
|
186
|
+
enforced: true,
|
|
187
|
+
layer: primary.layer,
|
|
188
|
+
degraded,
|
|
189
|
+
warnings,
|
|
190
|
+
...(primary.status === undefined ? {} : { status: primary.status }),
|
|
191
|
+
...(primary.detail === undefined ? {} : { detail: primary.detail }),
|
|
192
|
+
postures,
|
|
193
|
+
};
|
|
194
|
+
}
|