@bridge_gpt/mcp-server 0.2.53 → 0.2.55

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 (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -47,7 +47,7 @@
47
47
  * association is immutable server-side and can never be attached later.
48
48
  * c. Cut or validate `origin/<feature>` at EXACTLY the canonical indexed SHA
49
49
  * with the operator's LOCAL `git`, through the shared cut protocol
50
- * `conduct-epic init` also drives, with the run id so the scope is minted
50
+ * `implement-epic init` also drives, with the run id so the scope is minted
51
51
  * owned by the run. An existing branch at a different commit fails closed.
52
52
  * The GitHub App never pushes — it is `contents: read`.
53
53
  * d. Store and approve (unchanged requests). The server now READ-validates the
@@ -78,10 +78,10 @@
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
- // Reused from the conduct-epic client on purpose: adding a second wrapper under
84
+ // Reused from the implement-epic client on purpose: adding a second wrapper under
85
85
  // `conductor/` would give the v2 path its own copy to drift from.
86
86
  //
87
87
  // BAPI-850 extends that reuse to the canonical parse read, the repository base
@@ -89,15 +89,45 @@ import { validateBranchName } from "./base-ref.js";
89
89
  // typed clients the pilot already has are the v2 path's clients too, so neither
90
90
  // conductor can drift on a field name or an endpoint shape. No MCP tool, no
91
91
  // input-schema field, and no description changes.
92
- import { getConfigFieldBaseBranch, getIndexScopeLifecycle, getParseStatus, } from "./conduct-epic/bridge-client.js";
93
- import { boundScopeLifecycleLabel, createExecFileRunCommand, ensureCommitResolvableLocally, normalizeCommitSha, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_LIFECYCLE_LABELS, } from "./conduct-epic/cut-protocol.js";
92
+ import { getConfigFieldBaseBranch, getIndexScopeLifecycle, getParseStatus, } from "./implement-epic/bridge-client.js";
93
+ import { boundScopeLifecycleLabel, createExecFileRunCommand, ensureCommitResolvableLocally, normalizeCommitSha, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_LIFECYCLE_LABELS, } from "./implement-epic/cut-protocol.js";
94
94
  import { hashPlan } from "./conductor/plan.js";
95
+ import { BRING_UP_REFUSAL_MESSAGES, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
95
96
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
96
97
  // BAPI-872: local plane-manifest binding, so `plane down` can later stop the
97
98
  // run this setup resolved WITHOUT guessing from repository-wide active-run
98
99
  // state. See `planeBinding` on `SetupEpicDeps` below.
99
100
  import { createPlaneFsDeps, createPlaneProcessDeps } from "./plane/defaults.js";
100
101
  import { bindPlaneManifestEpicRun, manifestHasLiveProcess, readPlaneManifest, } from "./plane/manifest.js";
102
+ /**
103
+ * The one `policy_json.posture` value that means "unattended" (BAPI-1102).
104
+ *
105
+ * Mirrors the server's `api.models.epic_run.RUN_POSTURE_UNATTENDED`, which is a
106
+ * closed one-member vocabulary: the model refuses any other value and treats an
107
+ * omitted key as attended. Written out here rather than derived, because the
108
+ * client cannot import a Python constant; a unit test pins the two together.
109
+ */
110
+ export const SETUP_EPIC_UNATTENDED_POSTURE = "unattended";
111
+ /**
112
+ * The server's stable error code for an unattended-posture refusal (BAPI-734).
113
+ *
114
+ * Mirrors `api.library.epic_conductor.unattended_posture.UNATTENDED_POSTURE_REFUSED_CODE`.
115
+ * Matched by EQUALITY on the structured `error_code`, never by searching the
116
+ * message text: the message is prose that may be rewritten, the code is the
117
+ * contract.
118
+ */
119
+ export const SETUP_EPIC_UNATTENDED_POSTURE_REFUSED_CODE = "UNATTENDED_POSTURE_REFUSED";
120
+ /**
121
+ * The `policy_json` key authorizing automatic merge dispatch (BAPI-722).
122
+ *
123
+ * Composed into the unattended default alongside the posture. The two are not
124
+ * redundant: `posture` selects the unattended predicate set and the escalation
125
+ * discipline, `v2_auto_merge_enabled` is what actually authorizes the reconciler
126
+ * to dispatch a merge. A run with the posture and without the flag is a valid,
127
+ * fully supported configuration — it simply parks at every merge for a human,
128
+ * which is what the observed BAPI-1085 run did.
129
+ */
130
+ export const SETUP_EPIC_AUTO_MERGE_POLICY_KEY = "v2_auto_merge_enabled";
101
131
  /** Accepted `policy_json.review_policy.source` values (the `ReviewPolicy` surface). */
102
132
  export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
103
133
  "verdict_protocol",
@@ -138,6 +168,19 @@ export function getSetupEpicUsage() {
138
168
  "integration PR targets the repository base branch. A single-node plan keeps the",
139
169
  "repository base branch, matching plain start-tickets semantics.",
140
170
  "",
171
+ "Posture (default): a run that selects an epic branch is created UNATTENDED —",
172
+ "posture: unattended and v2_auto_merge_enabled: true — and the server stamps its",
173
+ "CI gate at first approval from the repository default, or records an explicit",
174
+ "no-CI-gate mode when the repository declares none. Child PRs merge into the epic",
175
+ "branch; the INTEGRATION PR into the repository base branch stays human-gated.",
176
+ "Pass --attended to opt out. A run that selects no epic branch (--into-base, or a",
177
+ "single-node plan) is unchanged and composes no posture at all.",
178
+ "",
179
+ "Unattended runs have TWO one-time repository prerequisites, refused by name",
180
+ "before any run is created: repository consent (unattended_epic_implementer_allowed) and",
181
+ "a verified notify webhook default. `drive-epic` readiness lists both with their",
182
+ "current state.",
183
+ "",
141
184
  "Required:",
142
185
  " --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
143
186
  " --plan-file <path> Path to epic-plan.dag.json (from decompose-epic)",
@@ -165,6 +208,13 @@ export function getSetupEpicUsage() {
165
208
  " (the pre-BAPI-1009 behavior). Use this if automation",
166
209
  " depended on \"no flag = direct to base\". Contradictory",
167
210
  " with --feature-branch; passing both is a parse error.",
211
+ " --attended OPT OUT of the unattended default: compose only",
212
+ " base_branch (plus --review-policy when supplied), with",
213
+ " no posture and no auto-merge authorization, and skip the",
214
+ " unattended repository prerequisites entirely. The create",
215
+ " request is byte-for-byte the pre-BAPI-1102 one.",
216
+ " Contradictory with a --policy-file that declares",
217
+ " posture: unattended; that pair is refused by name.",
168
218
  " --review-policy <src> PER-RUN review policy source, one of:",
169
219
  ` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
170
220
  " Composed into policy_json.review_policy on create.",
@@ -269,6 +319,7 @@ export function parseSetupEpicArgs(argv) {
269
319
  // fire on the INTENT, not on whether a name survived normalization.
270
320
  let featureBranchSupplied = false;
271
321
  let intoBase = false;
322
+ let attended = false;
272
323
  let reviewPolicy;
273
324
  let policyFile;
274
325
  let replacePolicy = false;
@@ -379,6 +430,14 @@ export function parseSetupEpicArgs(argv) {
379
430
  case "--into-base":
380
431
  intoBase = true;
381
432
  break;
433
+ // BAPI-1102. Valueless, like `--into-base` and `--replace-policy`. It is
434
+ // deliberately NOT contradictory with `--into-base`: a direct-to-main run
435
+ // composes no policy at all, so `--attended` is redundant there rather than
436
+ // conflicting, and refusing a redundant flag would be a worse experience
437
+ // than accepting it.
438
+ case "--attended":
439
+ attended = true;
440
+ break;
382
441
  case "--replace-policy":
383
442
  replacePolicy = true;
384
443
  break;
@@ -442,6 +501,7 @@ export function parseSetupEpicArgs(argv) {
442
501
  planVersion,
443
502
  featureBranch,
444
503
  intoBase,
504
+ attended,
445
505
  reviewPolicy,
446
506
  policyFile,
447
507
  replacePolicy,
@@ -523,6 +583,15 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
523
583
  // contradiction purposes so a conflicting value is reported rather than
524
584
  // silently ignored, but never invent it and never rewrite it into snake_case:
525
585
  // the server's strict boundary should be the one to reject it.
586
+ //
587
+ // BAPI-1127: this paired read is CONTRADICTION DETECTION over a policy file
588
+ // the operator just handed in, at SETUP time, before any run exists — it is
589
+ // not run-branch resolution and must never become one. It asks "did the file
590
+ // name a branch under either spelling that disagrees with --feature-branch?",
591
+ // so it reads both keys for PRESENCE and deliberately picks no winner between
592
+ // them. `conductor/run-branch.ts#resolveDeclaredRunBaseBranch` is the single
593
+ // authority for resolving a PERSISTED run's declared branch; this is a
594
+ // documented exception to that, not a second implementation of it.
526
595
  const declaredBranches = [];
527
596
  for (const key of ["base_branch", "baseBranch"]) {
528
597
  if (key in composed && composed[key] !== undefined && composed[key] !== null) {
@@ -611,7 +680,30 @@ export function preflightSetupEpicPolicy(args) {
611
680
  `branch declaration from the policy file, or drop --into-base.`,
612
681
  };
613
682
  }
614
- // 2. Policy-source contradictions between the file and the CLI selections.
683
+ // 2. Posture contradiction (BAPI-1102). Placed SECOND after the branch
684
+ // strategy, before the composer's field-level conflicts — so one invocation
685
+ // still yields exactly one named reason, deterministically. It belongs above
686
+ // the composer because it is a whole-INTENT contradiction: an operator who
687
+ // said "attended" and supplied a file that says "unattended" has not made a
688
+ // field mistake, and reporting a downstream field conflict first would send
689
+ // them to the wrong line of the file.
690
+ //
691
+ // READ-ONLY, and the read is deliberately the narrowest possible: only
692
+ // whether the file explicitly declares `posture: "unattended"`. Nothing is
693
+ // normalized, no other key is inspected, and — like every message here — the
694
+ // refusal interpolates the file PATH and fixed prose only, never a value
695
+ // read out of the file.
696
+ if (args.attended === true && policyFileDeclaresUnattendedPosture(args.filePolicy)) {
697
+ return {
698
+ ok: false,
699
+ reason: "attended_with_policy_file_posture",
700
+ message: `--attended opts out of the unattended default, but --policy-file ` +
701
+ `'${args.policyFile}' declares posture: "unattended". setup-epic will not ` +
702
+ `pick a winner between them: drop --attended, or remove the posture ` +
703
+ `declaration from the policy file.`,
704
+ };
705
+ }
706
+ // 3. Policy-source contradictions between the file and the CLI selections.
615
707
  // Delegated to the composer, which owns the "no silent winner" rule; the
616
708
  // gate only gives the outcome a stable name.
617
709
  if (args.filePolicy === undefined)
@@ -625,6 +717,24 @@ export function preflightSetupEpicPolicy(args) {
625
717
  }
626
718
  return { ok: true, policy: composed.policy };
627
719
  }
720
+ /**
721
+ * Does this policy file explicitly declare the UNATTENDED posture (BAPI-1102)?
722
+ *
723
+ * Strict by construction, and narrow on purpose. `true` only for a plain object
724
+ * whose `posture` is exactly the string `"unattended"` — the one spelling the
725
+ * server's `RUN_POSTURE_UNATTENDED` accepts. It never reads another key, never
726
+ * normalizes, and never returns the value it read, so the caller cannot leak a
727
+ * policy value into a refusal message even by accident.
728
+ *
729
+ * A file that declares no posture is not a contradiction: `--attended` is the
730
+ * opt-out from a DEFAULT, and a file that stays silent about posture leaves that
731
+ * default in play for `--attended` to decline.
732
+ */
733
+ export function policyFileDeclaresUnattendedPosture(filePolicy) {
734
+ if (filePolicy === undefined || filePolicy === null)
735
+ return false;
736
+ return filePolicy.posture === SETUP_EPIC_UNATTENDED_POSTURE;
737
+ }
628
738
  /**
629
739
  * Does this policy object explicitly supply the `notify.webhook_url` leaf?
630
740
  *
@@ -1005,6 +1115,258 @@ export function resolveFeatureBranchSelection(opts, planNodeCount, policyDeclare
1005
1115
  return undefined;
1006
1116
  return proposeFeatureBranchName(opts.epicKey);
1007
1117
  }
1118
+ /**
1119
+ * Evaluate the unattended prerequisites against a readiness report.
1120
+ *
1121
+ * Pure and total: it takes an already-read report (or `null` for a failed read)
1122
+ * and the COMPOSED policy the create request will carry, and returns at most ONE
1123
+ * refusal, in a fixed order that matches the server's own cheapest-first
1124
+ * sequence, so readiness, this gate, and the approval refusal all name the same
1125
+ * first blocker.
1126
+ *
1127
+ * The policy is a parameter rather than something re-derived here because one
1128
+ * prerequisite — the escalation channel — is satisfiable per RUN as well as per
1129
+ * repository. A version of this gate that read only the repository report
1130
+ * refused every unattended run, its own named remedy included.
1131
+ *
1132
+ * The reason CODES are the server's bounded vocabulary, spelled exactly as
1133
+ * `api/library/epic_conductor/unattended_posture.py` spells them. The client adds
1134
+ * remediation TEXT around them and invents no aliases — an alias would drift from
1135
+ * the server's refusal the first time either side was edited, and an operator
1136
+ * comparing a CLI message with an API response would have no way to tell they
1137
+ * were the same refusal.
1138
+ *
1139
+ * `readiness_unreadable` is the one code with no server twin, and it is
1140
+ * deliberately generic and secret-free: an unreadable or older-server report has
1141
+ * PROVEN nothing, and fail-closed is the only safe direction for a check that
1142
+ * authorizes unattended merging.
1143
+ */
1144
+ /**
1145
+ * Does this policy declare a notify webhook the server would probe?
1146
+ *
1147
+ * BAPI-1102. The shape check mirrors `_extract_webhook_url` in
1148
+ * `api/library/epic_conductor/webhook_verification.py` exactly as far as
1149
+ * PRESENCE goes — a `notify` object carrying a non-blank string `webhook_url` —
1150
+ * and stops there on purpose. Scheme, host, and reachability are the server's to
1151
+ * judge at activation, where the probe actually happens; re-deciding them here
1152
+ * would mean a client that refused a URL the server accepts, or accepted one it
1153
+ * refuses, the first time either side's rules were edited.
1154
+ *
1155
+ * `notify.local_sink` deliberately does NOT satisfy this, matching the server: a
1156
+ * local sink has no destination to prove reachable, so it is not an escalation
1157
+ * channel for a run nobody is watching.
1158
+ */
1159
+ export function policyDeclaresNotifyWebhook(policy) {
1160
+ if (policy === undefined || policy === null)
1161
+ return false;
1162
+ const notify = policy.notify;
1163
+ if (typeof notify !== "object" || notify === null || Array.isArray(notify)) {
1164
+ return false;
1165
+ }
1166
+ const url = notify.webhook_url;
1167
+ return typeof url === "string" && url.trim() !== "";
1168
+ }
1169
+ export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPolicy) {
1170
+ const unattended = readiness?.unattended ?? null;
1171
+ if (unattended === null) {
1172
+ return {
1173
+ reason: "readiness_unreadable",
1174
+ message: "the Bridge API did not report this repository's unattended prerequisites, so " +
1175
+ "they could not be confirmed. Retry once the server is reachable and running a " +
1176
+ "build that reports them, or pass --attended to create an attended run. No run " +
1177
+ "was created.",
1178
+ };
1179
+ }
1180
+ if (!unattended.conductor_allowed) {
1181
+ return {
1182
+ reason: "target_not_allowed",
1183
+ message: "this repository has not consented to unattended conductor runs. Set " +
1184
+ "`unattended_epic_implementer_allowed` for it (see " +
1185
+ "docs/claude/account-settings-operator-runbook.md), or pass --attended to " +
1186
+ "create an attended run. No run was created.",
1187
+ };
1188
+ }
1189
+ // Two independent sources satisfy the escalation channel, and the check has to
1190
+ // consult BOTH or it refuses its own documented remedy. No column supplies a
1191
+ // repository-level notify default today, so `notify_webhook_default_declared`
1192
+ // is `false` for every repository; reading only that field made this branch
1193
+ // refuse EVERY unattended run — including the `--policy-file` supplying
1194
+ // `notify.webhook_url` that its own message names as the fix — and left
1195
+ // `--attended` as the only reachable path. The run-level declaration is the
1196
+ // supported form, so it is the one that has to be honoured here.
1197
+ //
1198
+ // Presence, not verification: the server probes the URL at activation and is
1199
+ // the authority on whether it is reachable. This gate exists to spare the
1200
+ // operator a created-and-refused run, not to duplicate the probe.
1201
+ if (!unattended.notify_webhook_default_declared &&
1202
+ !policyDeclaresNotifyWebhook(requestedPolicy)) {
1203
+ return {
1204
+ reason: "webhook_unverified",
1205
+ message: "an unattended run must have a verified escalation channel, and neither this " +
1206
+ "repository nor this run declares one. Supply a --policy-file declaring " +
1207
+ "notify.webhook_url (notify.local_sink does not satisfy this), or pass " +
1208
+ "--attended to create an attended run. No run was created.",
1209
+ };
1210
+ }
1211
+ if (!unattended.repository_readiness_confirmed) {
1212
+ return {
1213
+ reason: "repository_readiness_unconfirmed",
1214
+ message: "the unattended default authorizes automatic merge, which requires the review and " +
1215
+ "conductor-CI workflows to be CONFIRMED on the default branch, and they are not. " +
1216
+ "Run `install-bridge conductor` to install them, or pass --attended to create an " +
1217
+ "attended run. No run was created.",
1218
+ };
1219
+ }
1220
+ return null;
1221
+ }
1222
+ /** The closing sentence every pre-create refusal ends with. */
1223
+ const NO_RUN_WAS_CREATED = "No run was created.";
1224
+ /**
1225
+ * Evaluate the bring-up facts against a readiness report.
1226
+ *
1227
+ * Pure and total: `null` is a readiness read that failed, which has proven
1228
+ * nothing and refuses as `readiness_unreadable`. Otherwise at most ONE refusal,
1229
+ * in the fixed {@link BRING_UP_REFUSAL_REASONS} order — each fact's
1230
+ * "unreadable" before its "bad", so an operator is never told to repair a
1231
+ * condition that could not be read.
1232
+ */
1233
+ export function evaluateSetupEpicBringUpPrerequisites(readiness) {
1234
+ if (readiness === null) {
1235
+ return {
1236
+ reason: "readiness_unreadable",
1237
+ message: "the Bridge API conductor readiness report could not be read, so the database " +
1238
+ "migration, worker presence, and single-reconciler facts could not be confirmed. " +
1239
+ `Retry once the server is reachable. ${NO_RUN_WAS_CREATED}`,
1240
+ };
1241
+ }
1242
+ const reason = firstBringUpRefusal(readiness.bring_up ?? null);
1243
+ if (reason === null)
1244
+ return null;
1245
+ return { reason, message: `${BRING_UP_REFUSAL_MESSAGES[reason]} ${NO_RUN_WAS_CREATED}` };
1246
+ }
1247
+ /**
1248
+ * Does the policy this invocation will actually send request unattended operation?
1249
+ *
1250
+ * Read off the COMPOSED policy — the same object the create request carries —
1251
+ * rather than off the flags, so a `--policy-file` that declares
1252
+ * `posture: "unattended"` is gated exactly as the default profile is. Reading the
1253
+ * flags instead would let the file route around the prerequisites, which is the
1254
+ * one thing this gate exists to prevent.
1255
+ *
1256
+ * `--attended` short-circuits before the read: it is the explicit opt-out, and
1257
+ * the preflight gate above has already refused the one case where it contradicts
1258
+ * a policy file.
1259
+ */
1260
+ export function requestedPolicyIsUnattended(policy, attended) {
1261
+ if (attended)
1262
+ return false;
1263
+ if (policy === undefined)
1264
+ return false;
1265
+ return policy.posture === SETUP_EPIC_UNATTENDED_POSTURE;
1266
+ }
1267
+ /**
1268
+ * Compose the create-time `policy_json` for a run with NO `--policy-file`.
1269
+ *
1270
+ * BAPI-1102. Extracted from the create branch so the default profile has one
1271
+ * definition and one place to read it, rather than an inline object literal that
1272
+ * a later edit could silently widen.
1273
+ *
1274
+ * Three shapes, and the boundaries between them are the contract:
1275
+ *
1276
+ * * **No feature branch selected** → `{}`. `--into-base`, a single-node plan,
1277
+ * and the branch-silent path each keep the EXACT legacy create request shape:
1278
+ * the caller sends no `policy_json` key at all, byte-for-byte what it sent
1279
+ * before BAPI-1009 (AC-9). Adding a posture, an auto-merge authorization, an
1280
+ * empty policy object, or anything else here would break all three at once —
1281
+ * and, worse, would ask the server to admit an ungated auto-merge on a run
1282
+ * whose children merge straight into the repository base branch.
1283
+ * * **Feature branch, `--attended`** → `base_branch` plus an explicitly
1284
+ * requested `review_policy`, and nothing else. Byte-identical to the
1285
+ * pre-BAPI-1102 composition, which is precisely what the opt-out promises.
1286
+ * * **Feature branch, default** → adds `posture: "unattended"` and
1287
+ * `v2_auto_merge_enabled: true`.
1288
+ *
1289
+ * Deliberately absent from every shape:
1290
+ *
1291
+ * * **`caps`.** Absent and JSON `null` both mean "the system default applies"
1292
+ * (`unattended_posture.py`), so composing a caps object here would replace a
1293
+ * server-owned ceiling table with a client-authored one.
1294
+ * * **`required_checks` / `no_ci_gate`.** The client has no business encoding a
1295
+ * repository's check names, and a repository default may change between run
1296
+ * creation and approval. First approval resolves the CI gate server-side from
1297
+ * the repository default, or records the explicit no-gate mode — and the stamp
1298
+ * is fill-if-ABSENT, so anything composed here would suppress it.
1299
+ * * **`notify`.** The escalation channel is a repository prerequisite, refused by
1300
+ * name before creation; inventing one here would fake a verified channel.
1301
+ */
1302
+ export function composeDefaultSetupEpicPolicy(args) {
1303
+ const policy = {};
1304
+ if (args.featureBranch !== undefined) {
1305
+ policy.base_branch = args.featureBranch;
1306
+ if (!args.attended) {
1307
+ policy.posture = SETUP_EPIC_UNATTENDED_POSTURE;
1308
+ policy[SETUP_EPIC_AUTO_MERGE_POLICY_KEY] = true;
1309
+ }
1310
+ }
1311
+ if (args.reviewPolicy !== undefined) {
1312
+ policy.review_policy = { source: args.reviewPolicy };
1313
+ }
1314
+ return policy;
1315
+ }
1316
+ /**
1317
+ * Apply the unattended default to a policy a `--policy-file` supplied (BAPI-1102).
1318
+ *
1319
+ * `composeDefaultSetupEpicPolicy` covers the NO-FILE path. This covers the other
1320
+ * one, and without it the default was unreachable by any documented invocation:
1321
+ * a file bypasses that composer entirely, so a file declaring only
1322
+ * `notify.webhook_url` — the exact remedy the `webhook_unverified` refusal, the
1323
+ * readiness remediation, and the record all name — produced a policy with no
1324
+ * `posture`, was never gated, and created an ATTENDED run with no auto-merge
1325
+ * that then parked `v2_merge_approval_required` at every merge. Silently. The
1326
+ * only working unattended invocation was a hand-written file carrying `posture`,
1327
+ * `v2_auto_merge_enabled` and the webhook together, which nothing documents.
1328
+ *
1329
+ * `policyFileDeclaresUnattendedPosture` already states the intended contract in
1330
+ * its own docstring — "a file that stays silent about posture leaves that
1331
+ * default in play for `--attended` to decline" — and this is the code that makes
1332
+ * that true.
1333
+ *
1334
+ * FILL-IF-ABSENT, on the strictest reading of absent, and the boundaries are the
1335
+ * contract:
1336
+ *
1337
+ * * **The file declared a `posture`** → untouched, whatever it says. A file that
1338
+ * answered the question owns the answer; that is what `--policy-file` means,
1339
+ * and the one case where it contradicts `--attended` is already a named
1340
+ * refusal upstream (`attended_with_policy_file_posture`).
1341
+ * * **`--attended`** → untouched. It is the explicit opt-out from a default, and
1342
+ * this is the default it opts out of.
1343
+ * * **No feature branch** → untouched. Identical to the no-file composer's own
1344
+ * boundary: a run whose children merge straight into the repository base
1345
+ * branch must never be handed a posture and an auto-merge authorization.
1346
+ * * **`v2_auto_merge_enabled` already present** → left exactly as the file set
1347
+ * it, including `false`. Filling the posture does not overwrite an explicit
1348
+ * merge decision.
1349
+ *
1350
+ * Pure: it returns a new object and never mutates the input. It is applied on the
1351
+ * CREATE path only. A reused live run reconciles the file against its STORED
1352
+ * policy, and adding a posture there would let `--replace-policy` newly authorize
1353
+ * auto-merge on a run that never had it — the AC-3 guarantee this ticket's gate
1354
+ * placement already rests on.
1355
+ */
1356
+ export function applySetupEpicUnattendedDefault(policy, args) {
1357
+ if (args.attended)
1358
+ return policy;
1359
+ if (args.featureBranch === undefined)
1360
+ return policy;
1361
+ if ("posture" in policy)
1362
+ return policy;
1363
+ const composed = { ...policy };
1364
+ composed.posture = SETUP_EPIC_UNATTENDED_POSTURE;
1365
+ if (!(SETUP_EPIC_AUTO_MERGE_POLICY_KEY in composed)) {
1366
+ composed[SETUP_EPIC_AUTO_MERGE_POLICY_KEY] = true;
1367
+ }
1368
+ return composed;
1369
+ }
1008
1370
  /**
1009
1371
  * One line naming the selected branch strategy AND why it was selected.
1010
1372
  *
@@ -1013,14 +1375,30 @@ export function resolveFeatureBranchSelection(opts, planNodeCount, policyDeclare
1013
1375
  * their policy file asked for it, or the multi-node default derived it — those
1014
1376
  * three have different recovery steps when the name is not the one they wanted.
1015
1377
  */
1016
- function describeBranchStrategy(opts, featureBranch, policyDeclaresBranch) {
1378
+ function describeBranchStrategy(opts, featureBranch, policyDeclaresBranch,
1379
+ /**
1380
+ * BAPI-1102 — `false` when a `--policy-file` owns the whole policy. The posture
1381
+ * suffix describes what THIS COMMAND composes, so claiming a posture over a
1382
+ * policy the file supplied would be a statement setup-epic cannot make: the
1383
+ * file may declare any posture, or none, and this function never reads it.
1384
+ */
1385
+ composesDefaultPolicy = true) {
1017
1386
  const cutNote = "cut on origin with local git at the canonical indexed commit; " +
1018
1387
  "validated server-side at approval";
1388
+ // BAPI-1102: the posture is stated ALONGSIDE the branch, on the same line, and
1389
+ // only for the paths where this command actually chooses it. An operator
1390
+ // reading `epic/BAPI-1102` needs to know whether the run will drive itself.
1391
+ const posture = !composesDefaultPolicy
1392
+ ? ""
1393
+ : opts.attended
1394
+ ? "; ATTENDED posture (--attended): no auto-merge authorization"
1395
+ : "; UNATTENDED posture with auto-merge into the epic branch " +
1396
+ "(the integration PR stays human-gated) — pass --attended to opt out";
1019
1397
  if (featureBranch !== undefined) {
1020
1398
  if (opts.featureBranch !== undefined) {
1021
- return `${featureBranch} (explicit --feature-branch override; ${cutNote})`;
1399
+ return `${featureBranch} (explicit --feature-branch override; ${cutNote}${posture})`;
1022
1400
  }
1023
- return `${featureBranch} (derived epic branch for this multi-ticket plan; ${cutNote})`;
1401
+ return `${featureBranch} (derived epic branch for this multi-ticket plan; ${cutNote}${posture})`;
1024
1402
  }
1025
1403
  if (opts.intoBase) {
1026
1404
  return "repository base branch — epic branch disabled by --into-base; every child PR targets base";
@@ -1152,6 +1530,17 @@ export const SETUP_EPIC_PARSE_FIRST_MESSAGE = "parse the repository first";
1152
1530
  * `baseBranch` slip) is deliberately NOT interpreted here: the server's strict
1153
1531
  * `RunPolicy` validation owns rejecting it, so this returns `undefined` and the
1154
1532
  * request proceeds to that 422 unchanged.
1533
+ *
1534
+ * BAPI-1127: SNAKE-CASE ONLY, on purpose, and a documented exception to the
1535
+ * "one persisted-run resolver" rule rather than a violation of it. The input
1536
+ * here is a REQUESTED policy document the operator is about to submit — not a
1537
+ * persisted run — so the right answer to the camelCase slip is to leave it
1538
+ * alone and let the server's strict boundary refuse it with a 422 the operator
1539
+ * can act on. Honoring the alias here would quietly repair a file the server is
1540
+ * about to reject, and the operator would never learn their file was wrong.
1541
+ * The canonical precedence for a PERSISTED run's declared branch lives in
1542
+ * `conductor/run-branch.ts#resolveDeclaredRunBaseBranch`; this function is not
1543
+ * it and must not grow into it.
1155
1544
  */
1156
1545
  export function resolveEffectiveFeatureBranch(featureBranch, requestedPolicy) {
1157
1546
  if (featureBranch !== undefined)
@@ -1256,13 +1645,13 @@ function scopeFailureGuidance(reason, featureBranch, epicRunId) {
1256
1645
  if (reason === "canonical_index_advanced") {
1257
1646
  return (`${SETUP_EPIC_SCOPE_REDRIVE_RECOVERY} The canonical index advanced before the ` +
1258
1647
  `scope could be seeded: delete origin/${featureBranch}, abandon this run ` +
1259
- `(conductor abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
1648
+ `(epic-implementer abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
1260
1649
  }
1261
1650
  if (reason === "head_mismatch") {
1262
1651
  return (`${SETUP_EPIC_SCOPE_REDRIVE_RECOVERY} origin/${featureBranch} does not sit at the ` +
1263
1652
  `commit this scope was cut at, and setup-epic never force-updates an existing ` +
1264
1653
  `ref: delete origin/${featureBranch}, abandon this run ` +
1265
- `(conductor abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
1654
+ `(epic-implementer abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
1266
1655
  }
1267
1656
  return `Recovery: ${SETUP_EPIC_SCOPE_RECOVERY_COMMAND}.`;
1268
1657
  }
@@ -1292,7 +1681,29 @@ function describeScopeTransition(state, status) {
1292
1681
  }
1293
1682
  return `Scope: ${label}…`;
1294
1683
  }
1684
+ /**
1685
+ * Run the whole `setup-epic` workflow and return its authoritative result.
1686
+ *
1687
+ * The internal entry point composed callers use. Behaviorally identical to
1688
+ * {@link runSetupEpicCli} in every observable way — same output, same refusals,
1689
+ * same exit codes — and differs only in what it hands back.
1690
+ */
1691
+ export async function runSetupEpicWorkflow(argv, overrides = {}) {
1692
+ const sink = {};
1693
+ const exitCode = await executeSetupEpic(argv, overrides, sink);
1694
+ return {
1695
+ exitCode,
1696
+ ...(sink.epicRunId !== undefined ? { epicRunId: sink.epicRunId } : {}),
1697
+ };
1698
+ }
1699
+ /**
1700
+ * The PUBLIC `setup-epic` entry point. Returns the process exit code and nothing
1701
+ * else, exactly as it always has.
1702
+ */
1295
1703
  export async function runSetupEpicCli(argv, overrides = {}) {
1704
+ return (await runSetupEpicWorkflow(argv, overrides)).exitCode;
1705
+ }
1706
+ async function executeSetupEpic(argv, overrides, runIdSink) {
1296
1707
  const deps = { ...createDefaultSetupEpicDeps(), ...overrides };
1297
1708
  const parsed = parseSetupEpicArgs(argv);
1298
1709
  if (parsed.status === "help") {
@@ -1361,7 +1772,12 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1361
1772
  // network and asks no question, so running it here is what makes `--local-only`
1362
1773
  // and an ordinary invocation report the same strategy for the same plan.
1363
1774
  const featureBranch = resolveFeatureBranchSelection(opts, plan.nodes.length, policyDeclaresBranch);
1364
- const branchStrategyLine = describeBranchStrategy(opts, featureBranch, policyDeclaresBranch);
1775
+ const branchStrategyLine = describeBranchStrategy(opts, featureBranch, policyDeclaresBranch,
1776
+ // `filePolicy` is set exactly when `--policy-file` was supplied, and a
1777
+ // supplied file supersedes composition entirely — so this is the same
1778
+ // predicate as "this command composes the policy", available here, several
1779
+ // statements before the gate resolves `requestedPolicy`.
1780
+ filePolicy === undefined);
1365
1781
  // --- Consolidated policy preflight (BAPI-1054) ---------------------------
1366
1782
  // ONE gate, run BEFORE credential resolution and before any request, over the
1367
1783
  // whole flag-backed choice-set. It sits above the `--local-only` return
@@ -1374,6 +1790,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1374
1790
  // still the better error for everything inside the policy file.
1375
1791
  const policyGate = preflightSetupEpicPolicy({
1376
1792
  intoBase: opts.intoBase,
1793
+ attended: opts.attended,
1377
1794
  policyFile: opts.policyFile,
1378
1795
  filePolicy,
1379
1796
  policyDeclaresBranch,
@@ -1451,14 +1868,42 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1451
1868
  // that passed the gate — not a second composition — so the payload the create
1452
1869
  // request carries is exactly what was validated.
1453
1870
  const requestedPolicy = policyGate.policy;
1454
- if (requestedPolicy !== undefined) {
1455
- say(`Policy: ${opts.policyFile} (complete run policy, applied at creation)`);
1456
- }
1457
1871
  // --- Effective feature branch (BAPI-850) ----------------------------------
1458
1872
  // Derived AFTER policy composition so a branch the policy file declares under
1459
1873
  // `base_branch` is honored exactly as `--feature-branch` would be. A malformed
1460
1874
  // or unknown policy field is left for the server's strict validation.
1461
1875
  const effectiveFeatureBranch = resolveEffectiveFeatureBranch(featureBranch, requestedPolicy);
1876
+ // --- The policy the CREATE path will send (BAPI-1102) ---------------------
1877
+ // Composed HERE, as soon as both of its inputs exist, so the one object that
1878
+ // gets reported, gated, and sent is the same object throughout. It is consumed
1879
+ // only on the create branch below; the reuse branch reconciles the RAW file
1880
+ // against the run's stored policy, because adding a posture there would let
1881
+ // `--replace-policy` newly authorize auto-merge on a run that never had it.
1882
+ const composedForCreate = requestedPolicy !== undefined
1883
+ ? applySetupEpicUnattendedDefault(requestedPolicy, {
1884
+ featureBranch: effectiveFeatureBranch,
1885
+ attended: opts.attended,
1886
+ })
1887
+ : composeDefaultSetupEpicPolicy({
1888
+ featureBranch,
1889
+ attended: opts.attended,
1890
+ reviewPolicy: opts.reviewPolicy,
1891
+ });
1892
+ if (requestedPolicy !== undefined) {
1893
+ // The posture is stated for the FILE path too, and read off the composed
1894
+ // object rather than re-derived. The `--policy-file` invocation is the one
1895
+ // the `webhook_unverified` remedy steers operators toward, so it was the one
1896
+ // path that could create an auto-merging run without ever printing that it
1897
+ // had — the exact thing this line exists to prevent. "Complete run policy"
1898
+ // is likewise no longer said when this command added the posture to it.
1899
+ const inherited = composedForCreate !== requestedPolicy
1900
+ ? "; UNATTENDED posture with auto-merge into the epic branch composed by " +
1901
+ "default over a posture-silent file (the integration PR stays " +
1902
+ "human-gated) — pass --attended to opt out"
1903
+ : "";
1904
+ const shape = inherited === "" ? "complete run policy" : "run policy";
1905
+ say(`Policy: ${opts.policyFile} (${shape}, applied at creation${inherited})`);
1906
+ }
1462
1907
  if (effectiveFeatureBranch !== undefined && featureBranch === undefined) {
1463
1908
  say(`Feature: ${effectiveFeatureBranch} (from the policy file; cut on origin with local ` +
1464
1909
  `git at the canonical indexed commit; validated server-side at approval)`);
@@ -1505,7 +1950,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1505
1950
  else if (err instanceof ConductorBridgeApiError && err.status === 409) {
1506
1951
  deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — it is wedged, and every ` +
1507
1952
  `plan call will keep failing. Abandon the duplicate before retrying:\n` +
1508
- ` conductor abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
1953
+ ` epic-implementer abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
1509
1954
  `Detail: ${errorDetail(err)}`);
1510
1955
  return 1;
1511
1956
  }
@@ -1563,7 +2008,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1563
2008
  `'${existingBaseBranch}', not the repository base '${repositoryBase}', and ` +
1564
2009
  `--into-base asks for the repository base branch. setup-epic will not retarget ` +
1565
2010
  `an existing run. Re-run without --into-base to reuse it unchanged, or abandon ` +
1566
- `the run (conductor abandon-run --epic-run-id ${existingRunId}) and recreate ` +
2011
+ `the run (epic-implementer abandon-run --epic-run-id ${existingRunId}) and recreate ` +
1567
2012
  `it on base.`);
1568
2013
  return 1;
1569
2014
  }
@@ -1693,7 +2138,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1693
2138
  `keep failing. This is TERMINAL: it will not clear on retry, and it is ` +
1694
2139
  `NOT a problem with your plan. Adopt the run you want to keep, or ` +
1695
2140
  `abandon the duplicate:\n` +
1696
- ` conductor abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
2141
+ ` epic-implementer abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
1697
2142
  `No run was created and no automation-start charge occurred.\n` +
1698
2143
  `Detail: ${errorDetail(err)}`);
1699
2144
  return 1;
@@ -1767,7 +2212,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1767
2212
  // used for status instead). A no-feature run performs none of this.
1768
2213
  //
1769
2214
  // The scope's BASE is the repository's configured base branch (the same
1770
- // resolution `conduct-epic init` uses), NOT `policy_json.base_branch` — that
2215
+ // resolution `implement-epic init` uses), NOT `policy_json.base_branch` — that
1771
2216
  // key names the FEATURE branch. A feature branch equal to the configured base
1772
2217
  // needs no scope (dispatch already cuts from it) and matches the server's
1773
2218
  // long-standing no-op for that case.
@@ -1922,6 +2367,9 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1922
2367
  result.feature_branch = effectiveFeatureBranch;
1923
2368
  // --- Step 1: create (only when there is no live run) ---------------------
1924
2369
  if (existingRunId) {
2370
+ // BAPI-1102 — the reuse path's authoritative id, taken from the pre-check's
2371
+ // server response rather than from anything local.
2372
+ runIdSink.epicRunId = existingRunId;
1925
2373
  say(`Run: reusing ${existingRunId} (status: ${existingStatus})`);
1926
2374
  // A pre-check that already found a live run means create would answer 200
1927
2375
  // anyway; reconcile the file against that run's stored policy directly.
@@ -1941,6 +2389,58 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1941
2389
  }
1942
2390
  }
1943
2391
  else {
2392
+ // --- BAPI-1102: the pre-create unattended prerequisite gate -------------
2393
+ //
2394
+ // Applied ONLY on the create path, and that placement is the AC-3 guarantee
2395
+ // in code: a reused live run keeps its stored policy and its dispatch
2396
+ // behavior untouched, so no deploy of this ticket can refuse or re-gate a
2397
+ // run that is already going.
2398
+ //
2399
+ // Evaluated against the COMPOSED policy, so it covers the default profile and
2400
+ // a `--policy-file` alike; the UNATTENDED evaluation is skipped for
2401
+ // `--attended` and for any policy that is not unattended. (The readiness
2402
+ // read itself is no longer skipped — see BAPI-1153 below.)
2403
+ //
2404
+ // The file branch runs through `applySetupEpicUnattendedDefault` rather than
2405
+ // taking the file verbatim: a file bypasses the no-file composer entirely, so
2406
+ // a posture-SILENT file used to skip this gate and create an attended run —
2407
+ // which made the default profile unreachable by any documented invocation.
2408
+ // The composed object is what gets gated AND what gets sent, so the create
2409
+ // request carries exactly what was validated.
2410
+ //
2411
+ // BAPI-1153: ONE readiness read now serves both gates, and it happens for
2412
+ // EVERY create — attended included — because the bring-up gate below applies
2413
+ // to every run. Before any create request, manifest binding, or lane.
2414
+ {
2415
+ let readiness = null;
2416
+ try {
2417
+ readiness = await fetchConductorReadiness(access, deps.fetch);
2418
+ }
2419
+ catch {
2420
+ // Fail CLOSED, and silently as to WHY: the thrown value can carry a URL
2421
+ // or a response excerpt, and this refusal is rendered to a terminal. The
2422
+ // generic `readiness_unreadable` message says what to do about it.
2423
+ readiness = null;
2424
+ }
2425
+ // Bring-up first: a behind database or a missing worker makes the
2426
+ // unattended questions moot, and refusing on the cheaper, universal fact
2427
+ // keeps the first-named blocker the same for attended and unattended runs.
2428
+ const bringUpRefusal = evaluateSetupEpicBringUpPrerequisites(readiness);
2429
+ if (bringUpRefusal !== null) {
2430
+ deps.errorLog(`setup-epic REFUSED (${bringUpRefusal.reason}): ${bringUpRefusal.message}`);
2431
+ return 1;
2432
+ }
2433
+ if (requestedPolicyIsUnattended(composedForCreate, opts.attended)) {
2434
+ // The COMPOSED policy, not the flags: a --policy-file declaring
2435
+ // notify.webhook_url satisfies the escalation-channel prerequisite, and
2436
+ // it is the same object the create request carries.
2437
+ const refusal = evaluateSetupEpicUnattendedPrerequisites(readiness, composedForCreate);
2438
+ if (refusal !== null) {
2439
+ deps.errorLog(`setup-epic REFUSED (${refusal.reason}): ${refusal.message}`);
2440
+ return 1;
2441
+ }
2442
+ }
2443
+ }
1944
2444
  try {
1945
2445
  // Persist the confirmed feature branch as policy_json.base_branch ONLY when
1946
2446
  // one was selected; otherwise keep the exact legacy create request shape.
@@ -1951,23 +2451,27 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1951
2451
  // BAPI-754: a --policy-file supersedes that ad-hoc composition entirely —
1952
2452
  // `requestedPolicy` IS the composed result, flags included — so the whole
1953
2453
  // 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
- }
2454
+ //
2455
+ // BAPI-1102: a run that SELECTS A FEATURE BRANCH is composed unattended by
2456
+ // default, whether or not a policy file supplied the rest of the policy.
2457
+ // The gating on a feature branch being selected is the whole safety of it,
2458
+ // not an optimization — see `composeDefaultSetupEpicPolicy` and
2459
+ // `applySetupEpicUnattendedDefault`.
2460
+ //
2461
+ // This is the OBJECT THE GATE ABOVE JUST VALIDATED, not a second
2462
+ // composition of the same inputs. Recomposing here is how the two could
2463
+ // drift: the gate would pass on one policy and the server would receive
2464
+ // another.
2465
+ const policyJson = composedForCreate;
1966
2466
  const createRequest = Object.keys(policyJson).length > 0 || requestedPolicy !== undefined
1967
2467
  ? { epicKey: opts.epicKey, policyJson }
1968
2468
  : { epicKey: opts.epicKey };
1969
2469
  const { run, created } = await createEpicRunWithDisposition(access, createRequest, deps.fetch);
1970
2470
  result.epic_run_id = run.epic_run_id;
2471
+ // BAPI-1102 — the create path's authoritative id. `createEpicRunWithDisposition`
2472
+ // returns the server's run for a fresh creation AND for a create-race
2473
+ // adoption (`created: false`), so both are captured by this one assignment.
2474
+ runIdSink.epicRunId = run.epic_run_id;
1971
2475
  result.status = run.status;
1972
2476
  result.run_created = created;
1973
2477
  if (created) {
@@ -1990,7 +2494,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1990
2494
  say(`Run: adopting existing active run ${run.epic_run_id} (status: ${run.status})`);
1991
2495
  say(` No second run was created — one epic has at most one active ` +
1992
2496
  `run. Continue on this one, or abandon it first with ` +
1993
- `\`conductor abandon-run --epic-run-id ${run.epic_run_id}\` to start over.`);
2497
+ `\`epic-implementer abandon-run --epic-run-id ${run.epic_run_id}\` to start over.`);
1994
2498
  if (requestedPolicy !== undefined) {
1995
2499
  const outcome = await applyReusedRunPolicy({
1996
2500
  access,
@@ -2056,7 +2560,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
2056
2560
  else {
2057
2561
  const msg = `Could not bind the local plane to run ${result.epic_run_id} (${bound.message}). ` +
2058
2562
  "A later `plane down` cannot be guaranteed to stop it automatically — if you need " +
2059
- `to stop this run, run \`conductor stop-run --epic-run-id ${result.epic_run_id}\`.`;
2563
+ `to stop this run, run \`epic-implementer stop-run --epic-run-id ${result.epic_run_id}\`.`;
2060
2564
  warnings.push(msg);
2061
2565
  say(`Plane: [warn] ${msg}`);
2062
2566
  }
@@ -2076,7 +2580,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
2076
2580
  const msg = `A local plane manifest exists but could not be validated (${manifestRead.error}), so ` +
2077
2581
  `run ${result.epic_run_id} was NOT bound to it. If a plane is running, \`plane down\` ` +
2078
2582
  "will not stop this run automatically — use " +
2079
- `\`conductor stop-run --epic-run-id ${result.epic_run_id}\`. A manifest written by a ` +
2583
+ `\`epic-implementer stop-run --epic-run-id ${result.epic_run_id}\`. A manifest written by a ` +
2080
2584
  "NEWER build must be wound down with that build's `plane down`.";
2081
2585
  warnings.push(msg);
2082
2586
  say(`Plane: [warn] ${msg}`);
@@ -2087,7 +2591,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
2087
2591
  // an unexpected error here is reported as a warning, never thrown.
2088
2592
  const msg = `Could not check for a local plane to bind run ${result.epic_run_id} to (${errorDetail(err)}). ` +
2089
2593
  "A later `plane down` cannot be guaranteed to stop it automatically — if you need to stop " +
2090
- `this run, run \`conductor stop-run --epic-run-id ${result.epic_run_id}\`.`;
2594
+ `this run, run \`epic-implementer stop-run --epic-run-id ${result.epic_run_id}\`.`;
2091
2595
  warnings.push(msg);
2092
2596
  say(`Plane: [warn] ${msg}`);
2093
2597
  }
@@ -2096,7 +2600,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
2096
2600
  // AFTER the run exists and BEFORE the plan is stored/approved: the scope's run
2097
2601
  // association is immutable server-side, so the cut is driven with this run's
2098
2602
  // id and the scope is minted owned by it. Everything from `cut/begin` to the
2099
- // hold's release is the SHARED protocol `conduct-epic init` drives — local git
2603
+ // hold's release is the SHARED protocol `implement-epic init` drives — local git
2100
2604
  // only, an exact-SHA create-only push, never the GitHub App, never a force
2101
2605
  // update. A failure leaves the run non-active (never discoverable by the
2102
2606
  // reconciler) and re-running setup-epic reuses it and re-drives the cut.
@@ -2203,6 +2707,25 @@ export async function runSetupEpicCli(argv, overrides = {}) {
2203
2707
  `dispatch has NOT started. Correct repository access or the branch ` +
2204
2708
  `configuration, then re-run setup-epic.\nDetail: ${errorDetail(err)}`);
2205
2709
  }
2710
+ else if (err instanceof ConductorBridgeApiError &&
2711
+ err.errorCode === SETUP_EPIC_UNATTENDED_POSTURE_REFUSED_CODE) {
2712
+ // BAPI-1102 — the one approval refusal with a canonical, non-obvious
2713
+ // remedy. Consent is read at BOTH boundaries, so a repository whose consent
2714
+ // was revoked between creation and approval refuses HERE, with the run left
2715
+ // exactly as it was: created, planning, unapproved, no activation, no
2716
+ // executor dispatch, and reusable once consent is restored.
2717
+ //
2718
+ // Named separately from the generic approval failure because the generic
2719
+ // message ("Failed to approve the plan: …") tells an operator to look at the
2720
+ // plan, which is the wrong place entirely.
2721
+ deps.errorLog(`The plan was NOT approved: this repository is no longer authorized for ` +
2722
+ `unattended runs. The run WAS created and is left in 'planning' with no ` +
2723
+ `activation and no executor dispatch — nothing was half-applied.\n` +
2724
+ `Restore \`unattended_epic_implementer_allowed\` for this repository (see ` +
2725
+ `docs/claude/account-settings-operator-runbook.md) and re-run setup-epic, ` +
2726
+ `or deliberately convert this run to an attended posture through the ` +
2727
+ `supported operator flow.\nDetail: ${errorDetail(err)}`);
2728
+ }
2206
2729
  else {
2207
2730
  deps.errorLog(`Failed to approve the plan: ${errorDetail(err)}`);
2208
2731
  }
@@ -2369,8 +2892,14 @@ export async function runSetupEpicCli(argv, overrides = {}) {
2369
2892
  else {
2370
2893
  say("The server-side reconciler will pick it up within ~30s.");
2371
2894
  }
2372
- say("To execute claimed jobs on this machine, run:");
2373
- say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName}`);
2895
+ // BAPI-1102 the executor command is printed WITH a claim scope. Since
2896
+ // BAPI-1026 an executor started with no scope REFUSES TO START, so the
2897
+ // unscoped form printed here was guidance that could not work: an operator
2898
+ // who copied it got a startup error, and the natural reading of that error is
2899
+ // that the run is broken rather than that the instruction was.
2900
+ say("To execute this run's claimed jobs on this machine, run:");
2901
+ say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName} ` +
2902
+ `--epic-run-id ${result.epic_run_id}`);
2374
2903
  }
2375
2904
  return exitCode;
2376
2905
  }