@bridge_gpt/mcp-server 0.2.34 → 0.2.37

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 (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. package/build/conductor/supervisor-judgment.js +0 -215
@@ -8,26 +8,43 @@
8
8
  *
9
9
  * Ordering is load-bearing:
10
10
  *
11
- * 0. GET the run state FIRST. `POST /epic-runs/runs` cannot be probed with a
12
- * "create and tolerate 409" it never returned 409, so a blind repeat
13
- * create used to mint a SECOND active run, wedging the epic permanently
14
- * (every later plan call 409s on "Multiple active runs") and double-charging
15
- * billing. The server is idempotent now, but the pre-check is still what
16
- * lets us report honestly and skip work.
17
- * 1. Create only when no live run exists.
18
- * 2. Hash locally — ADVISORY ONLY. The server re-hashes after applying
11
+ * 0. Local sidecar checks. Cheap, offline, and they produce a legible message
12
+ * naming the offending node instead of a bare HTTP 400.
13
+ * 1. GET the run state. `POST /epic-runs/runs` cannot be probed with a "create
14
+ * and tolerate 409" it never returned 409, so a blind repeat create used to
15
+ * mint a SECOND active run, wedging the epic permanently (every later plan
16
+ * call 409s on "Multiple active runs") and double-charging billing. The
17
+ * server is idempotent now, but the pre-check is still what lets us report
18
+ * honestly and skip work.
19
+ * 2. SERVER validation (BAPI-754). The local checks in step 0 are a mirror of
20
+ * the server's rules, and a mirror drifts — it had already lost
21
+ * `nodes[].status`. Creating first meant a plan the server would refuse
22
+ * still cost a run row and an automation-start debit before anything
23
+ * noticed. So the authoritative validator runs BEFORE any create, and the
24
+ * only sanctioned exception is an older server that lacks the lane (404),
25
+ * which warns and falls back to the legacy order.
26
+ * 3. Create only when no live run exists, carrying the complete policy from
27
+ * `--policy-file` so no post-create PATCH is needed. A create that returns
28
+ * 200 reused a live run and IGNORED that policy — reconcile, do not assume.
29
+ * 4. Hash locally — ADVISORY ONLY. The server re-hashes after applying
19
30
  * file-overlap serialization, so a divergence is legitimate, not corruption.
20
- * 3. Store, then approve.
31
+ * 5. Store, then approve.
21
32
  *
22
- * Read-only against the local filesystem: it reads the plan sidecar and writes
23
- * nothing.
33
+ * Read-only against the local filesystem: it reads the plan sidecar and the
34
+ * optional policy file, and writes nothing.
24
35
  */
25
36
  import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
26
37
  import os from "node:os";
27
38
  import readline from "node:readline";
28
- import { approveEpicPlan, createEpicRun, fetchEpicRunState, resolveConductorBridgeApiAccess, storeEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
39
+ import { approveEpicPlan, createEpicRunWithDisposition, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
29
40
  import { validateBranchName } from "./base-ref.js";
30
41
  import { hashPlan } from "./conductor/plan.js";
42
+ /** Accepted `policy_json.review_policy.source` values (the `ReviewPolicy` surface). */
43
+ export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
44
+ "verdict_protocol",
45
+ "native_review_decision",
46
+ "none",
47
+ ];
31
48
  /** Echoed single-line prompt on stderr (mirrors connect-github's helper). */
32
49
  function defaultPromptLine(promptText) {
33
50
  return new Promise((resolve) => {
@@ -82,10 +99,37 @@ export function getSetupEpicUsage() {
82
99
  " on origin, and every child-ticket PR targets it.",
83
100
  " Omit (the default) to continue on the repository base",
84
101
  " branch. Interactive runs are offered a proposal.",
85
- " --dry-run Validate and preview; make no mutating calls",
102
+ " --review-policy <src> PER-RUN review policy source, one of:",
103
+ ` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
104
+ " Composed into policy_json.review_policy on create.",
105
+ " This setting is per-run: repository-level review-policy",
106
+ " defaults are NOT persisted in supervisor project",
107
+ " defaults yet — that is BAPI-694.",
86
108
  " --json Emit a single JSON result object on stdout",
87
109
  " -h, --help Show this help",
88
110
  "",
111
+ "Policy controls:",
112
+ " --policy-file <path> JSON file holding the COMPLETE run policy. Applied in",
113
+ " the same invocation that creates the run, so no",
114
+ " post-create PATCH is needed. Contradicts nothing",
115
+ " silently: if the file and --feature-branch or",
116
+ " --review-policy disagree, setup-epic errors naming both.",
117
+ " --replace-policy Authorize replacing a LIVE run's stored policy with the",
118
+ " policy file, as a complete replacement. Without it, a",
119
+ " divergent policy on a reused run is refused with a",
120
+ " redacted diff rather than applied. Requires",
121
+ " --policy-file.",
122
+ "",
123
+ "Validation controls:",
124
+ " --dry-run Run the SERVER's real plan validator (including",
125
+ " file-overlap serialization) and stop. Creates no run,",
126
+ " stores no plan, and incurs no automation-start charge.",
127
+ " --local-only Skip server validation and run only the local sidecar",
128
+ " checks. Valid ONLY with --dry-run, and the result is",
129
+ " labeled 'partial (local checks only)': the local checks",
130
+ " do not cover everything the server enforces, so this is",
131
+ " never a substitute for authoritative validation.",
132
+ "",
89
133
  "After setup, the server-side reconciler picks the run up within ~30s.",
90
134
  "To execute claimed jobs on this machine, run:",
91
135
  " npx -y @bridge_gpt/mcp-server executor --repo <name>",
@@ -115,6 +159,25 @@ function parseFeatureBranchValue(raw) {
115
159
  return { ok: false, error: `Invalid --feature-branch value: ${reason}` };
116
160
  return { ok: true, value: trimmed };
117
161
  }
162
+ /**
163
+ * Validate a `--review-policy` value against the existing `ReviewPolicy`
164
+ * vocabulary. Rejects BEFORE any network access so an unknown source can never
165
+ * reach a create request (BAPI-679). An explicitly blank value normalizes to
166
+ * absent, matching `--feature-branch`.
167
+ */
168
+ function parseReviewPolicyValue(raw) {
169
+ const trimmed = raw.trim();
170
+ if (trimmed === "")
171
+ return { ok: true, value: undefined };
172
+ if (!SETUP_EPIC_REVIEW_POLICY_SOURCES.includes(trimmed)) {
173
+ return {
174
+ ok: false,
175
+ error: `Invalid --review-policy value '${trimmed}'. Expected one of: ` +
176
+ SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", "),
177
+ };
178
+ }
179
+ return { ok: true, value: trimmed };
180
+ }
118
181
  export function parseSetupEpicArgs(argv) {
119
182
  if (argv.includes("-h") || argv.includes("--help")) {
120
183
  return { status: "help", usage: getSetupEpicUsage() };
@@ -124,10 +187,22 @@ export function parseSetupEpicArgs(argv) {
124
187
  let repo;
125
188
  let planVersion;
126
189
  let featureBranch;
190
+ let reviewPolicy;
191
+ let policyFile;
192
+ let replacePolicy = false;
193
+ let localOnly = false;
127
194
  let dryRun = false;
128
195
  let json = false;
129
196
  for (let i = 0; i < argv.length; i++) {
130
197
  const arg = argv[i];
198
+ // `--policy-file=<path>` inline form (BAPI-754).
199
+ if (arg.startsWith("--policy-file=")) {
200
+ const raw = arg.slice("--policy-file=".length).trim();
201
+ if (raw === "")
202
+ return { status: "error", message: "--policy-file requires a value." };
203
+ policyFile = raw;
204
+ continue;
205
+ }
131
206
  // `--feature-branch=<name>` inline form (handled before the exact-match switch).
132
207
  if (arg.startsWith("--feature-branch=")) {
133
208
  const parsedFb = parseFeatureBranchValue(arg.slice("--feature-branch=".length));
@@ -136,6 +211,14 @@ export function parseSetupEpicArgs(argv) {
136
211
  featureBranch = parsedFb.value;
137
212
  continue;
138
213
  }
214
+ // `--review-policy=<source>` inline form (BAPI-679).
215
+ if (arg.startsWith("--review-policy=")) {
216
+ const parsedRp = parseReviewPolicyValue(arg.slice("--review-policy=".length));
217
+ if (!parsedRp.ok)
218
+ return { status: "error", message: parsedRp.error };
219
+ reviewPolicy = parsedRp.value;
220
+ continue;
221
+ }
139
222
  switch (arg) {
140
223
  case "--feature-branch": {
141
224
  // Do not consume a following flag as the value (Step 2.5).
@@ -149,6 +232,17 @@ export function parseSetupEpicArgs(argv) {
149
232
  i++;
150
233
  break;
151
234
  }
235
+ case "--review-policy": {
236
+ const v = takeValue(argv, i, arg);
237
+ if (v === null)
238
+ return { status: "error", message: "--review-policy requires a value." };
239
+ const parsedRp = parseReviewPolicyValue(v);
240
+ if (!parsedRp.ok)
241
+ return { status: "error", message: parsedRp.error };
242
+ reviewPolicy = parsedRp.value;
243
+ i++;
244
+ break;
245
+ }
152
246
  case "--epic-key": {
153
247
  const v = takeValue(argv, i, arg);
154
248
  if (v === null)
@@ -187,6 +281,23 @@ export function parseSetupEpicArgs(argv) {
187
281
  i++;
188
282
  break;
189
283
  }
284
+ case "--policy-file": {
285
+ const v = takeValue(argv, i, arg);
286
+ if (v === null)
287
+ return { status: "error", message: "--policy-file requires a value." };
288
+ policyFile = v.trim();
289
+ if (policyFile === "") {
290
+ return { status: "error", message: "--policy-file requires a value." };
291
+ }
292
+ i++;
293
+ break;
294
+ }
295
+ case "--replace-policy":
296
+ replacePolicy = true;
297
+ break;
298
+ case "--local-only":
299
+ localOnly = true;
300
+ break;
190
301
  case "--dry-run":
191
302
  dryRun = true;
192
303
  break;
@@ -204,10 +315,305 @@ export function parseSetupEpicArgs(argv) {
204
315
  return { status: "error", message: "setup-epic requires --epic-key <KEY>." };
205
316
  if (!planFile)
206
317
  return { status: "error", message: "setup-epic requires --plan-file <path>." };
318
+ // BAPI-754 — reject contradictory combinations HERE, before any file read,
319
+ // credential resolution, or network call, so a mistyped invocation cannot reach
320
+ // a mutating request.
321
+ if (localOnly && !dryRun) {
322
+ return {
323
+ status: "error",
324
+ message: "--local-only is only valid together with --dry-run. It downgrades the run to " +
325
+ "local checks only, which is never a substitute for the server's validation " +
326
+ "on a real setup.",
327
+ };
328
+ }
329
+ if (replacePolicy && policyFile === undefined) {
330
+ return {
331
+ status: "error",
332
+ message: "--replace-policy requires --policy-file. It authorizes replacing a live run's " +
333
+ "stored policy with the file's contents, so without a file there is nothing to " +
334
+ "replace it with.",
335
+ };
336
+ }
207
337
  return {
208
338
  status: "ok",
209
- options: { epicKey, planFile, repo, planVersion, featureBranch, dryRun, json },
339
+ options: {
340
+ epicKey,
341
+ planFile,
342
+ repo,
343
+ planVersion,
344
+ featureBranch,
345
+ reviewPolicy,
346
+ policyFile,
347
+ replacePolicy,
348
+ localOnly,
349
+ dryRun,
350
+ json,
351
+ },
352
+ };
353
+ }
354
+ /**
355
+ * Read and parse the `--policy-file` JSON.
356
+ *
357
+ * Every failure names the PATH and the failing action and nothing else. The file
358
+ * can carry `notify.webhook_url`, so echoing its contents — or even a JSON parse
359
+ * error's excerpt of them — into a terminal or a CI log would defeat the whole
360
+ * redaction discipline downstream. A caller who needs to see the file can open it.
361
+ */
362
+ export async function readSetupEpicPolicyFile(filePath, readFile) {
363
+ let raw;
364
+ try {
365
+ raw = await readFile(filePath);
366
+ }
367
+ catch {
368
+ return { ok: false, error: `Could not read policy file '${filePath}'.` };
369
+ }
370
+ let parsed;
371
+ try {
372
+ parsed = JSON.parse(raw);
373
+ }
374
+ catch {
375
+ return {
376
+ ok: false,
377
+ error: `Policy file '${filePath}' is not valid JSON.`,
378
+ };
379
+ }
380
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
381
+ return {
382
+ ok: false,
383
+ error: `Policy file '${filePath}' must contain a JSON object (the run policy).`,
384
+ };
385
+ }
386
+ return { ok: true, policy: parsed };
387
+ }
388
+ /** Read `review_policy.source` from a policy object, or a structural verdict. */
389
+ function readReviewPolicySource(policy) {
390
+ if (!("review_policy" in policy))
391
+ return { kind: "absent" };
392
+ const block = policy["review_policy"];
393
+ if (block === undefined || block === null)
394
+ return { kind: "absent" };
395
+ if (typeof block !== "object" || Array.isArray(block))
396
+ return { kind: "ambiguous" };
397
+ const source = block["source"];
398
+ if (source === undefined || source === null)
399
+ return { kind: "absent" };
400
+ if (typeof source !== "string" || source.trim() === "")
401
+ return { kind: "ambiguous" };
402
+ return { kind: "source", value: source };
403
+ }
404
+ /**
405
+ * Compose the `--feature-branch` and `--review-policy` selections onto the policy
406
+ * file's object.
407
+ *
408
+ * The rule is "no silent winner". Where the file is silent, the CLI selection
409
+ * fills the gap. Where both speak and AGREE, the file's existing spelling is kept
410
+ * untouched — adding a second spelling of the same setting would leave the server
411
+ * with two keys claiming one truth. Where both speak and DISAGREE, this fails with
412
+ * an error naming both sources, because either choice would mean running an epic
413
+ * under a policy the operator did not pick and could not see us pick.
414
+ *
415
+ * Unknown keys and wrong-typed values are passed through EXACTLY as written. The
416
+ * authoritative `RunPolicy` schema lives on the server, and re-implementing it here
417
+ * is how the two drift; the server's field-named `422` is the better error anyway.
418
+ */
419
+ export function composeSetupEpicPolicy(filePolicy, selections) {
420
+ const composed = { ...filePolicy };
421
+ // `baseBranch` is not a key the server accepts — it is the camelCase slip an
422
+ // author makes when hand-writing the file. Treat it as a DECLARATION for
423
+ // contradiction purposes so a conflicting value is reported rather than
424
+ // silently ignored, but never invent it and never rewrite it into snake_case:
425
+ // the server's strict boundary should be the one to reject it.
426
+ const declaredBranches = [];
427
+ for (const key of ["base_branch", "baseBranch"]) {
428
+ if (key in composed && composed[key] !== undefined && composed[key] !== null) {
429
+ declaredBranches.push({ key, value: composed[key] });
430
+ }
431
+ }
432
+ if (selections.featureBranch !== undefined) {
433
+ const conflicting = declaredBranches.filter((entry) => entry.value !== selections.featureBranch);
434
+ if (conflicting.length > 0) {
435
+ const names = conflicting.map((entry) => entry.key).join(" and ");
436
+ return {
437
+ ok: false,
438
+ error: `--feature-branch selected '${selections.featureBranch}', but --policy-file ` +
439
+ `declares a different branch under ${names}. setup-epic will not pick a ` +
440
+ `winner between them: drop the flag, or fix the policy file so both agree.`,
441
+ };
442
+ }
443
+ if (declaredBranches.length === 0)
444
+ composed.base_branch = selections.featureBranch;
445
+ }
446
+ const fileReview = readReviewPolicySource(composed);
447
+ if (selections.reviewPolicy !== undefined) {
448
+ if (fileReview.kind === "ambiguous") {
449
+ return {
450
+ ok: false,
451
+ error: `--review-policy selected '${selections.reviewPolicy}', but --policy-file's ` +
452
+ `review_policy is not an object with a non-empty string 'source'. setup-epic ` +
453
+ `will not guess which one you meant: drop the flag, or fix review_policy in ` +
454
+ `the policy file.`,
455
+ };
456
+ }
457
+ if (fileReview.kind === "source" && fileReview.value !== selections.reviewPolicy) {
458
+ return {
459
+ ok: false,
460
+ error: `--review-policy selected '${selections.reviewPolicy}', but --policy-file ` +
461
+ `declares review_policy.source '${fileReview.value}'. setup-epic will not pick ` +
462
+ `a winner between them: drop the flag, or fix the policy file so both agree.`,
463
+ };
464
+ }
465
+ if (fileReview.kind === "absent") {
466
+ const existing = composed["review_policy"];
467
+ composed.review_policy =
468
+ existing && typeof existing === "object" && !Array.isArray(existing)
469
+ ? { ...existing, source: selections.reviewPolicy }
470
+ : { source: selections.reviewPolicy };
471
+ }
472
+ }
473
+ return { ok: true, policy: composed };
474
+ }
475
+ /**
476
+ * Does this policy object explicitly supply the `notify.webhook_url` leaf?
477
+ *
478
+ * Explicit `null` counts — that is the documented way to CLEAR a stored URL, so it
479
+ * is a real change even though it looks like an absence.
480
+ */
481
+ export function policySuppliesWebhookUrl(policy) {
482
+ const notify = policy["notify"];
483
+ if (!notify || typeof notify !== "object" || Array.isArray(notify))
484
+ return false;
485
+ return "webhook_url" in notify;
486
+ }
487
+ /**
488
+ * Project a policy into the shape both sides of a comparison can actually see.
489
+ *
490
+ * Two keys are removed from every operand:
491
+ *
492
+ * - `notify.webhook_url` — never present in a server response, so comparing it
493
+ * would report a difference on every run that has one. The empty `notify` object
494
+ * is KEPT after removal, mirroring the server's `_redacted_policy_json`, so a
495
+ * policy that declares an empty notify block still compares equal to itself.
496
+ * - `feature_branch_provisioning` — server-minted provenance. It appears on the
497
+ * stored side and never on the requested side, and re-sending it is a conflict
498
+ * the server rejects outright.
499
+ *
500
+ * Returns a deep copy; both operands are left unmodified.
501
+ */
502
+ export function projectPolicyForComparison(policy) {
503
+ if (Array.isArray(policy))
504
+ return policy.map((item) => projectPolicyForComparison(item));
505
+ if (!policy || typeof policy !== "object")
506
+ return policy;
507
+ const out = {};
508
+ for (const [key, value] of Object.entries(policy)) {
509
+ if (key === "feature_branch_provisioning")
510
+ continue;
511
+ if (key === "notify" && value && typeof value === "object" && !Array.isArray(value)) {
512
+ const notify = {};
513
+ for (const [nKey, nValue] of Object.entries(value)) {
514
+ if (nKey === "webhook_url")
515
+ continue;
516
+ notify[nKey] = projectPolicyForComparison(nValue);
517
+ }
518
+ out.notify = notify;
519
+ continue;
520
+ }
521
+ out[key] = projectPolicyForComparison(value);
522
+ }
523
+ return out;
524
+ }
525
+ /** Render a value for a diff line, bounded so a large nested block stays readable. */
526
+ function renderDiffValue(value) {
527
+ const text = JSON.stringify(value ?? null);
528
+ return text.length > 120 ? `${text.slice(0, 120)}…` : text;
529
+ }
530
+ /**
531
+ * Compare two PROJECTED policies and return a deterministic structural diff.
532
+ *
533
+ * Ordering is by sorted path, not by insertion order, so two files that differ
534
+ * only in key order produce an identical (empty) diff and a real difference always
535
+ * renders in the same place.
536
+ *
537
+ * This function must only ever be handed projected operands — `webhook_url` has
538
+ * already been removed by then, so no diff line can carry it. That ordering is the
539
+ * safety property: the URL is never rendered and then masked, it is never present.
540
+ */
541
+ export function diffProjectedPolicies(stored, requested, basePath = "") {
542
+ const isPlainObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
543
+ if (isPlainObject(stored) && isPlainObject(requested)) {
544
+ const keys = [...new Set([...Object.keys(stored), ...Object.keys(requested)])].sort();
545
+ const entries = [];
546
+ for (const key of keys) {
547
+ const path = basePath ? `${basePath}.${key}` : key;
548
+ const inStored = key in stored;
549
+ const inRequested = key in requested;
550
+ if (inStored && !inRequested) {
551
+ entries.push({ path, kind: "removed" });
552
+ }
553
+ else if (!inStored && inRequested) {
554
+ entries.push({ path, kind: "added" });
555
+ }
556
+ else {
557
+ entries.push(...diffProjectedPolicies(stored[key], requested[key], path));
558
+ }
559
+ }
560
+ return entries;
561
+ }
562
+ if (JSON.stringify(stored ?? null) === JSON.stringify(requested ?? null))
563
+ return [];
564
+ return [{ path: basePath || "(policy)", kind: "changed" }];
565
+ }
566
+ /** Render a diff as operator-readable lines. Never receives a webhook URL. */
567
+ export function renderPolicyDiff(entries, stored, requested) {
568
+ const read = (source, path) => {
569
+ let cursor = source;
570
+ for (const part of path.split(".")) {
571
+ if (!cursor || typeof cursor !== "object")
572
+ return undefined;
573
+ cursor = cursor[part];
574
+ }
575
+ return cursor;
210
576
  };
577
+ return entries.map((entry) => {
578
+ if (entry.kind === "added") {
579
+ return ` + ${entry.path}: ${renderDiffValue(read(requested, entry.path))} (not in the stored policy)`;
580
+ }
581
+ if (entry.kind === "removed") {
582
+ return ` - ${entry.path}: ${renderDiffValue(read(stored, entry.path))} (absent from the policy file)`;
583
+ }
584
+ return (` ~ ${entry.path}: stored ${renderDiffValue(read(stored, entry.path))} ` +
585
+ `→ file ${renderDiffValue(read(requested, entry.path))}`);
586
+ });
587
+ }
588
+ /**
589
+ * The one permitted rendering of a supplied webhook URL. The value NEVER appears.
590
+ *
591
+ * The stored URL is invisible to a client by design, so a file that supplies one
592
+ * cannot be compared against it. Rather than report "no change" on a policy we
593
+ * cannot see, this is counted as divergence and described in words.
594
+ */
595
+ export const WEBHOOK_SUPPLIED_DIFF_LINE = " ~ notify.webhook_url: (supplied — will replace stored value on apply)";
596
+ /**
597
+ * Decide what to do with a policy file when create returned an EXISTING run.
598
+ *
599
+ * Create is idempotent: a `200` means the server kept the live run and ignored the
600
+ * policy we sent. Silently continuing would leave the operator believing their
601
+ * file is in force when it is not — so the divergence is surfaced, and applying it
602
+ * requires `--replace-policy` because a mid-run policy change is a real decision
603
+ * about a run that is already executing tickets.
604
+ */
605
+ export function reconcileReusedRunPolicy(args) {
606
+ const storedProjected = projectPolicyForComparison(args.storedPolicy ?? {});
607
+ const requestedProjected = projectPolicyForComparison(args.requestedPolicy);
608
+ const diffLines = renderPolicyDiff(diffProjectedPolicies(storedProjected, requestedProjected), storedProjected, requestedProjected);
609
+ if (policySuppliesWebhookUrl(args.requestedPolicy)) {
610
+ diffLines.push(WEBHOOK_SUPPLIED_DIFF_LINE);
611
+ }
612
+ if (diffLines.length === 0)
613
+ return { kind: "noop" };
614
+ return args.replacePolicy
615
+ ? { kind: "replace", diffLines }
616
+ : { kind: "refused_divergent", diffLines };
211
617
  }
212
618
  /**
213
619
  * Validate the plan DAG locally, mirroring the server's `validate_epic_plan_dag`.
@@ -246,11 +652,19 @@ export function validateEpicPlanSidecar(parsed) {
246
652
  return { ok: false, error: `Duplicate ticket_key in plan: ${key}.` };
247
653
  keys.add(key);
248
654
  if (node.touched_files === undefined) {
249
- // Only fatal when the repo has file-overlap serialization enabled, in
250
- // which case the server 400s. Warn either way it is the difference
251
- // between siblings serializing and colliding.
252
- warnings.push(`Node ${key} has no touched_files. File-overlap serialization cannot ` +
253
- `protect it; if the repo has that flag on, the server will reject this plan.`);
655
+ // BAPI-722: the server ACCEPTS an omitted touched_files it is an explicit
656
+ // opt-out of preemptive overlap serialization, not a rejection. This stays a
657
+ // warning because it is a genuine planning-QUALITY signal (an undeclared node
658
+ // is never pre-serialized against its siblings, so it relies entirely on the
659
+ // reactive merge-conflict/rebase lane), but it must not claim a hard failure
660
+ // the server will not produce — a false hard-rejection warning trains authors
661
+ // to distrust the whole validator. Reported for EVERY offending node in one
662
+ // pass rather than stopping at the first.
663
+ warnings.push(`Node ${key} has no touched_files, so it opts out of preemptive ` +
664
+ `file-overlap serialization: the server accepts the plan but will not ` +
665
+ `pre-serialize ${key} against overlapping siblings (a conflict would ` +
666
+ `instead be caught reactively and rebased). Add touched_files to ${key} ` +
667
+ `to get that scheduling protection (use [] when no files are predicted).`);
254
668
  }
255
669
  }
256
670
  // Build one adjacency in a consistent predecessor -> successor direction, the
@@ -339,15 +753,20 @@ function proposeFeatureBranchName(epicKey) {
339
753
  * parse time and is returned unchanged (no prompt).
340
754
  * - A non-interactive (piped/CI) run or a `--json` run never prompts and never
341
755
  * blocks — it retains the absent-branch behavior.
756
+ * - A `--policy-file` that already declares a branch has ALREADY answered this
757
+ * question (BAPI-754). Prompting anyway would invite an operator to type a
758
+ * competing value that composition would then have to refuse.
342
759
  * - An interactive run is shown the proposal (branch name, "create from the
343
760
  * repository base branch" strategy, and the child-PR-targeting consequence)
344
761
  * and may accept it, type a custom name, or decline to use the base branch.
345
762
  * An edited nonblank value is re-validated; an invalid value is redisplayed
346
763
  * for correction rather than silently rewritten.
347
764
  */
348
- async function resolveFeatureBranchSelection(opts, repoName, deps) {
765
+ async function resolveFeatureBranchSelection(opts, repoName, deps, policyDeclaresBranch = false) {
349
766
  if (opts.featureBranch !== undefined)
350
767
  return opts.featureBranch;
768
+ if (policyDeclaresBranch)
769
+ return undefined;
351
770
  if (!deps.isTTY || opts.json)
352
771
  return undefined;
353
772
  const proposed = proposeFeatureBranchName(opts.epicKey);
@@ -382,6 +801,64 @@ function errorDetail(err) {
382
801
  }
383
802
  return err instanceof Error ? err.message : String(err);
384
803
  }
804
+ /**
805
+ * Reconcile a policy file against a REUSED run's stored policy, applying it only
806
+ * under `--replace-policy`.
807
+ *
808
+ * Returns `true` to continue the workflow (`noop` or a successful `replaced`) and
809
+ * `false` when the run's policy diverges and was left alone — the caller then
810
+ * stops, because storing and approving a plan under a policy the operator did not
811
+ * choose is worse than doing nothing.
812
+ *
813
+ * Mutates `result.policy_applied` so the JSON summary reports the same
814
+ * disposition the human output describes.
815
+ */
816
+ async function applyReusedRunPolicy(args) {
817
+ const outcome = reconcileReusedRunPolicy({
818
+ storedPolicy: args.storedPolicy,
819
+ requestedPolicy: args.requestedPolicy,
820
+ replacePolicy: args.replacePolicy,
821
+ });
822
+ if (outcome.kind === "noop") {
823
+ args.result.policy_applied = "noop";
824
+ args.say("Policy: policy unchanged");
825
+ return true;
826
+ }
827
+ if (outcome.kind === "refused_divergent") {
828
+ args.result.policy_applied = "refused_divergent";
829
+ args.deps.errorLog(`Epic run ${args.epicRunId} is live and its stored policy differs from the ` +
830
+ `policy file. setup-epic will not silently change a running epic's policy.\n` +
831
+ `${outcome.diffLines.join("\n")}\n` +
832
+ `Re-run with --replace-policy to apply the file as the complete replacement, ` +
833
+ `or drop --policy-file to reuse the run unchanged.`);
834
+ return false;
835
+ }
836
+ try {
837
+ await replaceEpicRunPolicy(args.access, { epicRunId: args.epicRunId, policyJson: args.requestedPolicy }, args.deps.fetch);
838
+ }
839
+ catch (err) {
840
+ args.result.policy_applied = "refused_divergent";
841
+ args.deps.errorLog(`Failed to replace the run policy: ${errorDetail(err)}`);
842
+ return false;
843
+ }
844
+ args.result.policy_applied = "replaced";
845
+ args.say("Policy: policy applied: replaced");
846
+ for (const line of outcome.diffLines)
847
+ args.say(line);
848
+ return true;
849
+ }
850
+ /**
851
+ * Emit the post-access refusal summary and the non-zero exit status.
852
+ *
853
+ * A refusal happens after the run state is known, so `--json` consumers still get
854
+ * the canonical result object — `policy_applied: "refused_divergent"` is a real
855
+ * outcome to record, not an absence of one.
856
+ */
857
+ function emitRefusal(deps, opts, result) {
858
+ if (opts.json)
859
+ deps.log(JSON.stringify(result, null, 2));
860
+ return 1;
861
+ }
385
862
  export async function runSetupEpicCli(argv, overrides = {}) {
386
863
  const deps = { ...createDefaultSetupEpicDeps(), ...overrides };
387
864
  const parsed = parseSetupEpicArgs(argv);
@@ -429,6 +906,60 @@ export async function runSetupEpicCli(argv, overrides = {}) {
429
906
  return 1;
430
907
  }
431
908
  const localHash = hashPlan(plan);
909
+ // --- Read the policy file (still no network) ------------------------------
910
+ // BAPI-754: parsed here so a malformed file, or a contradiction between the
911
+ // file and the CLI flags, fails before credentials are resolved or any request
912
+ // is dispatched.
913
+ let filePolicy;
914
+ if (opts.policyFile !== undefined) {
915
+ const policyRead = await readSetupEpicPolicyFile(opts.policyFile, deps.readFile);
916
+ if (!policyRead.ok) {
917
+ deps.errorLog(policyRead.error);
918
+ return 1;
919
+ }
920
+ filePolicy = policyRead.policy;
921
+ }
922
+ const policyDeclaresBranch = filePolicy !== undefined &&
923
+ ["base_branch", "baseBranch"].some((key) => key in filePolicy && filePolicy[key] !== undefined && filePolicy[key] !== null);
924
+ // --- --local-only --dry-run: stop here, offline and explicitly partial ----
925
+ // Nothing below this point is reachable without the network, and the whole
926
+ // point of the flag is to not touch it — not even to resolve a credential.
927
+ if (opts.localOnly) {
928
+ if (filePolicy !== undefined) {
929
+ const composed = composeSetupEpicPolicy(filePolicy, {
930
+ featureBranch: opts.featureBranch,
931
+ reviewPolicy: opts.reviewPolicy,
932
+ });
933
+ if (!composed.ok) {
934
+ deps.errorLog(composed.error);
935
+ return 1;
936
+ }
937
+ }
938
+ say(`Epic: ${opts.epicKey}`);
939
+ say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
940
+ say(`Local hash: ${localHash}`);
941
+ for (const w of warnings)
942
+ say(` [warn] ${w}`);
943
+ say("");
944
+ say("Result: partial (local checks only)");
945
+ say(" The server's plan validator did NOT run, so this says nothing " +
946
+ "about whether the server would accept this plan. Re-run without " +
947
+ "--local-only for authoritative validation.");
948
+ if (opts.json) {
949
+ deps.log(JSON.stringify({
950
+ dry_run: true,
951
+ local_only: true,
952
+ result: "partial (local checks only)",
953
+ epic_key: opts.epicKey,
954
+ plan_version: plan.plan_version,
955
+ local_plan_hash: localHash,
956
+ server_validated: false,
957
+ policy_applied: "absent",
958
+ warnings,
959
+ }, null, 2));
960
+ }
961
+ return 0;
962
+ }
432
963
  // --- Resolve access ------------------------------------------------------
433
964
  const accessResult = await resolveConductorBridgeApiAccess({
434
965
  env: deps.env,
@@ -454,8 +985,8 @@ export async function runSetupEpicCli(argv, overrides = {}) {
454
985
  // Resolved AFTER local/access context is known but BEFORE any run-state read
455
986
  // or mutating request, so malformed interactive input fails before network
456
987
  // dispatch. Returns undefined to continue on the repository base branch.
457
- const promptedInteractively = opts.featureBranch === undefined && deps.isTTY && !opts.json;
458
- const featureBranch = await resolveFeatureBranchSelection(opts, access.repoName, deps);
988
+ const promptedInteractively = opts.featureBranch === undefined && !policyDeclaresBranch && deps.isTTY && !opts.json;
989
+ const featureBranch = await resolveFeatureBranchSelection(opts, access.repoName, deps, policyDeclaresBranch);
459
990
  if (featureBranch !== undefined) {
460
991
  say(`Feature: ${featureBranch} (create from repository base branch on origin)`);
461
992
  }
@@ -463,22 +994,57 @@ export async function runSetupEpicCli(argv, overrides = {}) {
463
994
  // Ordinary, non-warning notice — only when an interactive operator declined.
464
995
  say("Feature: none — continue using the repository base branch");
465
996
  }
997
+ // --- Compose the requested run policy (BAPI-754) -------------------------
998
+ // A contradiction between the file and the flags is refused here, before the
999
+ // pre-check and long before any create, so no request is dispatched under a
1000
+ // policy nobody chose.
1001
+ let requestedPolicy;
1002
+ if (filePolicy !== undefined) {
1003
+ const composed = composeSetupEpicPolicy(filePolicy, {
1004
+ featureBranch,
1005
+ reviewPolicy: opts.reviewPolicy,
1006
+ });
1007
+ if (!composed.ok) {
1008
+ deps.errorLog(composed.error);
1009
+ return 1;
1010
+ }
1011
+ requestedPolicy = composed.policy;
1012
+ say(`Policy: ${opts.policyFile} (complete run policy, applied at creation)`);
1013
+ }
466
1014
  // --- Step 0: pre-check ---------------------------------------------------
467
1015
  // Never create on an ambiguous read. A wrong answer here mints a duplicate run.
468
1016
  let existingRunId = null;
469
1017
  let existingStatus = null;
470
1018
  let existingBaseBranch = null;
1019
+ let existingReviewPolicy = null;
1020
+ // BAPI-754: the live run's SERVER-REDACTED stored policy, kept for the reuse
1021
+ // comparison. Redacted is the only form a client can ever see.
1022
+ let existingPolicyJson = null;
471
1023
  try {
472
1024
  const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
473
1025
  existingRunId = state.epic_run?.epic_run_id ?? null;
474
1026
  existingStatus = state.epic_run?.status ?? null;
475
1027
  const existingPolicy = state.epic_run?.policy_json;
1028
+ existingPolicyJson = existingPolicy ?? null;
476
1029
  const existingBase = existingPolicy && typeof existingPolicy === "object"
477
1030
  ? existingPolicy.base_branch
478
1031
  : undefined;
479
1032
  existingBaseBranch = typeof existingBase === "string" && existingBase.trim() !== ""
480
1033
  ? existingBase
481
1034
  : null;
1035
+ // BAPI-679: read the existing run's review policy for the conflict guard
1036
+ // below. Absent/malformed reads as null — "the resolver's default applies" —
1037
+ // which is deliberately NOT the same as an explicitly selected source.
1038
+ const existingReview = existingPolicy && typeof existingPolicy === "object"
1039
+ ? existingPolicy.review_policy
1040
+ : undefined;
1041
+ const existingReviewSource = existingReview && typeof existingReview === "object"
1042
+ ? existingReview.source
1043
+ : undefined;
1044
+ existingReviewPolicy =
1045
+ typeof existingReviewSource === "string" && existingReviewSource.trim() !== ""
1046
+ ? existingReviewSource
1047
+ : null;
482
1048
  }
483
1049
  catch (err) {
484
1050
  if (err instanceof ConductorBridgeApiError && err.status === 404) {
@@ -511,9 +1077,90 @@ export async function runSetupEpicCli(argv, overrides = {}) {
511
1077
  `it unchanged, or abandon the run to start over on a new branch.`);
512
1078
  return 1;
513
1079
  }
1080
+ // --- Review-policy conflict guard against an existing live run ----------
1081
+ // BAPI-679, analogous to the feature-branch guard above. An explicitly selected
1082
+ // policy that disagrees with a live run's effective policy must fail closed:
1083
+ // setup-epic never retargets an existing run, and silently proceeding would let
1084
+ // an operator believe the run is being reviewed under a policy it is not.
1085
+ // Selecting nothing preserves the existing run's policy untouched.
1086
+ if (existingRunId &&
1087
+ opts.reviewPolicy !== undefined &&
1088
+ existingReviewPolicy !== opts.reviewPolicy) {
1089
+ deps.errorLog(`Epic ${opts.epicKey} already has a live run (${existingRunId}) whose review ` +
1090
+ `policy is ${existingReviewPolicy ? `'${existingReviewPolicy}'` : "unset (resolver default)"}, ` +
1091
+ `which conflicts with the requested '${opts.reviewPolicy}'. setup-epic will not ` +
1092
+ `retarget an existing run. Re-run without --review-policy to reuse it unchanged, ` +
1093
+ `or abandon the run to start over.`);
1094
+ return 1;
1095
+ }
1096
+ if (opts.reviewPolicy !== undefined) {
1097
+ say(`Review: ${opts.reviewPolicy} (per-run; repository defaults pending BAPI-694)`);
1098
+ }
1099
+ // --- Authoritative server validation (BAPI-754) --------------------------
1100
+ // Runs BEFORE any create. A plan the server would refuse must not cost a run
1101
+ // row and an automation-start debit on the way to finding that out.
1102
+ let validated = null;
1103
+ let validateLaneMissing = false;
1104
+ try {
1105
+ validated = await validateEpicPlan(access, {
1106
+ planVersion: plan.plan_version,
1107
+ planBlob: plan,
1108
+ epicKey: opts.epicKey,
1109
+ }, deps.fetch);
1110
+ }
1111
+ catch (err) {
1112
+ const status = err instanceof ConductorBridgeApiError ? err.status : undefined;
1113
+ if (status === 404) {
1114
+ // The ONE sanctioned fallback: an older deployment without the lane. Warn
1115
+ // loudly (a wrong BAPI_BASE_URL looks exactly like this) and continue on
1116
+ // the legacy create-then-store order.
1117
+ validateLaneMissing = true;
1118
+ const msg = `This server has no POST /jira/epic-runs/plan/validate, so the plan could NOT ` +
1119
+ `be validated before creating the run. Either the deployment predates that ` +
1120
+ `capability, or BAPI_BASE_URL is pointing at an older one. Falling back to the ` +
1121
+ `legacy create-then-store order: an invalid plan may create a run and incur an ` +
1122
+ `automation-start charge before failing.`;
1123
+ warnings.push(msg);
1124
+ say(`Validation: [warn] ${msg}`);
1125
+ }
1126
+ else if (status === undefined) {
1127
+ deps.errorLog(`server unreachable, cannot fully validate — the plan was not checked against ` +
1128
+ `the server's validator.\nNo run was created and no automation-start charge ` +
1129
+ `occurred.\nDetail: ${errorDetail(err)}`);
1130
+ return 1;
1131
+ }
1132
+ else if (status === 409) {
1133
+ // The epic is WEDGED (multiple active runs), which says nothing about the
1134
+ // plan. Reporting this as "server validation: failed" would send the
1135
+ // operator to fix a sidecar that is perfectly fine. The pre-check above
1136
+ // normally catches this first; this branch covers the race where a
1137
+ // duplicate run appears between the pre-check and the validate call.
1138
+ deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — the plan could not be ` +
1139
+ `validated because the epic itself is wedged, and every plan call will ` +
1140
+ `keep failing. This is NOT a problem with your plan. Abandon the ` +
1141
+ `duplicate before retrying:\n` +
1142
+ ` PATCH /jira/epic-runs/runs/<epic_run_id> {"status": "abandoned"}\n` +
1143
+ `No run was created and no automation-start charge occurred.\n` +
1144
+ `Detail: ${errorDetail(err)}`);
1145
+ return 1;
1146
+ }
1147
+ else {
1148
+ deps.errorLog(`server validation: failed\n${errorDetail(err)}\n` +
1149
+ `No run was created and no automation-start charge occurred.`);
1150
+ return 1;
1151
+ }
1152
+ }
1153
+ if (validated) {
1154
+ say("Validation: server validation: passed");
1155
+ say(` plan hash ${validated.planHash}`);
1156
+ say(` serialization ${validated.serializationEnabled ? "enabled" : "disabled"}, ` +
1157
+ `${validated.insertedEdges} edge(s) inserted`);
1158
+ }
514
1159
  if (opts.dryRun) {
515
1160
  say("");
516
- say("[dry-run] No changes made. Would:");
1161
+ say(validated
1162
+ ? "[dry-run] Server validation passed. No changes made. Would:"
1163
+ : "[dry-run] partial (local checks only) — the server's validator did not run. Would:");
517
1164
  if (existingRunId) {
518
1165
  say(` - reuse existing run ${existingRunId} (status: ${existingStatus})`);
519
1166
  }
@@ -523,6 +1170,9 @@ export async function runSetupEpicCli(argv, overrides = {}) {
523
1170
  if (featureBranch !== undefined) {
524
1171
  say(` - feature branch: ${featureBranch} (create from repository base branch; no request made in dry-run)`);
525
1172
  }
1173
+ if (opts.reviewPolicy !== undefined) {
1174
+ say(` - review policy: ${opts.reviewPolicy} (per-run policy_json.review_policy; not persisted as a repository default until BAPI-694)`);
1175
+ }
526
1176
  say(` - POST /jira/epic-runs/runs/${opts.epicKey}/plan (v${plan.plan_version})`);
527
1177
  say(` - POST /jira/epic-runs/runs/${opts.epicKey}/approve-plan (v${plan.plan_version})`);
528
1178
  if (opts.json) {
@@ -535,6 +1185,22 @@ export async function runSetupEpicCli(argv, overrides = {}) {
535
1185
  existing_run_id: existingRunId,
536
1186
  // Only present for a feature-branch run — no-feature JSON is unchanged.
537
1187
  ...(featureBranch !== undefined ? { feature_branch: featureBranch } : {}),
1188
+ // Only present when a policy was explicitly selected — the
1189
+ // no-policy JSON shape is unchanged.
1190
+ ...(opts.reviewPolicy !== undefined
1191
+ ? { review_policy: { source: opts.reviewPolicy }, review_policy_scope: "per-run" }
1192
+ : {}),
1193
+ // BAPI-754 — a dry run applies nothing, so the disposition is always
1194
+ // "absent"; server_validated says whether the check was authoritative.
1195
+ server_validated: validated !== null,
1196
+ policy_applied: "absent",
1197
+ ...(validated
1198
+ ? {
1199
+ server_plan_hash: validated.planHash,
1200
+ serialization_enabled: validated.serializationEnabled,
1201
+ inserted_edges: validated.insertedEdges,
1202
+ }
1203
+ : { result: "partial (local checks only)" }),
538
1204
  warnings,
539
1205
  }, null, 2));
540
1206
  }
@@ -551,6 +1217,8 @@ export async function runSetupEpicCli(argv, overrides = {}) {
551
1217
  run_created: false,
552
1218
  plan_stored: false,
553
1219
  plan_approved: false,
1220
+ server_validated: validated !== null,
1221
+ policy_applied: "absent",
554
1222
  warnings,
555
1223
  };
556
1224
  if (featureBranch !== undefined)
@@ -558,21 +1226,89 @@ export async function runSetupEpicCli(argv, overrides = {}) {
558
1226
  // --- Step 1: create (only when there is no live run) ---------------------
559
1227
  if (existingRunId) {
560
1228
  say(`Run: reusing ${existingRunId} (status: ${existingStatus})`);
1229
+ // A pre-check that already found a live run means create would answer 200
1230
+ // anyway; reconcile the file against that run's stored policy directly.
1231
+ if (requestedPolicy !== undefined) {
1232
+ const outcome = await applyReusedRunPolicy({
1233
+ access,
1234
+ deps,
1235
+ say,
1236
+ result,
1237
+ epicRunId: existingRunId,
1238
+ storedPolicy: existingPolicyJson,
1239
+ requestedPolicy,
1240
+ replacePolicy: opts.replacePolicy,
1241
+ });
1242
+ if (!outcome)
1243
+ return emitRefusal(deps, opts, result);
1244
+ }
561
1245
  }
562
1246
  else {
563
1247
  try {
564
1248
  // Persist the confirmed feature branch as policy_json.base_branch ONLY when
565
1249
  // one was selected; otherwise keep the exact legacy create request shape.
566
- const createRequest = featureBranch !== undefined
567
- ? { epicKey: opts.epicKey, policyJson: { base_branch: featureBranch } }
1250
+ // BAPI-679: base_branch and review_policy COMPOSE — neither replaces the
1251
+ // other, and when neither is selected the legacy create request shape is
1252
+ // preserved byte-for-byte so the pre-feature contract is unchanged.
1253
+ //
1254
+ // BAPI-754: a --policy-file supersedes that ad-hoc composition entirely —
1255
+ // `requestedPolicy` IS the composed result, flags included — so the whole
1256
+ // policy reaches the server in the create that mints the run.
1257
+ let policyJson;
1258
+ if (requestedPolicy !== undefined) {
1259
+ policyJson = requestedPolicy;
1260
+ }
1261
+ else {
1262
+ policyJson = {};
1263
+ if (featureBranch !== undefined)
1264
+ policyJson.base_branch = featureBranch;
1265
+ if (opts.reviewPolicy !== undefined) {
1266
+ policyJson.review_policy = { source: opts.reviewPolicy };
1267
+ }
1268
+ }
1269
+ const createRequest = Object.keys(policyJson).length > 0 || requestedPolicy !== undefined
1270
+ ? { epicKey: opts.epicKey, policyJson }
568
1271
  : { epicKey: opts.epicKey };
569
- const run = await createEpicRun(access, createRequest, deps.fetch);
1272
+ const { run, created } = await createEpicRunWithDisposition(access, createRequest, deps.fetch);
570
1273
  result.epic_run_id = run.epic_run_id;
571
1274
  result.status = run.status;
572
- result.run_created = true;
573
- say(`Run: created ${run.epic_run_id}`);
1275
+ result.run_created = created;
1276
+ if (created) {
1277
+ say(`Run: created ${run.epic_run_id}`);
1278
+ if (requestedPolicy !== undefined) {
1279
+ result.policy_applied = "created";
1280
+ say("Policy: policy applied: created");
1281
+ }
1282
+ }
1283
+ else {
1284
+ // The pre-check said there was no live run, but create found one — a
1285
+ // concurrent setup won the race. Treat it exactly like the reuse path.
1286
+ say(`Run: reusing ${run.epic_run_id} (status: ${run.status})`);
1287
+ if (requestedPolicy !== undefined) {
1288
+ const outcome = await applyReusedRunPolicy({
1289
+ access,
1290
+ deps,
1291
+ say,
1292
+ result,
1293
+ epicRunId: run.epic_run_id,
1294
+ storedPolicy: run.policy_json,
1295
+ requestedPolicy,
1296
+ replacePolicy: opts.replacePolicy,
1297
+ });
1298
+ if (!outcome)
1299
+ return emitRefusal(deps, opts, result);
1300
+ }
1301
+ }
574
1302
  }
575
1303
  catch (err) {
1304
+ const status = err instanceof ConductorBridgeApiError ? err.status : undefined;
1305
+ if (status === 422 && requestedPolicy !== undefined) {
1306
+ deps.errorLog(`The run policy from '${opts.policyFile}' was rejected by the server.\n` +
1307
+ `${errorDetail(err)}\n` +
1308
+ `No run was created and no automation-start charge occurred. Fix the ` +
1309
+ `named field in the policy file and re-run.`);
1310
+ return 1;
1311
+ }
576
1312
  deps.errorLog(`Failed to create the epic run: ${errorDetail(err)}`);
577
1313
  return 1;
578
1314
  }
@@ -668,6 +1404,14 @@ export async function runSetupEpicCli(argv, overrides = {}) {
668
1404
  }
669
1405
  else {
670
1406
  say("");
1407
+ // BAPI-754: repeat the capability warning in the summary. An operator who
1408
+ // scrolled past it at detection time would otherwise finish the run believing
1409
+ // the plan had been validated.
1410
+ if (validateLaneMissing) {
1411
+ say("[warn] This server has no POST /jira/epic-runs/plan/validate — the plan was " +
1412
+ "NOT validated before the run was created, and the legacy create-then-store " +
1413
+ "order was used. Check that BAPI_BASE_URL points at the intended deployment.");
1414
+ }
671
1415
  say(`Epic run ${result.epic_run_id} is ${result.status ?? "unknown"}.`);
672
1416
  say("The server-side reconciler will pick it up within ~30s.");
673
1417
  say("To execute claimed jobs on this machine, run:");