@bridge_gpt/mcp-server 0.2.36 → 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 (90) hide show
  1. package/README.md +44 -6
  2. package/build/base-url.js +79 -0
  3. package/build/bridge-api-urls.js +9 -0
  4. package/build/chain-orchestrator.js +93 -15
  5. package/build/claude-user-config-doctor.js +317 -0
  6. package/build/commands.generated.js +2 -1
  7. package/build/conductor/bridge-api-client.js +178 -4
  8. package/build/conductor-bin.js +1 -1
  9. package/build/conductor-bundle-artifacts.js +7 -6
  10. package/build/credential-store.js +205 -4
  11. package/build/direct-ticket-tools.js +70 -0
  12. package/build/doctor.js +239 -80
  13. package/build/executor/cli.js +51 -1
  14. package/build/executor/credentials.js +1 -7
  15. package/build/executor/deps.js +18 -1
  16. package/build/executor/env.js +51 -25
  17. package/build/executor/heartbeat.js +138 -17
  18. package/build/executor/http-client.js +49 -8
  19. package/build/executor/job-errors.js +4 -0
  20. package/build/executor/job-runner.js +422 -22
  21. package/build/executor/observation.js +130 -0
  22. package/build/executor/permissions.js +104 -8
  23. package/build/executor/preflight.js +32 -0
  24. package/build/executor/runner.js +8 -0
  25. package/build/executor/test-clock.js +67 -3
  26. package/build/executor/types.js +4 -1
  27. package/build/executor/worker-command.js +11 -3
  28. package/build/executor/worker-config-isolation.js +287 -0
  29. package/build/executor/worker-finalization.js +68 -14
  30. package/build/executor/worktree.js +46 -4
  31. package/build/index.js +614 -244
  32. package/build/init.js +363 -73
  33. package/build/install-bridge.js +568 -80
  34. package/build/launcher-config-inspection.js +351 -0
  35. package/build/mcp-invoke.js +49 -6
  36. package/build/mcp-provisioning.js +30 -7
  37. package/build/mcp-registration-doctor.js +14 -5
  38. package/build/notifications.js +553 -0
  39. package/build/pipeline-orchestrator.js +146 -4
  40. package/build/pipeline-utils.js +3 -0
  41. package/build/pipelines.generated.js +22 -9
  42. package/build/plan-execution-ledger.js +550 -0
  43. package/build/plan-phase-routing.js +272 -0
  44. package/build/plane/alembic-head.js +110 -0
  45. package/build/plane/build-freshness.js +167 -0
  46. package/build/plane/cli.js +480 -0
  47. package/build/plane/defaults.js +266 -0
  48. package/build/plane/manifest.js +377 -0
  49. package/build/plane/member-logs.js +147 -0
  50. package/build/plane/member-roster.js +147 -0
  51. package/build/plane/preflight.js +289 -0
  52. package/build/plane/shutdown.js +195 -0
  53. package/build/plane/status.js +125 -0
  54. package/build/plane/supervisor.js +569 -0
  55. package/build/plane/test-fakes.js +156 -0
  56. package/build/plane/types.js +75 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/run-unit-tests-launcher.js +2 -0
  59. package/build/setup-epic.js +662 -27
  60. package/build/sfcc/log-gate.js +38 -11
  61. package/build/sfcc/log-query.js +55 -15
  62. package/build/sfcc/ocapi-shape.js +51 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-projection.js +181 -0
  66. package/build/sfcc/read-result.js +158 -0
  67. package/build/sfcc/reads-custom-object-def.js +29 -18
  68. package/build/sfcc/reads-site-preference.js +75 -29
  69. package/build/sfcc/reads-system-object.js +40 -34
  70. package/build/sfcc/sfcc-result.js +106 -0
  71. package/build/sfcc/tool-wrapper.js +56 -13
  72. package/build/sfcc/write-grants.js +45 -22
  73. package/build/sfcc/write-guard.js +21 -13
  74. package/build/sfcc/write-result.js +61 -14
  75. package/build/sfcc/write-tool-common.js +126 -32
  76. package/build/sfcc/writes-system-object.js +11 -50
  77. package/build/start-tickets-prereqs.js +129 -0
  78. package/build/start-tickets.js +17 -13
  79. package/build/ticket-backend-metadata.js +59 -0
  80. package/build/ticket-key-utils.js +92 -0
  81. package/build/tool-error-envelope.js +71 -0
  82. package/build/tool-surface-gating.js +72 -0
  83. package/build/update-status.js +102 -0
  84. package/build/upgrade-advice.js +47 -0
  85. package/build/upgrade-cli.js +417 -101
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +73 -0
  88. package/docs/CONDUCTOR.md +23 -8
  89. package/package.json +3 -3
  90. package/pipelines/implement-ticket.json +15 -5
@@ -8,24 +8,35 @@
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";
31
42
  /** Accepted `policy_json.review_policy.source` values (the `ReviewPolicy` surface). */
@@ -94,10 +105,31 @@ export function getSetupEpicUsage() {
94
105
  " This setting is per-run: repository-level review-policy",
95
106
  " defaults are NOT persisted in supervisor project",
96
107
  " defaults yet — that is BAPI-694.",
97
- " --dry-run Validate and preview; make no mutating calls",
98
108
  " --json Emit a single JSON result object on stdout",
99
109
  " -h, --help Show this help",
100
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
+ "",
101
133
  "After setup, the server-side reconciler picks the run up within ~30s.",
102
134
  "To execute claimed jobs on this machine, run:",
103
135
  " npx -y @bridge_gpt/mcp-server executor --repo <name>",
@@ -156,10 +188,21 @@ export function parseSetupEpicArgs(argv) {
156
188
  let planVersion;
157
189
  let featureBranch;
158
190
  let reviewPolicy;
191
+ let policyFile;
192
+ let replacePolicy = false;
193
+ let localOnly = false;
159
194
  let dryRun = false;
160
195
  let json = false;
161
196
  for (let i = 0; i < argv.length; i++) {
162
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
+ }
163
206
  // `--feature-branch=<name>` inline form (handled before the exact-match switch).
164
207
  if (arg.startsWith("--feature-branch=")) {
165
208
  const parsedFb = parseFeatureBranchValue(arg.slice("--feature-branch=".length));
@@ -238,6 +281,23 @@ export function parseSetupEpicArgs(argv) {
238
281
  i++;
239
282
  break;
240
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;
241
301
  case "--dry-run":
242
302
  dryRun = true;
243
303
  break;
@@ -255,11 +315,306 @@ export function parseSetupEpicArgs(argv) {
255
315
  return { status: "error", message: "setup-epic requires --epic-key <KEY>." };
256
316
  if (!planFile)
257
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
+ }
258
337
  return {
259
338
  status: "ok",
260
- options: { epicKey, planFile, repo, planVersion, featureBranch, reviewPolicy, 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
+ },
261
352
  };
262
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;
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 };
617
+ }
263
618
  /**
264
619
  * Validate the plan DAG locally, mirroring the server's `validate_epic_plan_dag`.
265
620
  *
@@ -398,15 +753,20 @@ function proposeFeatureBranchName(epicKey) {
398
753
  * parse time and is returned unchanged (no prompt).
399
754
  * - A non-interactive (piped/CI) run or a `--json` run never prompts and never
400
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.
401
759
  * - An interactive run is shown the proposal (branch name, "create from the
402
760
  * repository base branch" strategy, and the child-PR-targeting consequence)
403
761
  * and may accept it, type a custom name, or decline to use the base branch.
404
762
  * An edited nonblank value is re-validated; an invalid value is redisplayed
405
763
  * for correction rather than silently rewritten.
406
764
  */
407
- async function resolveFeatureBranchSelection(opts, repoName, deps) {
765
+ async function resolveFeatureBranchSelection(opts, repoName, deps, policyDeclaresBranch = false) {
408
766
  if (opts.featureBranch !== undefined)
409
767
  return opts.featureBranch;
768
+ if (policyDeclaresBranch)
769
+ return undefined;
410
770
  if (!deps.isTTY || opts.json)
411
771
  return undefined;
412
772
  const proposed = proposeFeatureBranchName(opts.epicKey);
@@ -441,6 +801,64 @@ function errorDetail(err) {
441
801
  }
442
802
  return err instanceof Error ? err.message : String(err);
443
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
+ }
444
862
  export async function runSetupEpicCli(argv, overrides = {}) {
445
863
  const deps = { ...createDefaultSetupEpicDeps(), ...overrides };
446
864
  const parsed = parseSetupEpicArgs(argv);
@@ -488,6 +906,60 @@ export async function runSetupEpicCli(argv, overrides = {}) {
488
906
  return 1;
489
907
  }
490
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
+ }
491
963
  // --- Resolve access ------------------------------------------------------
492
964
  const accessResult = await resolveConductorBridgeApiAccess({
493
965
  env: deps.env,
@@ -513,8 +985,8 @@ export async function runSetupEpicCli(argv, overrides = {}) {
513
985
  // Resolved AFTER local/access context is known but BEFORE any run-state read
514
986
  // or mutating request, so malformed interactive input fails before network
515
987
  // dispatch. Returns undefined to continue on the repository base branch.
516
- const promptedInteractively = opts.featureBranch === undefined && deps.isTTY && !opts.json;
517
- 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);
518
990
  if (featureBranch !== undefined) {
519
991
  say(`Feature: ${featureBranch} (create from repository base branch on origin)`);
520
992
  }
@@ -522,17 +994,38 @@ export async function runSetupEpicCli(argv, overrides = {}) {
522
994
  // Ordinary, non-warning notice — only when an interactive operator declined.
523
995
  say("Feature: none — continue using the repository base branch");
524
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
+ }
525
1014
  // --- Step 0: pre-check ---------------------------------------------------
526
1015
  // Never create on an ambiguous read. A wrong answer here mints a duplicate run.
527
1016
  let existingRunId = null;
528
1017
  let existingStatus = null;
529
1018
  let existingBaseBranch = null;
530
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;
531
1023
  try {
532
1024
  const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
533
1025
  existingRunId = state.epic_run?.epic_run_id ?? null;
534
1026
  existingStatus = state.epic_run?.status ?? null;
535
1027
  const existingPolicy = state.epic_run?.policy_json;
1028
+ existingPolicyJson = existingPolicy ?? null;
536
1029
  const existingBase = existingPolicy && typeof existingPolicy === "object"
537
1030
  ? existingPolicy.base_branch
538
1031
  : undefined;
@@ -603,9 +1096,71 @@ export async function runSetupEpicCli(argv, overrides = {}) {
603
1096
  if (opts.reviewPolicy !== undefined) {
604
1097
  say(`Review: ${opts.reviewPolicy} (per-run; repository defaults pending BAPI-694)`);
605
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
+ }
606
1159
  if (opts.dryRun) {
607
1160
  say("");
608
- 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:");
609
1164
  if (existingRunId) {
610
1165
  say(` - reuse existing run ${existingRunId} (status: ${existingStatus})`);
611
1166
  }
@@ -635,6 +1190,17 @@ export async function runSetupEpicCli(argv, overrides = {}) {
635
1190
  ...(opts.reviewPolicy !== undefined
636
1191
  ? { review_policy: { source: opts.reviewPolicy }, review_policy_scope: "per-run" }
637
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)" }),
638
1204
  warnings,
639
1205
  }, null, 2));
640
1206
  }
@@ -651,6 +1217,8 @@ export async function runSetupEpicCli(argv, overrides = {}) {
651
1217
  run_created: false,
652
1218
  plan_stored: false,
653
1219
  plan_approved: false,
1220
+ server_validated: validated !== null,
1221
+ policy_applied: "absent",
654
1222
  warnings,
655
1223
  };
656
1224
  if (featureBranch !== undefined)
@@ -658,6 +1226,22 @@ export async function runSetupEpicCli(argv, overrides = {}) {
658
1226
  // --- Step 1: create (only when there is no live run) ---------------------
659
1227
  if (existingRunId) {
660
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
+ }
661
1245
  }
662
1246
  else {
663
1247
  try {
@@ -666,22 +1250,65 @@ export async function runSetupEpicCli(argv, overrides = {}) {
666
1250
  // BAPI-679: base_branch and review_policy COMPOSE — neither replaces the
667
1251
  // other, and when neither is selected the legacy create request shape is
668
1252
  // preserved byte-for-byte so the pre-feature contract is unchanged.
669
- const policyJson = {};
670
- if (featureBranch !== undefined)
671
- policyJson.base_branch = featureBranch;
672
- if (opts.reviewPolicy !== undefined) {
673
- policyJson.review_policy = { source: opts.reviewPolicy };
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;
674
1260
  }
675
- const createRequest = Object.keys(policyJson).length > 0
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
676
1270
  ? { epicKey: opts.epicKey, policyJson }
677
1271
  : { epicKey: opts.epicKey };
678
- const run = await createEpicRun(access, createRequest, deps.fetch);
1272
+ const { run, created } = await createEpicRunWithDisposition(access, createRequest, deps.fetch);
679
1273
  result.epic_run_id = run.epic_run_id;
680
1274
  result.status = run.status;
681
- result.run_created = true;
682
- 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
+ }
683
1302
  }
684
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
+ }
685
1312
  deps.errorLog(`Failed to create the epic run: ${errorDetail(err)}`);
686
1313
  return 1;
687
1314
  }
@@ -777,6 +1404,14 @@ export async function runSetupEpicCli(argv, overrides = {}) {
777
1404
  }
778
1405
  else {
779
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
+ }
780
1415
  say(`Epic run ${result.epic_run_id} is ${result.status ?? "unknown"}.`);
781
1416
  say("The server-side reconciler will pick it up within ~30s.");
782
1417
  say("To execute claimed jobs on this machine, run:");