@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.
Files changed (79) hide show
  1. package/README.md +59 -13
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +7 -5
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +232 -5
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/deny-enforcement-preflight.js +107 -10
  10. package/build/conductor/doctor.js +428 -5
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/local-merge.js +170 -11
  13. package/build/conductor/readiness-cli.js +152 -0
  14. package/build/conductor/readiness-sections.js +666 -0
  15. package/build/conductor/readiness.js +710 -0
  16. package/build/conductor/tools.js +56 -3
  17. package/build/conductor-bin.js +21 -17
  18. package/build/connect-bitbucket-api.js +370 -0
  19. package/build/connect-bitbucket.js +437 -0
  20. package/build/docs.generated.js +1 -1
  21. package/build/doctor.js +40 -1
  22. package/build/drive-epic.js +423 -11
  23. package/build/env-file-link.js +164 -0
  24. package/build/epic-integration-pr.js +10 -0
  25. package/build/executor/cli.js +41 -6
  26. package/build/executor/deps.js +5 -1
  27. package/build/executor/env-file-guard.js +113 -0
  28. package/build/executor/env.js +78 -1
  29. package/build/executor/heartbeat.js +9 -0
  30. package/build/executor/http-client.js +90 -22
  31. package/build/executor/job-errors.js +43 -2
  32. package/build/executor/job-runner.js +130 -28
  33. package/build/executor/merge-job.js +67 -16
  34. package/build/executor/permissions.js +106 -0
  35. package/build/executor/preflight.js +38 -13
  36. package/build/executor/resume-pre-spawn.js +2 -1
  37. package/build/executor/runner.js +175 -4
  38. package/build/executor/service-unit.js +15 -0
  39. package/build/executor/terminal-mutation.js +22 -1
  40. package/build/executor/types.js +86 -0
  41. package/build/executor/worker-command.js +21 -5
  42. package/build/executor/worker-guard-hook.js +939 -0
  43. package/build/executor/worker-log.js +56 -0
  44. package/build/executor/worktree.js +11 -0
  45. package/build/git-reachability.js +147 -0
  46. package/build/index.js +4734 -4270
  47. package/build/install-bridge.js +95 -0
  48. package/build/install-doctor.js +154 -2
  49. package/build/pipelines.generated.js +6 -4
  50. package/build/plan-epic-conductor-eligibility.js +37 -7
  51. package/build/plane/alembic-head.js +40 -11
  52. package/build/plane/build-freshness.js +22 -11
  53. package/build/plane/cli.js +78 -15
  54. package/build/plane/defaults.js +165 -0
  55. package/build/plane/manifest.js +63 -8
  56. package/build/plane/member-logs.js +6 -0
  57. package/build/plane/member-roster.js +195 -11
  58. package/build/plane/preflight.js +402 -44
  59. package/build/plane/shutdown.js +25 -3
  60. package/build/plane/status.js +11 -0
  61. package/build/plane/supervisor.js +343 -14
  62. package/build/plane/test-fakes.js +43 -0
  63. package/build/plane/types.js +118 -11
  64. package/build/pr-base-contract.js +20 -0
  65. package/build/readiness-check.js +412 -0
  66. package/build/readme.generated.js +1 -1
  67. package/build/review-synthesis-config.js +60 -0
  68. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  69. package/build/setup-epic.js +560 -139
  70. package/build/sfcc/log-query.js +2 -1
  71. package/build/start-tickets-conductor.js +11 -2
  72. package/build/start-tickets.js +69 -2
  73. package/build/version.generated.js +3 -3
  74. package/build/worker-containment-diagnostic.js +97 -0
  75. package/build/worker-guard-hook-bin.js +6 -0
  76. package/docs/CONDUCTOR.md +27 -0
  77. package/docs/install/mcp-tool-integrations.md +3 -2
  78. package/package.json +4 -3
  79. 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
- const bounded = state === SCOPE_BOOTSTRAP_UNREADABLE_STATE || state in SCOPE_LIFECYCLE_LABELS
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
- : "unknown";
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 VALIDATION on a stale row_version,
961
- * which surfaces as a thrown `ConductorBridgeApiError("http", 400)`.
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
- if (p["valid"] !== true ||
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 body = JSON.stringify({
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
  /**
@@ -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 "skip-permissions + a deterministic deny-layer": workers
5
- * run `claude -p --dangerously-skip-permissions` with a small stable deny set. But
6
- * whether `permissions.deny` is actually enforced under `--dangerously-skip-permissions`
7
- * is version-specific and must be PROBED, never assumed. This module exports the single
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 deny-check outcome onto the
27
- * standard inspection shape. Never throws — unexpected exceptions become a fatal
28
- * `enforced: false` result. Always cleans up the probe context's temp dirs.
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
- export async function runDenyEnforcementPreflight(opts = {}) {
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: opts.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
- "settings permissions.deny was not enforced under --dangerously-skip-permissions; relying on PreToolUse fallback.",
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
+ }