@bridge_gpt/mcp-server 0.2.52 → 0.2.54

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 (52) hide show
  1. package/README.md +121 -15
  2. package/build/agent-launchers/claude.js +3 -3
  3. package/build/agent-launchers/prompt.js +8 -11
  4. package/build/base-ref.js +33 -9
  5. package/build/bounded-wait.js +174 -0
  6. package/build/commands.generated.js +7 -5
  7. package/build/conductor/bridge-api-client.js +97 -8
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/doctor.js +428 -5
  10. package/build/conductor/epic-runtime.js +133 -97
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/readiness-cli.js +152 -0
  13. package/build/conductor/readiness-sections.js +666 -0
  14. package/build/conductor/readiness.js +795 -0
  15. package/build/conductor/run-branch.js +137 -0
  16. package/build/conductor/test-run-branch-vectors.js +165 -0
  17. package/build/conductor/tools.js +56 -3
  18. package/build/conductor-bin.js +21 -17
  19. package/build/doctor.js +68 -1
  20. package/build/drive-epic.js +287 -51
  21. package/build/executor/claim-scope.js +104 -0
  22. package/build/executor/cli.js +14 -25
  23. package/build/executor/env-file-guard.js +82 -3
  24. package/build/executor/job-runner.js +60 -0
  25. package/build/index.js +4496 -4697
  26. package/build/install-doctor.js +154 -2
  27. package/build/local-artifact-storage.js +130 -0
  28. package/build/pipelines.generated.js +17 -10
  29. package/build/plane/alembic-head.js +40 -11
  30. package/build/plane/build-freshness.js +22 -11
  31. package/build/plane/cli.js +285 -36
  32. package/build/plane/manifest.js +209 -1
  33. package/build/plane/member-roster.js +70 -0
  34. package/build/plane/preflight.js +363 -48
  35. package/build/plane/shutdown.js +14 -1
  36. package/build/plane/status.js +35 -1
  37. package/build/plane/supervisor.js +546 -164
  38. package/build/plane/types.js +61 -2
  39. package/build/polling-policy.js +72 -0
  40. package/build/readiness-check.js +412 -0
  41. package/build/readme.generated.js +1 -1
  42. package/build/review-generation.js +219 -0
  43. package/build/run-unit-tests-launcher.js +5 -0
  44. package/build/setup-epic.js +514 -23
  45. package/build/ticket-key-utils.js +4 -3
  46. package/build/ticket-review-artifact-gate.js +461 -0
  47. package/build/upgrade-cli.js +5 -26
  48. package/build/version.generated.js +3 -3
  49. package/docs/install/mcp-tool-integrations.md +23 -1
  50. package/package.json +2 -2
  51. package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
  52. package/pipelines/review-ticket.json +17 -4
@@ -78,7 +78,7 @@
78
78
  import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
79
79
  import os from "node:os";
80
80
  import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "./epic-integration-pr.js";
81
- import { approveEpicPlan, createEpicRunWithDisposition, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
81
+ import { approveEpicPlan, createEpicRunWithDisposition, fetchConductorReadiness, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
82
82
  import { resolveTrackedPaths } from "./git-reachability.js";
83
83
  import { validateBranchName } from "./base-ref.js";
84
84
  // Reused from the conduct-epic client on purpose: adding a second wrapper under
@@ -98,6 +98,35 @@ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
98
98
  // state. See `planeBinding` on `SetupEpicDeps` below.
99
99
  import { createPlaneFsDeps, createPlaneProcessDeps } from "./plane/defaults.js";
100
100
  import { bindPlaneManifestEpicRun, manifestHasLiveProcess, readPlaneManifest, } from "./plane/manifest.js";
101
+ /**
102
+ * The one `policy_json.posture` value that means "unattended" (BAPI-1102).
103
+ *
104
+ * Mirrors the server's `api.models.epic_run.RUN_POSTURE_UNATTENDED`, which is a
105
+ * closed one-member vocabulary: the model refuses any other value and treats an
106
+ * omitted key as attended. Written out here rather than derived, because the
107
+ * client cannot import a Python constant; a unit test pins the two together.
108
+ */
109
+ export const SETUP_EPIC_UNATTENDED_POSTURE = "unattended";
110
+ /**
111
+ * The server's stable error code for an unattended-posture refusal (BAPI-734).
112
+ *
113
+ * Mirrors `api.library.epic_conductor.unattended_posture.UNATTENDED_POSTURE_REFUSED_CODE`.
114
+ * Matched by EQUALITY on the structured `error_code`, never by searching the
115
+ * message text: the message is prose that may be rewritten, the code is the
116
+ * contract.
117
+ */
118
+ export const SETUP_EPIC_UNATTENDED_POSTURE_REFUSED_CODE = "UNATTENDED_POSTURE_REFUSED";
119
+ /**
120
+ * The `policy_json` key authorizing automatic merge dispatch (BAPI-722).
121
+ *
122
+ * Composed into the unattended default alongside the posture. The two are not
123
+ * redundant: `posture` selects the unattended predicate set and the escalation
124
+ * discipline, `v2_auto_merge_enabled` is what actually authorizes the reconciler
125
+ * to dispatch a merge. A run with the posture and without the flag is a valid,
126
+ * fully supported configuration — it simply parks at every merge for a human,
127
+ * which is what the observed BAPI-1085 run did.
128
+ */
129
+ export const SETUP_EPIC_AUTO_MERGE_POLICY_KEY = "v2_auto_merge_enabled";
101
130
  /** Accepted `policy_json.review_policy.source` values (the `ReviewPolicy` surface). */
102
131
  export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
103
132
  "verdict_protocol",
@@ -138,6 +167,19 @@ export function getSetupEpicUsage() {
138
167
  "integration PR targets the repository base branch. A single-node plan keeps the",
139
168
  "repository base branch, matching plain start-tickets semantics.",
140
169
  "",
170
+ "Posture (default): a run that selects an epic branch is created UNATTENDED —",
171
+ "posture: unattended and v2_auto_merge_enabled: true — and the server stamps its",
172
+ "CI gate at first approval from the repository default, or records an explicit",
173
+ "no-CI-gate mode when the repository declares none. Child PRs merge into the epic",
174
+ "branch; the INTEGRATION PR into the repository base branch stays human-gated.",
175
+ "Pass --attended to opt out. A run that selects no epic branch (--into-base, or a",
176
+ "single-node plan) is unchanged and composes no posture at all.",
177
+ "",
178
+ "Unattended runs have TWO one-time repository prerequisites, refused by name",
179
+ "before any run is created: repository consent (unattended_conductor_allowed) and",
180
+ "a verified notify webhook default. `drive-epic` readiness lists both with their",
181
+ "current state.",
182
+ "",
141
183
  "Required:",
142
184
  " --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
143
185
  " --plan-file <path> Path to epic-plan.dag.json (from decompose-epic)",
@@ -165,6 +207,13 @@ export function getSetupEpicUsage() {
165
207
  " (the pre-BAPI-1009 behavior). Use this if automation",
166
208
  " depended on \"no flag = direct to base\". Contradictory",
167
209
  " with --feature-branch; passing both is a parse error.",
210
+ " --attended OPT OUT of the unattended default: compose only",
211
+ " base_branch (plus --review-policy when supplied), with",
212
+ " no posture and no auto-merge authorization, and skip the",
213
+ " unattended repository prerequisites entirely. The create",
214
+ " request is byte-for-byte the pre-BAPI-1102 one.",
215
+ " Contradictory with a --policy-file that declares",
216
+ " posture: unattended; that pair is refused by name.",
168
217
  " --review-policy <src> PER-RUN review policy source, one of:",
169
218
  ` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
170
219
  " Composed into policy_json.review_policy on create.",
@@ -269,6 +318,7 @@ export function parseSetupEpicArgs(argv) {
269
318
  // fire on the INTENT, not on whether a name survived normalization.
270
319
  let featureBranchSupplied = false;
271
320
  let intoBase = false;
321
+ let attended = false;
272
322
  let reviewPolicy;
273
323
  let policyFile;
274
324
  let replacePolicy = false;
@@ -379,6 +429,14 @@ export function parseSetupEpicArgs(argv) {
379
429
  case "--into-base":
380
430
  intoBase = true;
381
431
  break;
432
+ // BAPI-1102. Valueless, like `--into-base` and `--replace-policy`. It is
433
+ // deliberately NOT contradictory with `--into-base`: a direct-to-main run
434
+ // composes no policy at all, so `--attended` is redundant there rather than
435
+ // conflicting, and refusing a redundant flag would be a worse experience
436
+ // than accepting it.
437
+ case "--attended":
438
+ attended = true;
439
+ break;
382
440
  case "--replace-policy":
383
441
  replacePolicy = true;
384
442
  break;
@@ -442,6 +500,7 @@ export function parseSetupEpicArgs(argv) {
442
500
  planVersion,
443
501
  featureBranch,
444
502
  intoBase,
503
+ attended,
445
504
  reviewPolicy,
446
505
  policyFile,
447
506
  replacePolicy,
@@ -523,6 +582,15 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
523
582
  // contradiction purposes so a conflicting value is reported rather than
524
583
  // silently ignored, but never invent it and never rewrite it into snake_case:
525
584
  // the server's strict boundary should be the one to reject it.
585
+ //
586
+ // BAPI-1127: this paired read is CONTRADICTION DETECTION over a policy file
587
+ // the operator just handed in, at SETUP time, before any run exists — it is
588
+ // not run-branch resolution and must never become one. It asks "did the file
589
+ // name a branch under either spelling that disagrees with --feature-branch?",
590
+ // so it reads both keys for PRESENCE and deliberately picks no winner between
591
+ // them. `conductor/run-branch.ts#resolveDeclaredRunBaseBranch` is the single
592
+ // authority for resolving a PERSISTED run's declared branch; this is a
593
+ // documented exception to that, not a second implementation of it.
526
594
  const declaredBranches = [];
527
595
  for (const key of ["base_branch", "baseBranch"]) {
528
596
  if (key in composed && composed[key] !== undefined && composed[key] !== null) {
@@ -611,7 +679,30 @@ export function preflightSetupEpicPolicy(args) {
611
679
  `branch declaration from the policy file, or drop --into-base.`,
612
680
  };
613
681
  }
614
- // 2. Policy-source contradictions between the file and the CLI selections.
682
+ // 2. Posture contradiction (BAPI-1102). Placed SECOND after the branch
683
+ // strategy, before the composer's field-level conflicts — so one invocation
684
+ // still yields exactly one named reason, deterministically. It belongs above
685
+ // the composer because it is a whole-INTENT contradiction: an operator who
686
+ // said "attended" and supplied a file that says "unattended" has not made a
687
+ // field mistake, and reporting a downstream field conflict first would send
688
+ // them to the wrong line of the file.
689
+ //
690
+ // READ-ONLY, and the read is deliberately the narrowest possible: only
691
+ // whether the file explicitly declares `posture: "unattended"`. Nothing is
692
+ // normalized, no other key is inspected, and — like every message here — the
693
+ // refusal interpolates the file PATH and fixed prose only, never a value
694
+ // read out of the file.
695
+ if (args.attended === true && policyFileDeclaresUnattendedPosture(args.filePolicy)) {
696
+ return {
697
+ ok: false,
698
+ reason: "attended_with_policy_file_posture",
699
+ message: `--attended opts out of the unattended default, but --policy-file ` +
700
+ `'${args.policyFile}' declares posture: "unattended". setup-epic will not ` +
701
+ `pick a winner between them: drop --attended, or remove the posture ` +
702
+ `declaration from the policy file.`,
703
+ };
704
+ }
705
+ // 3. Policy-source contradictions between the file and the CLI selections.
615
706
  // Delegated to the composer, which owns the "no silent winner" rule; the
616
707
  // gate only gives the outcome a stable name.
617
708
  if (args.filePolicy === undefined)
@@ -625,6 +716,24 @@ export function preflightSetupEpicPolicy(args) {
625
716
  }
626
717
  return { ok: true, policy: composed.policy };
627
718
  }
719
+ /**
720
+ * Does this policy file explicitly declare the UNATTENDED posture (BAPI-1102)?
721
+ *
722
+ * Strict by construction, and narrow on purpose. `true` only for a plain object
723
+ * whose `posture` is exactly the string `"unattended"` — the one spelling the
724
+ * server's `RUN_POSTURE_UNATTENDED` accepts. It never reads another key, never
725
+ * normalizes, and never returns the value it read, so the caller cannot leak a
726
+ * policy value into a refusal message even by accident.
727
+ *
728
+ * A file that declares no posture is not a contradiction: `--attended` is the
729
+ * opt-out from a DEFAULT, and a file that stays silent about posture leaves that
730
+ * default in play for `--attended` to decline.
731
+ */
732
+ export function policyFileDeclaresUnattendedPosture(filePolicy) {
733
+ if (filePolicy === undefined || filePolicy === null)
734
+ return false;
735
+ return filePolicy.posture === SETUP_EPIC_UNATTENDED_POSTURE;
736
+ }
628
737
  /**
629
738
  * Does this policy object explicitly supply the `notify.webhook_url` leaf?
630
739
  *
@@ -1005,6 +1114,233 @@ export function resolveFeatureBranchSelection(opts, planNodeCount, policyDeclare
1005
1114
  return undefined;
1006
1115
  return proposeFeatureBranchName(opts.epicKey);
1007
1116
  }
1117
+ /**
1118
+ * Evaluate the unattended prerequisites against a readiness report.
1119
+ *
1120
+ * Pure and total: it takes an already-read report (or `null` for a failed read)
1121
+ * and the COMPOSED policy the create request will carry, and returns at most ONE
1122
+ * refusal, in a fixed order that matches the server's own cheapest-first
1123
+ * sequence, so readiness, this gate, and the approval refusal all name the same
1124
+ * first blocker.
1125
+ *
1126
+ * The policy is a parameter rather than something re-derived here because one
1127
+ * prerequisite — the escalation channel — is satisfiable per RUN as well as per
1128
+ * repository. A version of this gate that read only the repository report
1129
+ * refused every unattended run, its own named remedy included.
1130
+ *
1131
+ * The reason CODES are the server's bounded vocabulary, spelled exactly as
1132
+ * `api/library/epic_conductor/unattended_posture.py` spells them. The client adds
1133
+ * remediation TEXT around them and invents no aliases — an alias would drift from
1134
+ * the server's refusal the first time either side was edited, and an operator
1135
+ * comparing a CLI message with an API response would have no way to tell they
1136
+ * were the same refusal.
1137
+ *
1138
+ * `readiness_unreadable` is the one code with no server twin, and it is
1139
+ * deliberately generic and secret-free: an unreadable or older-server report has
1140
+ * PROVEN nothing, and fail-closed is the only safe direction for a check that
1141
+ * authorizes unattended merging.
1142
+ */
1143
+ /**
1144
+ * Does this policy declare a notify webhook the server would probe?
1145
+ *
1146
+ * BAPI-1102. The shape check mirrors `_extract_webhook_url` in
1147
+ * `api/library/epic_conductor/webhook_verification.py` exactly as far as
1148
+ * PRESENCE goes — a `notify` object carrying a non-blank string `webhook_url` —
1149
+ * and stops there on purpose. Scheme, host, and reachability are the server's to
1150
+ * judge at activation, where the probe actually happens; re-deciding them here
1151
+ * would mean a client that refused a URL the server accepts, or accepted one it
1152
+ * refuses, the first time either side's rules were edited.
1153
+ *
1154
+ * `notify.local_sink` deliberately does NOT satisfy this, matching the server: a
1155
+ * local sink has no destination to prove reachable, so it is not an escalation
1156
+ * channel for a run nobody is watching.
1157
+ */
1158
+ export function policyDeclaresNotifyWebhook(policy) {
1159
+ if (policy === undefined || policy === null)
1160
+ return false;
1161
+ const notify = policy.notify;
1162
+ if (typeof notify !== "object" || notify === null || Array.isArray(notify)) {
1163
+ return false;
1164
+ }
1165
+ const url = notify.webhook_url;
1166
+ return typeof url === "string" && url.trim() !== "";
1167
+ }
1168
+ export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPolicy) {
1169
+ const unattended = readiness?.unattended ?? null;
1170
+ if (unattended === null) {
1171
+ return {
1172
+ reason: "readiness_unreadable",
1173
+ message: "the Bridge API did not report this repository's unattended prerequisites, so " +
1174
+ "they could not be confirmed. Retry once the server is reachable and running a " +
1175
+ "build that reports them, or pass --attended to create an attended run. No run " +
1176
+ "was created.",
1177
+ };
1178
+ }
1179
+ if (!unattended.conductor_allowed) {
1180
+ return {
1181
+ reason: "target_not_allowed",
1182
+ message: "this repository has not consented to unattended conductor runs. Set " +
1183
+ "`unattended_conductor_allowed` for it (see " +
1184
+ "docs/claude/account-settings-operator-runbook.md), or pass --attended to " +
1185
+ "create an attended run. No run was created.",
1186
+ };
1187
+ }
1188
+ // Two independent sources satisfy the escalation channel, and the check has to
1189
+ // consult BOTH or it refuses its own documented remedy. No column supplies a
1190
+ // repository-level notify default today, so `notify_webhook_default_declared`
1191
+ // is `false` for every repository; reading only that field made this branch
1192
+ // refuse EVERY unattended run — including the `--policy-file` supplying
1193
+ // `notify.webhook_url` that its own message names as the fix — and left
1194
+ // `--attended` as the only reachable path. The run-level declaration is the
1195
+ // supported form, so it is the one that has to be honoured here.
1196
+ //
1197
+ // Presence, not verification: the server probes the URL at activation and is
1198
+ // the authority on whether it is reachable. This gate exists to spare the
1199
+ // operator a created-and-refused run, not to duplicate the probe.
1200
+ if (!unattended.notify_webhook_default_declared &&
1201
+ !policyDeclaresNotifyWebhook(requestedPolicy)) {
1202
+ return {
1203
+ reason: "webhook_unverified",
1204
+ message: "an unattended run must have a verified escalation channel, and neither this " +
1205
+ "repository nor this run declares one. Supply a --policy-file declaring " +
1206
+ "notify.webhook_url (notify.local_sink does not satisfy this), or pass " +
1207
+ "--attended to create an attended run. No run was created.",
1208
+ };
1209
+ }
1210
+ if (!unattended.repository_readiness_confirmed) {
1211
+ return {
1212
+ reason: "repository_readiness_unconfirmed",
1213
+ message: "the unattended default authorizes automatic merge, which requires the review and " +
1214
+ "conductor-CI workflows to be CONFIRMED on the default branch, and they are not. " +
1215
+ "Run `install-bridge conductor` to install them, or pass --attended to create an " +
1216
+ "attended run. No run was created.",
1217
+ };
1218
+ }
1219
+ return null;
1220
+ }
1221
+ /**
1222
+ * Does the policy this invocation will actually send request unattended operation?
1223
+ *
1224
+ * Read off the COMPOSED policy — the same object the create request carries —
1225
+ * rather than off the flags, so a `--policy-file` that declares
1226
+ * `posture: "unattended"` is gated exactly as the default profile is. Reading the
1227
+ * flags instead would let the file route around the prerequisites, which is the
1228
+ * one thing this gate exists to prevent.
1229
+ *
1230
+ * `--attended` short-circuits before the read: it is the explicit opt-out, and
1231
+ * the preflight gate above has already refused the one case where it contradicts
1232
+ * a policy file.
1233
+ */
1234
+ export function requestedPolicyIsUnattended(policy, attended) {
1235
+ if (attended)
1236
+ return false;
1237
+ if (policy === undefined)
1238
+ return false;
1239
+ return policy.posture === SETUP_EPIC_UNATTENDED_POSTURE;
1240
+ }
1241
+ /**
1242
+ * Compose the create-time `policy_json` for a run with NO `--policy-file`.
1243
+ *
1244
+ * BAPI-1102. Extracted from the create branch so the default profile has one
1245
+ * definition and one place to read it, rather than an inline object literal that
1246
+ * a later edit could silently widen.
1247
+ *
1248
+ * Three shapes, and the boundaries between them are the contract:
1249
+ *
1250
+ * * **No feature branch selected** → `{}`. `--into-base`, a single-node plan,
1251
+ * and the branch-silent path each keep the EXACT legacy create request shape:
1252
+ * the caller sends no `policy_json` key at all, byte-for-byte what it sent
1253
+ * before BAPI-1009 (AC-9). Adding a posture, an auto-merge authorization, an
1254
+ * empty policy object, or anything else here would break all three at once —
1255
+ * and, worse, would ask the server to admit an ungated auto-merge on a run
1256
+ * whose children merge straight into the repository base branch.
1257
+ * * **Feature branch, `--attended`** → `base_branch` plus an explicitly
1258
+ * requested `review_policy`, and nothing else. Byte-identical to the
1259
+ * pre-BAPI-1102 composition, which is precisely what the opt-out promises.
1260
+ * * **Feature branch, default** → adds `posture: "unattended"` and
1261
+ * `v2_auto_merge_enabled: true`.
1262
+ *
1263
+ * Deliberately absent from every shape:
1264
+ *
1265
+ * * **`caps`.** Absent and JSON `null` both mean "the system default applies"
1266
+ * (`unattended_posture.py`), so composing a caps object here would replace a
1267
+ * server-owned ceiling table with a client-authored one.
1268
+ * * **`required_checks` / `no_ci_gate`.** The client has no business encoding a
1269
+ * repository's check names, and a repository default may change between run
1270
+ * creation and approval. First approval resolves the CI gate server-side from
1271
+ * the repository default, or records the explicit no-gate mode — and the stamp
1272
+ * is fill-if-ABSENT, so anything composed here would suppress it.
1273
+ * * **`notify`.** The escalation channel is a repository prerequisite, refused by
1274
+ * name before creation; inventing one here would fake a verified channel.
1275
+ */
1276
+ export function composeDefaultSetupEpicPolicy(args) {
1277
+ const policy = {};
1278
+ if (args.featureBranch !== undefined) {
1279
+ policy.base_branch = args.featureBranch;
1280
+ if (!args.attended) {
1281
+ policy.posture = SETUP_EPIC_UNATTENDED_POSTURE;
1282
+ policy[SETUP_EPIC_AUTO_MERGE_POLICY_KEY] = true;
1283
+ }
1284
+ }
1285
+ if (args.reviewPolicy !== undefined) {
1286
+ policy.review_policy = { source: args.reviewPolicy };
1287
+ }
1288
+ return policy;
1289
+ }
1290
+ /**
1291
+ * Apply the unattended default to a policy a `--policy-file` supplied (BAPI-1102).
1292
+ *
1293
+ * `composeDefaultSetupEpicPolicy` covers the NO-FILE path. This covers the other
1294
+ * one, and without it the default was unreachable by any documented invocation:
1295
+ * a file bypasses that composer entirely, so a file declaring only
1296
+ * `notify.webhook_url` — the exact remedy the `webhook_unverified` refusal, the
1297
+ * readiness remediation, and the record all name — produced a policy with no
1298
+ * `posture`, was never gated, and created an ATTENDED run with no auto-merge
1299
+ * that then parked `v2_merge_approval_required` at every merge. Silently. The
1300
+ * only working unattended invocation was a hand-written file carrying `posture`,
1301
+ * `v2_auto_merge_enabled` and the webhook together, which nothing documents.
1302
+ *
1303
+ * `policyFileDeclaresUnattendedPosture` already states the intended contract in
1304
+ * its own docstring — "a file that stays silent about posture leaves that
1305
+ * default in play for `--attended` to decline" — and this is the code that makes
1306
+ * that true.
1307
+ *
1308
+ * FILL-IF-ABSENT, on the strictest reading of absent, and the boundaries are the
1309
+ * contract:
1310
+ *
1311
+ * * **The file declared a `posture`** → untouched, whatever it says. A file that
1312
+ * answered the question owns the answer; that is what `--policy-file` means,
1313
+ * and the one case where it contradicts `--attended` is already a named
1314
+ * refusal upstream (`attended_with_policy_file_posture`).
1315
+ * * **`--attended`** → untouched. It is the explicit opt-out from a default, and
1316
+ * this is the default it opts out of.
1317
+ * * **No feature branch** → untouched. Identical to the no-file composer's own
1318
+ * boundary: a run whose children merge straight into the repository base
1319
+ * branch must never be handed a posture and an auto-merge authorization.
1320
+ * * **`v2_auto_merge_enabled` already present** → left exactly as the file set
1321
+ * it, including `false`. Filling the posture does not overwrite an explicit
1322
+ * merge decision.
1323
+ *
1324
+ * Pure: it returns a new object and never mutates the input. It is applied on the
1325
+ * CREATE path only. A reused live run reconciles the file against its STORED
1326
+ * policy, and adding a posture there would let `--replace-policy` newly authorize
1327
+ * auto-merge on a run that never had it — the AC-3 guarantee this ticket's gate
1328
+ * placement already rests on.
1329
+ */
1330
+ export function applySetupEpicUnattendedDefault(policy, args) {
1331
+ if (args.attended)
1332
+ return policy;
1333
+ if (args.featureBranch === undefined)
1334
+ return policy;
1335
+ if ("posture" in policy)
1336
+ return policy;
1337
+ const composed = { ...policy };
1338
+ composed.posture = SETUP_EPIC_UNATTENDED_POSTURE;
1339
+ if (!(SETUP_EPIC_AUTO_MERGE_POLICY_KEY in composed)) {
1340
+ composed[SETUP_EPIC_AUTO_MERGE_POLICY_KEY] = true;
1341
+ }
1342
+ return composed;
1343
+ }
1008
1344
  /**
1009
1345
  * One line naming the selected branch strategy AND why it was selected.
1010
1346
  *
@@ -1013,14 +1349,30 @@ export function resolveFeatureBranchSelection(opts, planNodeCount, policyDeclare
1013
1349
  * their policy file asked for it, or the multi-node default derived it — those
1014
1350
  * three have different recovery steps when the name is not the one they wanted.
1015
1351
  */
1016
- function describeBranchStrategy(opts, featureBranch, policyDeclaresBranch) {
1352
+ function describeBranchStrategy(opts, featureBranch, policyDeclaresBranch,
1353
+ /**
1354
+ * BAPI-1102 — `false` when a `--policy-file` owns the whole policy. The posture
1355
+ * suffix describes what THIS COMMAND composes, so claiming a posture over a
1356
+ * policy the file supplied would be a statement setup-epic cannot make: the
1357
+ * file may declare any posture, or none, and this function never reads it.
1358
+ */
1359
+ composesDefaultPolicy = true) {
1017
1360
  const cutNote = "cut on origin with local git at the canonical indexed commit; " +
1018
1361
  "validated server-side at approval";
1362
+ // BAPI-1102: the posture is stated ALONGSIDE the branch, on the same line, and
1363
+ // only for the paths where this command actually chooses it. An operator
1364
+ // reading `epic/BAPI-1102` needs to know whether the run will drive itself.
1365
+ const posture = !composesDefaultPolicy
1366
+ ? ""
1367
+ : opts.attended
1368
+ ? "; ATTENDED posture (--attended): no auto-merge authorization"
1369
+ : "; UNATTENDED posture with auto-merge into the epic branch " +
1370
+ "(the integration PR stays human-gated) — pass --attended to opt out";
1019
1371
  if (featureBranch !== undefined) {
1020
1372
  if (opts.featureBranch !== undefined) {
1021
- return `${featureBranch} (explicit --feature-branch override; ${cutNote})`;
1373
+ return `${featureBranch} (explicit --feature-branch override; ${cutNote}${posture})`;
1022
1374
  }
1023
- return `${featureBranch} (derived epic branch for this multi-ticket plan; ${cutNote})`;
1375
+ return `${featureBranch} (derived epic branch for this multi-ticket plan; ${cutNote}${posture})`;
1024
1376
  }
1025
1377
  if (opts.intoBase) {
1026
1378
  return "repository base branch — epic branch disabled by --into-base; every child PR targets base";
@@ -1152,6 +1504,17 @@ export const SETUP_EPIC_PARSE_FIRST_MESSAGE = "parse the repository first";
1152
1504
  * `baseBranch` slip) is deliberately NOT interpreted here: the server's strict
1153
1505
  * `RunPolicy` validation owns rejecting it, so this returns `undefined` and the
1154
1506
  * request proceeds to that 422 unchanged.
1507
+ *
1508
+ * BAPI-1127: SNAKE-CASE ONLY, on purpose, and a documented exception to the
1509
+ * "one persisted-run resolver" rule rather than a violation of it. The input
1510
+ * here is a REQUESTED policy document the operator is about to submit — not a
1511
+ * persisted run — so the right answer to the camelCase slip is to leave it
1512
+ * alone and let the server's strict boundary refuse it with a 422 the operator
1513
+ * can act on. Honoring the alias here would quietly repair a file the server is
1514
+ * about to reject, and the operator would never learn their file was wrong.
1515
+ * The canonical precedence for a PERSISTED run's declared branch lives in
1516
+ * `conductor/run-branch.ts#resolveDeclaredRunBaseBranch`; this function is not
1517
+ * it and must not grow into it.
1155
1518
  */
1156
1519
  export function resolveEffectiveFeatureBranch(featureBranch, requestedPolicy) {
1157
1520
  if (featureBranch !== undefined)
@@ -1292,7 +1655,29 @@ function describeScopeTransition(state, status) {
1292
1655
  }
1293
1656
  return `Scope: ${label}…`;
1294
1657
  }
1658
+ /**
1659
+ * Run the whole `setup-epic` workflow and return its authoritative result.
1660
+ *
1661
+ * The internal entry point composed callers use. Behaviorally identical to
1662
+ * {@link runSetupEpicCli} in every observable way — same output, same refusals,
1663
+ * same exit codes — and differs only in what it hands back.
1664
+ */
1665
+ export async function runSetupEpicWorkflow(argv, overrides = {}) {
1666
+ const sink = {};
1667
+ const exitCode = await executeSetupEpic(argv, overrides, sink);
1668
+ return {
1669
+ exitCode,
1670
+ ...(sink.epicRunId !== undefined ? { epicRunId: sink.epicRunId } : {}),
1671
+ };
1672
+ }
1673
+ /**
1674
+ * The PUBLIC `setup-epic` entry point. Returns the process exit code and nothing
1675
+ * else, exactly as it always has.
1676
+ */
1295
1677
  export async function runSetupEpicCli(argv, overrides = {}) {
1678
+ return (await runSetupEpicWorkflow(argv, overrides)).exitCode;
1679
+ }
1680
+ async function executeSetupEpic(argv, overrides, runIdSink) {
1296
1681
  const deps = { ...createDefaultSetupEpicDeps(), ...overrides };
1297
1682
  const parsed = parseSetupEpicArgs(argv);
1298
1683
  if (parsed.status === "help") {
@@ -1361,7 +1746,12 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1361
1746
  // network and asks no question, so running it here is what makes `--local-only`
1362
1747
  // and an ordinary invocation report the same strategy for the same plan.
1363
1748
  const featureBranch = resolveFeatureBranchSelection(opts, plan.nodes.length, policyDeclaresBranch);
1364
- const branchStrategyLine = describeBranchStrategy(opts, featureBranch, policyDeclaresBranch);
1749
+ const branchStrategyLine = describeBranchStrategy(opts, featureBranch, policyDeclaresBranch,
1750
+ // `filePolicy` is set exactly when `--policy-file` was supplied, and a
1751
+ // supplied file supersedes composition entirely — so this is the same
1752
+ // predicate as "this command composes the policy", available here, several
1753
+ // statements before the gate resolves `requestedPolicy`.
1754
+ filePolicy === undefined);
1365
1755
  // --- Consolidated policy preflight (BAPI-1054) ---------------------------
1366
1756
  // ONE gate, run BEFORE credential resolution and before any request, over the
1367
1757
  // whole flag-backed choice-set. It sits above the `--local-only` return
@@ -1374,6 +1764,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1374
1764
  // still the better error for everything inside the policy file.
1375
1765
  const policyGate = preflightSetupEpicPolicy({
1376
1766
  intoBase: opts.intoBase,
1767
+ attended: opts.attended,
1377
1768
  policyFile: opts.policyFile,
1378
1769
  filePolicy,
1379
1770
  policyDeclaresBranch,
@@ -1451,14 +1842,42 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1451
1842
  // that passed the gate — not a second composition — so the payload the create
1452
1843
  // request carries is exactly what was validated.
1453
1844
  const requestedPolicy = policyGate.policy;
1454
- if (requestedPolicy !== undefined) {
1455
- say(`Policy: ${opts.policyFile} (complete run policy, applied at creation)`);
1456
- }
1457
1845
  // --- Effective feature branch (BAPI-850) ----------------------------------
1458
1846
  // Derived AFTER policy composition so a branch the policy file declares under
1459
1847
  // `base_branch` is honored exactly as `--feature-branch` would be. A malformed
1460
1848
  // or unknown policy field is left for the server's strict validation.
1461
1849
  const effectiveFeatureBranch = resolveEffectiveFeatureBranch(featureBranch, requestedPolicy);
1850
+ // --- The policy the CREATE path will send (BAPI-1102) ---------------------
1851
+ // Composed HERE, as soon as both of its inputs exist, so the one object that
1852
+ // gets reported, gated, and sent is the same object throughout. It is consumed
1853
+ // only on the create branch below; the reuse branch reconciles the RAW file
1854
+ // against the run's stored policy, because adding a posture there would let
1855
+ // `--replace-policy` newly authorize auto-merge on a run that never had it.
1856
+ const composedForCreate = requestedPolicy !== undefined
1857
+ ? applySetupEpicUnattendedDefault(requestedPolicy, {
1858
+ featureBranch: effectiveFeatureBranch,
1859
+ attended: opts.attended,
1860
+ })
1861
+ : composeDefaultSetupEpicPolicy({
1862
+ featureBranch,
1863
+ attended: opts.attended,
1864
+ reviewPolicy: opts.reviewPolicy,
1865
+ });
1866
+ if (requestedPolicy !== undefined) {
1867
+ // The posture is stated for the FILE path too, and read off the composed
1868
+ // object rather than re-derived. The `--policy-file` invocation is the one
1869
+ // the `webhook_unverified` remedy steers operators toward, so it was the one
1870
+ // path that could create an auto-merging run without ever printing that it
1871
+ // had — the exact thing this line exists to prevent. "Complete run policy"
1872
+ // is likewise no longer said when this command added the posture to it.
1873
+ const inherited = composedForCreate !== requestedPolicy
1874
+ ? "; UNATTENDED posture with auto-merge into the epic branch composed by " +
1875
+ "default over a posture-silent file (the integration PR stays " +
1876
+ "human-gated) — pass --attended to opt out"
1877
+ : "";
1878
+ const shape = inherited === "" ? "complete run policy" : "run policy";
1879
+ say(`Policy: ${opts.policyFile} (${shape}, applied at creation${inherited})`);
1880
+ }
1462
1881
  if (effectiveFeatureBranch !== undefined && featureBranch === undefined) {
1463
1882
  say(`Feature: ${effectiveFeatureBranch} (from the policy file; cut on origin with local ` +
1464
1883
  `git at the canonical indexed commit; validated server-side at approval)`);
@@ -1922,6 +2341,9 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1922
2341
  result.feature_branch = effectiveFeatureBranch;
1923
2342
  // --- Step 1: create (only when there is no live run) ---------------------
1924
2343
  if (existingRunId) {
2344
+ // BAPI-1102 — the reuse path's authoritative id, taken from the pre-check's
2345
+ // server response rather than from anything local.
2346
+ runIdSink.epicRunId = existingRunId;
1925
2347
  say(`Run: reusing ${existingRunId} (status: ${existingStatus})`);
1926
2348
  // A pre-check that already found a live run means create would answer 200
1927
2349
  // anyway; reconcile the file against that run's stored policy directly.
@@ -1941,6 +2363,46 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1941
2363
  }
1942
2364
  }
1943
2365
  else {
2366
+ // --- BAPI-1102: the pre-create unattended prerequisite gate -------------
2367
+ //
2368
+ // Applied ONLY on the create path, and that placement is the AC-3 guarantee
2369
+ // in code: a reused live run keeps its stored policy and its dispatch
2370
+ // behavior untouched, so no deploy of this ticket can refuse or re-gate a
2371
+ // run that is already going.
2372
+ //
2373
+ // Evaluated against the COMPOSED policy, so it covers the default profile and
2374
+ // a `--policy-file` alike; skipped entirely for `--attended` and for any
2375
+ // policy that is not unattended, which is what keeps an attended run from
2376
+ // paying for a readiness read it does not need.
2377
+ //
2378
+ // The file branch runs through `applySetupEpicUnattendedDefault` rather than
2379
+ // taking the file verbatim: a file bypasses the no-file composer entirely, so
2380
+ // a posture-SILENT file used to skip this gate and create an attended run —
2381
+ // which made the default profile unreachable by any documented invocation.
2382
+ // The composed object is what gets gated AND what gets sent, so the create
2383
+ // request carries exactly what was validated.
2384
+ {
2385
+ if (requestedPolicyIsUnattended(composedForCreate, opts.attended)) {
2386
+ let readiness = null;
2387
+ try {
2388
+ readiness = await fetchConductorReadiness(access, deps.fetch);
2389
+ }
2390
+ catch {
2391
+ // Fail CLOSED, and silently as to WHY: the thrown value can carry a URL
2392
+ // or a response excerpt, and this refusal is rendered to a terminal. The
2393
+ // generic `readiness_unreadable` message says what to do about it.
2394
+ readiness = null;
2395
+ }
2396
+ // The COMPOSED policy, not the flags: a --policy-file declaring
2397
+ // notify.webhook_url satisfies the escalation-channel prerequisite, and
2398
+ // it is the same object the create request carries.
2399
+ const refusal = evaluateSetupEpicUnattendedPrerequisites(readiness, composedForCreate);
2400
+ if (refusal !== null) {
2401
+ deps.errorLog(`setup-epic REFUSED (${refusal.reason}): ${refusal.message}`);
2402
+ return 1;
2403
+ }
2404
+ }
2405
+ }
1944
2406
  try {
1945
2407
  // Persist the confirmed feature branch as policy_json.base_branch ONLY when
1946
2408
  // one was selected; otherwise keep the exact legacy create request shape.
@@ -1951,23 +2413,27 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1951
2413
  // BAPI-754: a --policy-file supersedes that ad-hoc composition entirely —
1952
2414
  // `requestedPolicy` IS the composed result, flags included — so the whole
1953
2415
  // policy reaches the server in the create that mints the run.
1954
- let policyJson;
1955
- if (requestedPolicy !== undefined) {
1956
- policyJson = requestedPolicy;
1957
- }
1958
- else {
1959
- policyJson = {};
1960
- if (featureBranch !== undefined)
1961
- policyJson.base_branch = featureBranch;
1962
- if (opts.reviewPolicy !== undefined) {
1963
- policyJson.review_policy = { source: opts.reviewPolicy };
1964
- }
1965
- }
2416
+ //
2417
+ // BAPI-1102: a run that SELECTS A FEATURE BRANCH is composed unattended by
2418
+ // default, whether or not a policy file supplied the rest of the policy.
2419
+ // The gating on a feature branch being selected is the whole safety of it,
2420
+ // not an optimization — see `composeDefaultSetupEpicPolicy` and
2421
+ // `applySetupEpicUnattendedDefault`.
2422
+ //
2423
+ // This is the OBJECT THE GATE ABOVE JUST VALIDATED, not a second
2424
+ // composition of the same inputs. Recomposing here is how the two could
2425
+ // drift: the gate would pass on one policy and the server would receive
2426
+ // another.
2427
+ const policyJson = composedForCreate;
1966
2428
  const createRequest = Object.keys(policyJson).length > 0 || requestedPolicy !== undefined
1967
2429
  ? { epicKey: opts.epicKey, policyJson }
1968
2430
  : { epicKey: opts.epicKey };
1969
2431
  const { run, created } = await createEpicRunWithDisposition(access, createRequest, deps.fetch);
1970
2432
  result.epic_run_id = run.epic_run_id;
2433
+ // BAPI-1102 — the create path's authoritative id. `createEpicRunWithDisposition`
2434
+ // returns the server's run for a fresh creation AND for a create-race
2435
+ // adoption (`created: false`), so both are captured by this one assignment.
2436
+ runIdSink.epicRunId = run.epic_run_id;
1971
2437
  result.status = run.status;
1972
2438
  result.run_created = created;
1973
2439
  if (created) {
@@ -2203,6 +2669,25 @@ export async function runSetupEpicCli(argv, overrides = {}) {
2203
2669
  `dispatch has NOT started. Correct repository access or the branch ` +
2204
2670
  `configuration, then re-run setup-epic.\nDetail: ${errorDetail(err)}`);
2205
2671
  }
2672
+ else if (err instanceof ConductorBridgeApiError &&
2673
+ err.errorCode === SETUP_EPIC_UNATTENDED_POSTURE_REFUSED_CODE) {
2674
+ // BAPI-1102 — the one approval refusal with a canonical, non-obvious
2675
+ // remedy. Consent is read at BOTH boundaries, so a repository whose consent
2676
+ // was revoked between creation and approval refuses HERE, with the run left
2677
+ // exactly as it was: created, planning, unapproved, no activation, no
2678
+ // executor dispatch, and reusable once consent is restored.
2679
+ //
2680
+ // Named separately from the generic approval failure because the generic
2681
+ // message ("Failed to approve the plan: …") tells an operator to look at the
2682
+ // plan, which is the wrong place entirely.
2683
+ deps.errorLog(`The plan was NOT approved: this repository is no longer authorized for ` +
2684
+ `unattended runs. The run WAS created and is left in 'planning' with no ` +
2685
+ `activation and no executor dispatch — nothing was half-applied.\n` +
2686
+ `Restore \`unattended_conductor_allowed\` for this repository (see ` +
2687
+ `docs/claude/account-settings-operator-runbook.md) and re-run setup-epic, ` +
2688
+ `or deliberately convert this run to an attended posture through the ` +
2689
+ `supported operator flow.\nDetail: ${errorDetail(err)}`);
2690
+ }
2206
2691
  else {
2207
2692
  deps.errorLog(`Failed to approve the plan: ${errorDetail(err)}`);
2208
2693
  }
@@ -2369,8 +2854,14 @@ export async function runSetupEpicCli(argv, overrides = {}) {
2369
2854
  else {
2370
2855
  say("The server-side reconciler will pick it up within ~30s.");
2371
2856
  }
2372
- say("To execute claimed jobs on this machine, run:");
2373
- say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName}`);
2857
+ // BAPI-1102 the executor command is printed WITH a claim scope. Since
2858
+ // BAPI-1026 an executor started with no scope REFUSES TO START, so the
2859
+ // unscoped form printed here was guidance that could not work: an operator
2860
+ // who copied it got a startup error, and the natural reading of that error is
2861
+ // that the run is broken rather than that the instruction was.
2862
+ say("To execute this run's claimed jobs on this machine, run:");
2863
+ say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName} ` +
2864
+ `--epic-run-id ${result.epic_run_id}`);
2374
2865
  }
2375
2866
  return exitCode;
2376
2867
  }