@bridge_gpt/mcp-server 0.2.65 → 0.2.67

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 (132) hide show
  1. package/README.md +269 -82
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +55 -34
  4. package/build/agent-launchers/executor-adapter.js +34 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/bridge-config.js +104 -0
  8. package/build/codex-bridge-profile.js +10 -1
  9. package/build/commands.generated.js +12 -12
  10. package/build/conductor/bridge-api-client.js +372 -10
  11. package/build/conductor/bring-up-facts.js +1 -1
  12. package/build/conductor/doctor.js +3 -3
  13. package/build/conductor/epic-child-continuation.js +61 -34
  14. package/build/conductor/epic-implementer-cli.js +34 -6
  15. package/build/conductor/errors.js +4 -3
  16. package/build/conductor/guided-repair.js +327 -0
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/merge-execution-contract.js +27 -8
  19. package/build/conductor/merge-execution-eligibility.js +3 -2
  20. package/build/conductor/pr-ci-producer.js +1 -1
  21. package/build/conductor/readiness-cli.js +46 -9
  22. package/build/conductor/readiness-sections.js +41 -6
  23. package/build/conductor/readiness.js +204 -30
  24. package/build/conductor/recovery-cli.js +703 -20
  25. package/build/conductor/recovery-operations.js +392 -3
  26. package/build/conductor/repair-contract.js +112 -3
  27. package/build/conductor/repository-workflow-remedy.js +125 -0
  28. package/build/conductor/tools.js +2 -2
  29. package/build/conductor-bundle-artifacts.js +8 -0
  30. package/build/credential-store.js +5 -4
  31. package/build/diagnostic-text.js +36 -0
  32. package/build/docs.generated.js +8 -3
  33. package/build/doctor.js +70 -10
  34. package/build/drive-epic-key.js +6 -5
  35. package/build/drive-epic.js +479 -131
  36. package/build/epic-coherence-input.js +2 -1
  37. package/build/epic-implementer-bin.js +30 -26
  38. package/build/epic-implementer-bundle-cli.js +2 -2
  39. package/build/epic-launch-diagnostic.js +31 -0
  40. package/build/epic-launch-profile.js +44 -14
  41. package/build/executor/cli.js +3 -2
  42. package/build/executor/completion-contract.js +48 -5
  43. package/build/executor/completion-supervision.js +9 -0
  44. package/build/executor/correction-dispatch.js +60 -0
  45. package/build/executor/env.js +25 -0
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/http-client.js +35 -0
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +38 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +485 -74
  52. package/build/executor/job-types.js +8 -13
  53. package/build/executor/observation.js +6 -1
  54. package/build/executor/preflight.js +136 -10
  55. package/build/executor/prompt-spec.js +113 -9
  56. package/build/executor/required-work-ledger.js +6 -1
  57. package/build/executor/resume-pre-spawn.js +2 -0
  58. package/build/executor/runner.js +31 -2
  59. package/build/executor/service-unit.js +7 -1
  60. package/build/executor/stale-artifacts.js +5 -4
  61. package/build/executor/types.js +1 -0
  62. package/build/executor/watch-cli.js +35 -8
  63. package/build/executor/worker-command.js +43 -43
  64. package/build/executor/worker-finalization.js +161 -43
  65. package/build/executor/worker-guard-hook.js +152 -12
  66. package/build/executor/worker-log.js +137 -70
  67. package/build/executor/worktree-gc.js +30 -6
  68. package/build/executor/worktree.js +82 -1
  69. package/build/index.js +482 -660
  70. package/build/init.js +107 -4
  71. package/build/install-bridge-conductor.js +8 -6
  72. package/build/install-bridge.js +287 -23
  73. package/build/install-doctor.js +172 -30
  74. package/build/install-reexec.js +69 -7
  75. package/build/macos-terminal-permission-guidance.js +16 -0
  76. package/build/model-thresholds-tool.js +185 -0
  77. package/build/phase-context-git-probe.js +68 -0
  78. package/build/phase-result-artifacts.js +173 -5
  79. package/build/pipelines.generated.js +19 -25
  80. package/build/plane/alembic-head.js +12 -3
  81. package/build/plane/base-worktree-guard.js +147 -0
  82. package/build/plane/cli.js +217 -17
  83. package/build/plane/code-snapshot.js +468 -0
  84. package/build/plane/defaults.js +48 -3
  85. package/build/plane/endpoint.js +41 -0
  86. package/build/plane/manifest.js +120 -1
  87. package/build/plane/member-roster.js +94 -19
  88. package/build/plane/preflight.js +30 -2
  89. package/build/plane/process-cwd.js +67 -0
  90. package/build/plane/restart.js +542 -0
  91. package/build/plane/shutdown.js +31 -0
  92. package/build/plane/status.js +145 -6
  93. package/build/plane/supervisor.js +588 -177
  94. package/build/plane/types.js +54 -2
  95. package/build/plane/worker-exit-code.js +2 -0
  96. package/build/printed-remedy-commands.js +37 -0
  97. package/build/project-root.js +99 -0
  98. package/build/readiness-check.js +1 -1
  99. package/build/readme.generated.js +1 -1
  100. package/build/review-generation.js +22 -0
  101. package/build/review-tickets.js +156 -13
  102. package/build/setup-epic.js +224 -127
  103. package/build/sfcc/config.js +9 -0
  104. package/build/sfcc/credentials.js +100 -38
  105. package/build/sfcc/hostname.js +67 -0
  106. package/build/sfcc/permissions.js +17 -13
  107. package/build/sfcc/register.js +1 -1
  108. package/build/sfcc/setup-status.js +2 -0
  109. package/build/sfcc/tool-wrapper.js +41 -43
  110. package/build/sfcc/tools-configured.js +96 -0
  111. package/build/sfcc/write-grants.js +17 -11
  112. package/build/sfcc/write-result.js +4 -3
  113. package/build/sfcc/writes-site-preference.js +20 -8
  114. package/build/start-tickets.js +332 -79
  115. package/build/tool-error-envelope.js +61 -0
  116. package/build/upgrade-cli.js +109 -24
  117. package/build/version.generated.js +3 -3
  118. package/build/visual-diff.js +22 -1
  119. package/build/windows-npm-entry.js +103 -0
  120. package/build/worker-guard-hook-bin.js +3 -5
  121. package/build/worker-guard-hook-main.js +47 -0
  122. package/build/worktree-core.js +136 -9
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  124. package/docs/EPIC-IMPLEMENTER.md +662 -0
  125. package/docs/install/mcp-tool-integrations.md +25 -15
  126. package/docs/install/sfcc-integration.md +21 -8
  127. package/package.json +1 -1
  128. package/pipelines/implement-ticket.json +1 -1
  129. package/pipelines/plan-epic.json +0 -5
  130. package/pipelines/review-ticket.json +1 -1
  131. package/docs/CONDUCTOR.md +0 -605
  132. package/pipelines/idea-to-pr.json +0 -49
@@ -1,14 +1,16 @@
1
1
  /**
2
- * `conductor` operator recovery and repair verbs — twelve CLI-only commands.
2
+ * `conductor` operator recovery and repair verbs — fourteen CLI-only commands.
3
3
  *
4
4
  * The four BAPI-872 recovery verbs: `stop-run`, `abandon-run`, `unpark`,
5
5
  * `adopt-current-head-and-unpark`. The six run-addressed repair verbs: BAPI-1154's
6
6
  * `scope-reparse`, `dispatch-remediation`, `merge-child`, and BAPI-1176's
7
7
  * `scope-catch-up`, `patch-policy`, `resume-run`. BAPI-1300's parked-child
8
8
  * continuation pair: `continue-child` and the read-only `continuation-receipt`.
9
+ * BAPI-1326's allowance-preserving CI resume: `resume-ci`. BAPI-1388's guided
10
+ * provider re-run of failed CI workflow runs: `rerun-ci`.
9
11
  *
10
12
  * CLI-only by design — this is an explicit ticket non-goal, not an oversight:
11
- * none of these twelve command names is ever registered as an MCP tool, tool
13
+ * none of these fourteen command names is ever registered as an MCP tool, tool
12
14
  * description, or generated tool-schema input (`index-static.test.ts` guards
13
15
  * this). They exist so an operator (or `plane down`, via
14
16
  * `recovery-operations.ts`) can recover a stuck run or ticket WITHOUT
@@ -25,6 +27,7 @@
25
27
  import { readFile } from "node:fs/promises";
26
28
  import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
27
29
  import { DRIVER_CONTEXT_MAX_BYTES, POLICY_PATCH_MAX_BYTES, RESUME_RUN_UNCONFIRMED_MESSAGE, } from "./repair-contract.js";
30
+ import { decodeGuidedBinding, GuidedBindingError, } from "./guided-repair.js";
28
31
  // ---------------------------------------------------------------------------
29
32
  // Flag parsing
30
33
  // ---------------------------------------------------------------------------
@@ -378,13 +381,26 @@ export async function runAdoptCurrentHeadAndUnparkCommand(argv) {
378
381
  // ---------------------------------------------------------------------------
379
382
  // BAPI-1154 — repair verbs: scope-reparse, dispatch-remediation, merge-child
380
383
  // ---------------------------------------------------------------------------
381
- const SCOPE_REPARSE_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id"]);
384
+ // BAPI-1333 — `--binding` is the ONE guided-repair input: the opaque token
385
+ // ExplainRun rendered, carrying the subject the command is pinned to. It is not
386
+ // a version counter the operator composes, so REJECTED_VERSION_FLAGS is unchanged.
387
+ const SCOPE_REPARSE_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id", "--binding"]);
382
388
  const DISPATCH_REMEDIATION_VALUE_FLAGS = new Set([
383
389
  "--epic-run-id",
384
390
  "--ticket-key",
385
391
  "--context-file",
392
+ "--binding",
386
393
  ]);
387
394
  const MERGE_CHILD_VALUE_FLAGS = new Set(["--epic-run-id", "--pr"]);
395
+ /**
396
+ * BAPI-1333 — attach `binding` ONLY when `--binding` was given, so an unguided
397
+ * parse result is exactly what it was before.
398
+ */
399
+ function withBinding(parsed, values) {
400
+ if (!values.has("--binding"))
401
+ return parsed;
402
+ return { ...parsed, binding: requireFlag(values, "--binding") };
403
+ }
388
404
  function requireFlag(values, flag) {
389
405
  const value = values.get(flag);
390
406
  if (value === undefined || value.trim().length === 0) {
@@ -397,25 +413,25 @@ export function parseScopeReparseArgs(argv) {
397
413
  if (bools.has("--help")) {
398
414
  return { epicRunId: "", scopeId: "", json: bools.has("--json"), help: true };
399
415
  }
400
- return {
416
+ return withBinding({
401
417
  epicRunId: requireFlag(values, "--epic-run-id"),
402
418
  scopeId: requireFlag(values, "--scope-id"),
403
419
  json: bools.has("--json"),
404
420
  help: false,
405
- };
421
+ }, values);
406
422
  }
407
423
  export function parseDispatchRemediationArgs(argv) {
408
424
  const { values, bools } = tokenizeRecoveryFlags(argv, DISPATCH_REMEDIATION_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
409
425
  if (bools.has("--help")) {
410
426
  return { epicRunId: "", ticketKey: "", contextFile: "", json: bools.has("--json"), help: true };
411
427
  }
412
- return {
428
+ return withBinding({
413
429
  epicRunId: requireFlag(values, "--epic-run-id"),
414
430
  ticketKey: requireFlag(values, "--ticket-key"),
415
431
  contextFile: requireFlag(values, "--context-file"),
416
432
  json: bools.has("--json"),
417
433
  help: false,
418
- };
434
+ }, values);
419
435
  }
420
436
  export function parseMergeChildArgs(argv) {
421
437
  const { values, bools } = tokenizeRecoveryFlags(argv, MERGE_CHILD_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
@@ -457,6 +473,124 @@ export function decodeDriverContextFile(bytes) {
457
473
  }
458
474
  return text;
459
475
  }
476
+ /** One repair result, however it ended, as the renderer needs it. */
477
+ // ---------------------------------------------------------------------------
478
+ // BAPI-1333 — guided (binding-pinned) repairs
479
+ // ---------------------------------------------------------------------------
480
+ /** Exit codes shared with `resume-ci`: 0 applied/no-op, 2 refused/stale, 3 unconfirmed. */
481
+ export const GUIDED_REPAIR_EXIT_OK = 0;
482
+ export const GUIDED_REPAIR_EXIT_FAILED = 1;
483
+ export const GUIDED_REPAIR_EXIT_REFUSED = 2;
484
+ export const GUIDED_REPAIR_EXIT_UNCONFIRMED = 3;
485
+ export const GUIDED_REPAIR_UNCONFIRMED_MESSAGE = "the outcome could not be confirmed: the server may or may not have applied this " +
486
+ "guided repair. Re-run the SAME command to re-send the same pinned request, or " +
487
+ "inspect the run read-only";
488
+ const GUIDED_STALE_GUIDANCE = "Nothing was written. The run changed since this command was rendered (possibly " +
489
+ "by this command's own earlier delivery). Re-read the run explanation before " +
490
+ "choosing another action.";
491
+ /**
492
+ * Decode `--binding` and prove it names THIS command, run, and target. Runs
493
+ * before credentials are resolved, so a mismatched token reaches no one.
494
+ */
495
+ function checkGuidedBinding(token, verb, expected) {
496
+ let binding;
497
+ try {
498
+ binding = decodeGuidedBinding(token);
499
+ }
500
+ catch (err) {
501
+ throw new ConductorValidationError(err instanceof GuidedBindingError ? err.message : "--binding could not be decoded; nothing was sent.");
502
+ }
503
+ if (binding.verb !== verb ||
504
+ binding.epicRunId !== expected.epicRunId ||
505
+ (expected.scopeId !== undefined && binding.scopeId !== expected.scopeId) ||
506
+ (expected.ticketKey !== undefined && binding.ticketKey !== expected.ticketKey)) {
507
+ throw new ConductorValidationError("--binding was rendered for a different command, run, or target; nothing was sent.");
508
+ }
509
+ return binding;
510
+ }
511
+ /**
512
+ * Send one guided repair and render its closed outcome. Success goes to stdout;
513
+ * every failure goes to stderr (JSON mode included), with the exit codes above.
514
+ */
515
+ async function runGuidedRepairCommand(input, json) {
516
+ const { guidedRepairOnce } = await import("./recovery-operations.js");
517
+ const { guidedPayloadDigest } = await import("./guided-repair.js");
518
+ const outcome = await guidedRepairOnce({
519
+ binding: input.binding,
520
+ payloadDigest: guidedPayloadDigest(input.payload),
521
+ send: input.send,
522
+ unconfirmedMessage: GUIDED_REPAIR_UNCONFIRMED_MESSAGE,
523
+ });
524
+ const verb = input.binding.verb;
525
+ const runId = input.binding.epicRunId;
526
+ if (outcome.kind === "committed" || outcome.kind === "no_op") {
527
+ const result = outcome.result;
528
+ if (outcome.kind === "committed" && !result.event_recorded) {
529
+ console.error(`Warning: ${verb} on run ${runId} (${input.label}) COMMITTED, but its ` +
530
+ "operator_verb_applied audit event was not recorded. The action stands.");
531
+ }
532
+ if (json) {
533
+ console.log(JSON.stringify({
534
+ ok: true,
535
+ outcome: outcome.kind,
536
+ request_id: outcome.requestId,
537
+ reference_reused: outcome.referenceReused,
538
+ result,
539
+ }));
540
+ return GUIDED_REPAIR_EXIT_OK;
541
+ }
542
+ console.log([
543
+ `${verb} (guided) — run ${runId}`,
544
+ ` target: ${input.label}`,
545
+ ` ${input.stateLabel}: ${result.before ?? "none"} → ${result.after ?? "none"}`,
546
+ ` outcome: ${outcome.kind === "committed" ? "committed" : "no-op (already in effect; nothing written)"}`,
547
+ ` request: ${outcome.requestId}${outcome.referenceReused ? " (re-sent from the local reference)" : ""}`,
548
+ ` event recorded: ${result.event_recorded}`,
549
+ ].join("\n"));
550
+ return GUIDED_REPAIR_EXIT_OK;
551
+ }
552
+ if (outcome.kind === "stale" || outcome.kind === "refused") {
553
+ const payload = {
554
+ ok: false,
555
+ outcome: outcome.kind,
556
+ code: outcome.code,
557
+ message: outcome.message,
558
+ request_id: outcome.requestId,
559
+ nothing_written: true,
560
+ };
561
+ if (json) {
562
+ console.error(JSON.stringify(payload));
563
+ }
564
+ else {
565
+ console.error(`Error: ${verb} ${outcome.kind === "stale" ? "is stale" : "refused"} on run ${runId} ` +
566
+ `(${input.label}) — ${outcome.code}: ${outcome.message}` +
567
+ (outcome.kind === "stale" ? ` ${GUIDED_STALE_GUIDANCE}` : ""));
568
+ }
569
+ return GUIDED_REPAIR_EXIT_REFUSED;
570
+ }
571
+ if (outcome.kind === "unconfirmed") {
572
+ if (json) {
573
+ console.error(JSON.stringify({
574
+ ok: false,
575
+ outcome: "unconfirmed",
576
+ message: outcome.message,
577
+ request_id: outcome.requestId,
578
+ reference_kept: true,
579
+ }));
580
+ }
581
+ else {
582
+ console.error(`Error: ${verb} on run ${runId} (${input.label}) — ${outcome.message}.`);
583
+ }
584
+ return GUIDED_REPAIR_EXIT_UNCONFIRMED;
585
+ }
586
+ if (json) {
587
+ console.error(JSON.stringify({ ok: false, outcome: "unavailable", message: outcome.message }));
588
+ }
589
+ else {
590
+ console.error(`Error: ${verb} on run ${runId} (${input.label}) — ${outcome.message}.`);
591
+ }
592
+ return GUIDED_REPAIR_EXIT_FAILED;
593
+ }
460
594
  /**
461
595
  * Render a repair outcome. Human mode prints the verb and run first, the target
462
596
  * second, ONE labeled `Before → After` pair third, then whether this call
@@ -521,6 +655,12 @@ const SCOPE_REPARSE_USAGE = [
521
655
  "schedule its parse. The scope must be the one bound to the named run, or the",
522
656
  "server refuses it (TICKET_NOT_IN_RUN) with nothing written. Prints the scope",
523
657
  "lifecycle before → after; it does not wait for the parse to finish.",
658
+ "",
659
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
660
+ "rendered it. The request is pinned to the subject that explanation showed and",
661
+ "is stored locally before it is sent, so re-running the same command after a",
662
+ "lost response re-sends the SAME request. A subject that has since changed is",
663
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
524
664
  ].join("\n");
525
665
  export async function runScopeReparseCommand(argv) {
526
666
  try {
@@ -529,9 +669,25 @@ export async function runScopeReparseCommand(argv) {
529
669
  console.log(SCOPE_REPARSE_USAGE);
530
670
  return 0;
531
671
  }
672
+ const binding = parsed.binding === undefined
673
+ ? null
674
+ : checkGuidedBinding(parsed.binding, "scope-reparse", {
675
+ epicRunId: parsed.epicRunId,
676
+ scopeId: parsed.scopeId,
677
+ });
532
678
  const access = await resolveRecoveryAccess(parsed.json);
533
679
  if (!access)
534
680
  return 1;
681
+ if (binding !== null) {
682
+ const { scopeReparse } = await import("./bridge-api-client.js");
683
+ return await runGuidedRepairCommand({
684
+ binding,
685
+ payload: { scope_id: parsed.scopeId },
686
+ send: (guard) => scopeReparse(access, { epicRunId: parsed.epicRunId, scopeId: parsed.scopeId, guard }),
687
+ label: `scope ${parsed.scopeId}`,
688
+ stateLabel: "scope lifecycle",
689
+ }, parsed.json);
690
+ }
535
691
  const { scopeReparseRepair } = await import("./recovery-operations.js");
536
692
  const outcome = await scopeReparseRepair(access, {
537
693
  epicRunId: parsed.epicRunId,
@@ -554,15 +710,23 @@ export async function runScopeReparseCommand(argv) {
554
710
  const DISPATCH_REMEDIATION_USAGE = [
555
711
  "Usage: epic-implementer dispatch-remediation --epic-run-id <id> --ticket-key <key> --context-file <path> [--json]",
556
712
  "",
557
- "Dispatch a remediation job for a ticket at code_review (or parked there),",
713
+ "Dispatch a remediation job for a ticket at code_review or ci (or parked there),",
558
714
  "carrying your driver context to the worker. The SERVER chooses the job type",
559
- "(remediate, or reconcile_overlap for a conflict/overlap ticket) and admits it",
715
+ "(remediate, or reconcile_overlap for a conflict/overlap ticket; ci_fix for a CI",
716
+ "failure the server's FRESH CI read routes to investigation) and admits it",
560
717
  "through the reconciler's own strict-serial lane, remediation budget, and",
561
- "dedup key. A parked ticket is resumed in the same step.",
718
+ "dedup key. A parked ticket is resumed in the same step (unguided only). An",
719
+ "infrastructure CI failure is refused by name and points to rerun-ci.",
562
720
  "",
563
721
  `--context-file must be non-empty UTF-8 text of at most DRIVER_CONTEXT_MAX_BYTES`,
564
722
  `(${DRIVER_CONTEXT_MAX_BYTES} bytes). It is rejected, never truncated, and is never`,
565
723
  "printed, logged, or echoed — it reaches only the worker's prompt.",
724
+ "",
725
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
726
+ "rendered it. The request is pinned to the subject that explanation showed and",
727
+ "is stored locally before it is sent, so re-running the same command after a",
728
+ "lost response re-sends the SAME request. A subject that has since changed is",
729
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
566
730
  ].join("\n");
567
731
  export async function runDispatchRemediationCommand(argv, deps = {}) {
568
732
  try {
@@ -581,9 +745,31 @@ export async function runDispatchRemediationCommand(argv, deps = {}) {
581
745
  throw new ConductorValidationError("--context-file could not be read; nothing was sent.");
582
746
  }
583
747
  const context = decodeDriverContextFile(bytes);
748
+ const binding = parsed.binding === undefined
749
+ ? null
750
+ : checkGuidedBinding(parsed.binding, "dispatch-remediation", {
751
+ epicRunId: parsed.epicRunId,
752
+ ticketKey: parsed.ticketKey,
753
+ });
584
754
  const access = await resolveRecoveryAccess(parsed.json);
585
755
  if (!access)
586
756
  return 1;
757
+ if (binding !== null) {
758
+ const { dispatchRemediation } = await import("./bridge-api-client.js");
759
+ return await runGuidedRepairCommand({
760
+ binding,
761
+ // Digested, never stored: the context stays out of the reference file.
762
+ payload: { ticket_key: parsed.ticketKey, context: context },
763
+ send: (guard) => dispatchRemediation(access, {
764
+ epicRunId: parsed.epicRunId,
765
+ ticketKey: parsed.ticketKey,
766
+ context,
767
+ guard,
768
+ }),
769
+ label: `ticket ${parsed.ticketKey}`,
770
+ stateLabel: "selected gate",
771
+ }, parsed.json);
772
+ }
587
773
  const { dispatchRemediationRepair } = await import("./recovery-operations.js");
588
774
  const outcome = await dispatchRemediationRepair(access, {
589
775
  epicRunId: parsed.epicRunId,
@@ -594,7 +780,7 @@ export async function runDispatchRemediationCommand(argv, deps = {}) {
594
780
  verb: "dispatch-remediation",
595
781
  epicRunId: parsed.epicRunId,
596
782
  targetLabel: `ticket ${parsed.ticketKey}`,
597
- stateLabel: "code_review gate",
783
+ stateLabel: "selected gate",
598
784
  }, parsed.json, (result) => [
599
785
  ` job: ${result.job_type || "unknown"}${result.job_id !== null ? ` (job ${result.job_id})` : ""}`,
600
786
  ]);
@@ -604,6 +790,195 @@ export async function runDispatchRemediationCommand(argv, deps = {}) {
604
790
  return 1;
605
791
  }
606
792
  }
793
+ // ---------------------------------------------------------------------------
794
+ // BAPI-1388 — rerun-ci
795
+ // ---------------------------------------------------------------------------
796
+ const RERUN_CI_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key", "--binding"]);
797
+ /** Exit codes: 0 applied (including an all-not-re-runnable no-op), 2 refused/stale, 3 unconfirmed, 1 other. */
798
+ export const RERUN_CI_EXIT_OK = 0;
799
+ export const RERUN_CI_EXIT_FAILED = 1;
800
+ export const RERUN_CI_EXIT_REFUSED = 2;
801
+ export const RERUN_CI_EXIT_UNCONFIRMED = 3;
802
+ const RERUN_CI_USAGE = [
803
+ "Usage: epic-implementer rerun-ci --epic-run-id <id> --ticket-key <key> --binding <token> [--json]",
804
+ "",
805
+ "Ask the CI provider to re-run a ticket's FAILED required checks at its bound head,",
806
+ "for a ticket at ci or parked on it. Copy the command exactly as the run",
807
+ "explanation rendered it: --binding pins the ticket version and head it showed.",
808
+ "The SERVER re-reads fresh CI evidence, re-runs each GitHub Actions workflow run at",
809
+ "most once per head, and reports every failed check as enqueued, deduplicated,",
810
+ "deferred, or not_re_runnable.",
811
+ "",
812
+ "rerun-ci NEVER re-runs the claude-review workflow. It charges no allowance and",
813
+ "changes no park state, and a re-run is not a verdict: when CI is green, use",
814
+ "`epic-implementer resume-ci` (a parked ticket) or let the reconciler observe it.",
815
+ "If the failure is a code defect, use `dispatch-remediation --context-file`.",
816
+ "",
817
+ "The request is stored locally before it is sent, so re-running the same command",
818
+ "after a lost response re-sends the SAME request. A subject that has since changed",
819
+ "is refused as stale with nothing written.",
820
+ "",
821
+ "Exit codes: 0 applied (including a no-op when nothing could be re-run); 2 refused",
822
+ "or stale; 3 unconfirmed; 1 other failure (including a server without this verb).",
823
+ ].join("\n");
824
+ export function parseRerunCiArgs(argv) {
825
+ const { values, bools } = tokenizeRecoveryFlags(argv, RERUN_CI_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
826
+ if (bools.has("--help")) {
827
+ return { epicRunId: "", ticketKey: "", binding: "", json: bools.has("--json"), help: true };
828
+ }
829
+ return {
830
+ epicRunId: requireFlag(values, "--epic-run-id"),
831
+ ticketKey: requireFlag(values, "--ticket-key"),
832
+ binding: requireFlag(values, "--binding"),
833
+ json: bools.has("--json"),
834
+ help: false,
835
+ };
836
+ }
837
+ const RERUN_CI_STATUS_TEXT = {
838
+ enqueued: "re-run requested",
839
+ deduplicated: "already requested",
840
+ deferred: "deferred (not requested; run the verb again after a fresh explain-run)",
841
+ not_re_runnable: "not re-runnable",
842
+ };
843
+ const RERUN_CI_REASON_TEXT = {
844
+ not_github_actions: "not a GitHub Actions check",
845
+ no_workflow_run_id: "no workflow run id",
846
+ claude_review_excluded: "claude-review is never re-run",
847
+ };
848
+ const RERUN_CI_OUTCOME_TEXT = {
849
+ enqueued: "re-runs requested",
850
+ partial: "partially requested (some runs deferred)",
851
+ nothing_to_enqueue: "nothing to enqueue (no failed check could be re-run)",
852
+ };
853
+ const RERUN_CI_NEXT_ACTION = {
854
+ enqueued: "Wait for the re-runs to finish, then read `epic-implementer explain-run`. When CI is " +
855
+ "green use `epic-implementer resume-ci` (a parked ticket); this verb did not mark CI passed.",
856
+ partial: "Some runs were not requested. Read `epic-implementer explain-run` and, if still " +
857
+ "offered, re-run the command it renders with its fresh binding.",
858
+ nothing_to_enqueue: "No re-run was possible. Read `epic-implementer explain-run`; for a code defect use " +
859
+ "`dispatch-remediation --context-file`.",
860
+ };
861
+ function rerunCiCheckLine(check) {
862
+ const identity = [
863
+ check.name ?? "(unnamed check)",
864
+ check.workflow_identity ? `workflow ${check.workflow_identity}` : null,
865
+ check.run_id !== null ? `run ${check.run_id}` : null,
866
+ ]
867
+ .filter((part) => part !== null)
868
+ .join(", ");
869
+ const reason = check.reason ? ` — ${RERUN_CI_REASON_TEXT[check.reason] ?? check.reason}` : "";
870
+ const job = check.job_id !== null ? ` (job ${check.job_id})` : "";
871
+ return ` - ${identity}: ${RERUN_CI_STATUS_TEXT[check.status] ?? check.status}${reason}${job}`;
872
+ }
873
+ /**
874
+ * Exported for tests: render one `rerun-ci` outcome. Success goes to stdout and
875
+ * every failure to stderr (`--json` included). An unsupported server is NEVER a
876
+ * success and says it is "not available on this server".
877
+ */
878
+ export function renderRerunCiOutcome(outcome, parsed, json) {
879
+ const label = `ticket ${parsed.ticketKey} (run ${parsed.epicRunId})`;
880
+ if (outcome.kind === "applied") {
881
+ const result = outcome.result;
882
+ if (result.committed && !result.event_recorded) {
883
+ console.error(`Warning: rerun-ci for ${label} COMMITTED, but its operator_verb_applied audit ` +
884
+ "event was not recorded. The action stands.");
885
+ }
886
+ if (json) {
887
+ console.log(JSON.stringify({
888
+ ok: true,
889
+ outcome: "applied",
890
+ request_id: outcome.requestId,
891
+ reference_reused: outcome.referenceReused,
892
+ result,
893
+ }));
894
+ return RERUN_CI_EXIT_OK;
895
+ }
896
+ const lines = [
897
+ `rerun-ci (guided) — ${label}`,
898
+ ` ticket: ${result.ticket_key}`,
899
+ ` head: ${result.head_sha ? "`" + result.head_sha + "`" : "unknown"}`,
900
+ ` outcome: ${RERUN_CI_OUTCOME_TEXT[result.outcome] ?? result.outcome}`,
901
+ " failed checks:",
902
+ ...result.checks.map(rerunCiCheckLine),
903
+ ` next action: ${RERUN_CI_NEXT_ACTION[result.outcome] ?? "Read `epic-implementer explain-run`."}`,
904
+ ` request: ${outcome.requestId}${outcome.referenceReused ? " (re-sent from the local reference)" : ""}`,
905
+ ` event recorded: ${result.event_recorded}`,
906
+ ];
907
+ console.log(lines.join("\n"));
908
+ return RERUN_CI_EXIT_OK;
909
+ }
910
+ if (outcome.kind === "refused" || outcome.kind === "stale") {
911
+ const payload = {
912
+ ok: false,
913
+ outcome: outcome.kind,
914
+ code: outcome.code,
915
+ message: outcome.message,
916
+ request_id: outcome.requestId,
917
+ nothing_written: true,
918
+ };
919
+ if (json) {
920
+ console.error(JSON.stringify(payload));
921
+ }
922
+ else {
923
+ console.error(`Refused: rerun-ci for ${label} — ${outcome.code}: ${outcome.message}. ` +
924
+ (outcome.kind === "stale"
925
+ ? "Nothing was written. The run changed since this command was rendered; re-read the run explanation."
926
+ : "Nothing was written."));
927
+ }
928
+ return RERUN_CI_EXIT_REFUSED;
929
+ }
930
+ if (outcome.kind === "unconfirmed") {
931
+ if (json) {
932
+ console.error(JSON.stringify({
933
+ ok: false,
934
+ outcome: "unconfirmed",
935
+ reason: outcome.reason,
936
+ message: outcome.message,
937
+ request_id: outcome.requestId,
938
+ }));
939
+ }
940
+ else {
941
+ console.error(`Unconfirmed: rerun-ci for ${label} — ${outcome.message}.`);
942
+ }
943
+ return RERUN_CI_EXIT_UNCONFIRMED;
944
+ }
945
+ if (json) {
946
+ console.error(JSON.stringify({ ok: false, outcome: outcome.kind, message: outcome.message }));
947
+ }
948
+ else {
949
+ console.error(`Failed: rerun-ci for ${label} — ${outcome.message}.`);
950
+ }
951
+ return RERUN_CI_EXIT_FAILED;
952
+ }
953
+ export async function runRerunCiCommand(argv) {
954
+ try {
955
+ const parsed = parseRerunCiArgs(argv);
956
+ if (parsed.help) {
957
+ console.log(RERUN_CI_USAGE);
958
+ return 0;
959
+ }
960
+ // Prove the token names THIS command, run, and ticket before credentials are
961
+ // resolved, so a mismatched binding reaches no one.
962
+ const binding = checkGuidedBinding(parsed.binding, "rerun-ci", {
963
+ epicRunId: parsed.epicRunId,
964
+ ticketKey: parsed.ticketKey,
965
+ });
966
+ const access = await resolveRecoveryAccess(parsed.json);
967
+ if (!access)
968
+ return RERUN_CI_EXIT_FAILED;
969
+ const { rerunCiRepair } = await import("./recovery-operations.js");
970
+ const outcome = await rerunCiRepair(access, {
971
+ epicRunId: parsed.epicRunId,
972
+ ticketKey: parsed.ticketKey,
973
+ binding,
974
+ });
975
+ return renderRerunCiOutcome(outcome, parsed, parsed.json);
976
+ }
977
+ catch (error) {
978
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
979
+ return RERUN_CI_EXIT_FAILED;
980
+ }
981
+ }
607
982
  const MERGE_CHILD_USAGE = [
608
983
  "Usage: epic-implementer merge-child --epic-run-id <id> --pr <number> [--json]",
609
984
  "",
@@ -651,9 +1026,9 @@ export async function runMergeChildCommand(argv) {
651
1026
  // ---------------------------------------------------------------------------
652
1027
  // BAPI-1176 — repair verbs: scope-catch-up, patch-policy, resume-run
653
1028
  // ---------------------------------------------------------------------------
654
- const SCOPE_CATCH_UP_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id"]);
655
- const PATCH_POLICY_VALUE_FLAGS = new Set(["--epic-run-id", "--patch-file"]);
656
- const RESUME_RUN_VALUE_FLAGS = new Set(["--epic-run-id"]);
1029
+ const SCOPE_CATCH_UP_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id", "--binding"]);
1030
+ const PATCH_POLICY_VALUE_FLAGS = new Set(["--epic-run-id", "--patch-file", "--binding"]);
1031
+ const RESUME_RUN_VALUE_FLAGS = new Set(["--epic-run-id", "--binding"]);
657
1032
  /** `--clear-quarantine` is a `resume-run` switch only; no other verb accepts it. */
658
1033
  const RESUME_RUN_BOOL_FLAGS = new Set(["--json", "--help", "--clear-quarantine"]);
659
1034
  export function parseScopeCatchUpArgs(argv) {
@@ -661,24 +1036,24 @@ export function parseScopeCatchUpArgs(argv) {
661
1036
  if (bools.has("--help")) {
662
1037
  return { epicRunId: "", scopeId: "", json: bools.has("--json"), help: true };
663
1038
  }
664
- return {
1039
+ return withBinding({
665
1040
  epicRunId: requireFlag(values, "--epic-run-id"),
666
1041
  scopeId: requireFlag(values, "--scope-id"),
667
1042
  json: bools.has("--json"),
668
1043
  help: false,
669
- };
1044
+ }, values);
670
1045
  }
671
1046
  export function parsePatchPolicyArgs(argv) {
672
1047
  const { values, bools } = tokenizeRecoveryFlags(argv, PATCH_POLICY_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
673
1048
  if (bools.has("--help")) {
674
1049
  return { epicRunId: "", patchFile: "", json: bools.has("--json"), help: true };
675
1050
  }
676
- return {
1051
+ return withBinding({
677
1052
  epicRunId: requireFlag(values, "--epic-run-id"),
678
1053
  patchFile: requireFlag(values, "--patch-file"),
679
1054
  json: bools.has("--json"),
680
1055
  help: false,
681
- };
1056
+ }, values);
682
1057
  }
683
1058
  export function parseResumeRunArgs(argv) {
684
1059
  const { values, bools } = tokenizeRecoveryFlags(argv, RESUME_RUN_VALUE_FLAGS, RESUME_RUN_BOOL_FLAGS);
@@ -690,12 +1065,12 @@ export function parseResumeRunArgs(argv) {
690
1065
  help: true,
691
1066
  };
692
1067
  }
693
- return {
1068
+ return withBinding({
694
1069
  epicRunId: requireFlag(values, "--epic-run-id"),
695
1070
  clearQuarantine: bools.has("--clear-quarantine"),
696
1071
  json: bools.has("--json"),
697
1072
  help: false,
698
- };
1073
+ }, values);
699
1074
  }
700
1075
  /**
701
1076
  * Validate and decode a `patch-policy` file's BYTES into a merge-patch object.
@@ -749,6 +1124,12 @@ const SCOPE_CATCH_UP_USAGE = [
749
1124
  "refused (SCOPE_CATCH_UP_REFUSED). The scope must be the one bound to the named",
750
1125
  "run, or the server refuses it (TICKET_NOT_IN_RUN) with nothing written. Prints",
751
1126
  "the scope lifecycle before → after; it does not wait for a scheduled parse.",
1127
+ "",
1128
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
1129
+ "rendered it. The request is pinned to the subject that explanation showed and",
1130
+ "is stored locally before it is sent, so re-running the same command after a",
1131
+ "lost response re-sends the SAME request. A subject that has since changed is",
1132
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
752
1133
  ].join("\n");
753
1134
  export async function runScopeCatchUpCommand(argv) {
754
1135
  try {
@@ -757,9 +1138,25 @@ export async function runScopeCatchUpCommand(argv) {
757
1138
  console.log(SCOPE_CATCH_UP_USAGE);
758
1139
  return 0;
759
1140
  }
1141
+ const binding = parsed.binding === undefined
1142
+ ? null
1143
+ : checkGuidedBinding(parsed.binding, "scope-catch-up", {
1144
+ epicRunId: parsed.epicRunId,
1145
+ scopeId: parsed.scopeId,
1146
+ });
760
1147
  const access = await resolveRecoveryAccess(parsed.json);
761
1148
  if (!access)
762
1149
  return 1;
1150
+ if (binding !== null) {
1151
+ const { scopeCatchUp } = await import("./bridge-api-client.js");
1152
+ return await runGuidedRepairCommand({
1153
+ binding,
1154
+ payload: { scope_id: parsed.scopeId },
1155
+ send: (guard) => scopeCatchUp(access, { epicRunId: parsed.epicRunId, scopeId: parsed.scopeId, guard }),
1156
+ label: `scope ${parsed.scopeId}`,
1157
+ stateLabel: "scope lifecycle",
1158
+ }, parsed.json);
1159
+ }
763
1160
  const { scopeCatchUpRepair } = await import("./recovery-operations.js");
764
1161
  const outcome = await scopeCatchUpRepair(access, {
765
1162
  epicRunId: parsed.epicRunId,
@@ -792,6 +1189,12 @@ const PATCH_POLICY_USAGE = [
792
1189
  "--patch-file must hold a non-empty JSON object of at most POLICY_PATCH_MAX_BYTES",
793
1190
  `(${POLICY_PATCH_MAX_BYTES} bytes). Its contents are never printed; the result names only`,
794
1191
  "the changed dotted paths and before/after policy digest tokens.",
1192
+ "",
1193
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
1194
+ "rendered it. The request is pinned to the subject that explanation showed and",
1195
+ "is stored locally before it is sent, so re-running the same command after a",
1196
+ "lost response re-sends the SAME request. A subject that has since changed is",
1197
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
795
1198
  ].join("\n");
796
1199
  export async function runPatchPolicyCommand(argv, deps = {}) {
797
1200
  try {
@@ -810,9 +1213,23 @@ export async function runPatchPolicyCommand(argv, deps = {}) {
810
1213
  throw new ConductorValidationError("--patch-file could not be read; nothing was sent.");
811
1214
  }
812
1215
  const mergePatch = decodePolicyPatchFile(bytes);
1216
+ const binding = parsed.binding === undefined
1217
+ ? null
1218
+ : checkGuidedBinding(parsed.binding, "patch-policy", { epicRunId: parsed.epicRunId });
813
1219
  const access = await resolveRecoveryAccess(parsed.json);
814
1220
  if (!access)
815
1221
  return 1;
1222
+ if (binding !== null) {
1223
+ const { patchPolicy } = await import("./bridge-api-client.js");
1224
+ return await runGuidedRepairCommand({
1225
+ binding,
1226
+ // Digested, never stored: no policy value reaches the reference file.
1227
+ payload: { merge_patch: mergePatch },
1228
+ send: (guard) => patchPolicy(access, { epicRunId: parsed.epicRunId, mergePatch, guard }),
1229
+ label: "run policy",
1230
+ stateLabel: "policy digest",
1231
+ }, parsed.json);
1232
+ }
816
1233
  const { patchPolicyRepair } = await import("./recovery-operations.js");
817
1234
  const outcome = await patchPolicyRepair(access, {
818
1235
  epicRunId: parsed.epicRunId,
@@ -845,6 +1262,12 @@ const RESUME_RUN_USAGE = [
845
1262
  "",
846
1263
  "If the response is lost or cannot be read, the resume may or may not have",
847
1264
  "committed: inspect the run read-only before any separately authorized retry.",
1265
+ "",
1266
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
1267
+ "rendered it. The request is pinned to the subject that explanation showed and",
1268
+ "is stored locally before it is sent, so re-running the same command after a",
1269
+ "lost response re-sends the SAME request. A subject that has since changed is",
1270
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
848
1271
  ].join("\n");
849
1272
  export async function runResumeRunCommand(argv) {
850
1273
  try {
@@ -853,9 +1276,29 @@ export async function runResumeRunCommand(argv) {
853
1276
  console.log(RESUME_RUN_USAGE);
854
1277
  return 0;
855
1278
  }
1279
+ const binding = parsed.binding === undefined
1280
+ ? null
1281
+ : checkGuidedBinding(parsed.binding, "resume-run", { epicRunId: parsed.epicRunId });
1282
+ if (binding !== null && binding.controlState === "quarantined" && !parsed.clearQuarantine) {
1283
+ throw new ConductorValidationError("This binding names a quarantined run; --clear-quarantine is required. Nothing was sent.");
1284
+ }
856
1285
  const access = await resolveRecoveryAccess(parsed.json);
857
1286
  if (!access)
858
1287
  return 1;
1288
+ if (binding !== null) {
1289
+ const { resumeRun } = await import("./bridge-api-client.js");
1290
+ return await runGuidedRepairCommand({
1291
+ binding,
1292
+ payload: { clear_quarantine: parsed.clearQuarantine },
1293
+ send: (guard) => resumeRun(access, {
1294
+ epicRunId: parsed.epicRunId,
1295
+ clearQuarantine: parsed.clearQuarantine,
1296
+ guard,
1297
+ }),
1298
+ label: parsed.clearQuarantine ? "run (clear quarantine)" : "run",
1299
+ stateLabel: "run state",
1300
+ }, parsed.json);
1301
+ }
859
1302
  const { resumeRunRepair } = await import("./recovery-operations.js");
860
1303
  const outcome = await resumeRunRepair(access, {
861
1304
  epicRunId: parsed.epicRunId,
@@ -1047,6 +1490,11 @@ export function renderContinueChildResult(result, target, json) {
1047
1490
  }
1048
1491
  console.error(`${label}: not continuable right now. Nothing was sent.`);
1049
1492
  console.error(currentLine(result.current));
1493
+ // BAPI-1344: the park's own guidance names the exit that remains (for a
1494
+ // first attempt with no recorded retained head, the run operator).
1495
+ if (result.current.parked && result.current.needs_human_reason) {
1496
+ console.error(`Park: ${result.current.needs_human_reason}`);
1497
+ }
1050
1498
  return 1;
1051
1499
  }
1052
1500
  if (result.kind === "unconfirmed") {
@@ -1162,3 +1610,238 @@ export async function runContinuationReceiptCommand(argv) {
1162
1610
  return 1;
1163
1611
  }
1164
1612
  }
1613
+ // ---------------------------------------------------------------------------
1614
+ // BAPI-1326 — resume-ci
1615
+ // ---------------------------------------------------------------------------
1616
+ const RESUME_CI_USAGE = [
1617
+ "Usage: epic-implementer resume-ci --epic-run-id <id> --ticket-key <key> [--json]",
1618
+ "",
1619
+ "Resume a child parked on a qualified CI stop (an expired CI wait, exhausted",
1620
+ "required-check repairs, or an unavailable verdict) once CI has finished green.",
1621
+ "The server re-reads the pull request, the specification, and the required",
1622
+ "checks, then returns CI to evaluation at the same head. It creates no job,",
1623
+ "grants no repair or new wait, keeps the original deadline, and does not mark",
1624
+ "CI passed or authorize review or merge.",
1625
+ "",
1626
+ "The operation is pinned locally before it is sent; re-running after a lost",
1627
+ "response reuses it and reports the original result. Nothing else is tried.",
1628
+ "",
1629
+ "Exit codes: 0 committed or replayed; 2 refused; 3 unconfirmed; 1 other failure.",
1630
+ ].join("\n");
1631
+ /** Distinct exit codes, so automation can tell a refusal from an unknown outcome. */
1632
+ export const RESUME_CI_EXIT_OK = 0;
1633
+ export const RESUME_CI_EXIT_FAILED = 1;
1634
+ export const RESUME_CI_EXIT_REFUSED = 2;
1635
+ export const RESUME_CI_EXIT_UNCONFIRMED = 3;
1636
+ /**
1637
+ * Fixed operator sentences, chosen by the server's structured code. No server,
1638
+ * provider, or exception text ever reaches the terminal.
1639
+ */
1640
+ const RESUME_CI_REFUSAL_SENTENCES = {
1641
+ TICKET_NOT_IN_RUN: "the ticket is not part of this run",
1642
+ CI_STOP_NOT_ELIGIBLE: "this CI park has no complete qualified stop record, so resume-ci does not apply",
1643
+ CI_RESUME_STALE_OPERATION: "the ticket is no longer on the CI stop this operation was made for, and its result is not retained",
1644
+ CI_RESUME_OPERATION_CONFLICT: "this operation already names a different CI recovery",
1645
+ ROW_VERSION_CONFLICT: "the ticket kept changing while the resume was being applied",
1646
+ RUN_NOT_DISPATCHABLE: "the run is paused, stopped, quarantined, or not active",
1647
+ CI_RESUME_BUDGET_EXHAUSTED: "the run's wall-clock or cost budget is spent, or could not be read",
1648
+ CI_RESUME_HELD: "the ticket is held by a completion, permission, declassification, or stale-specification hold",
1649
+ CI_RESUME_LIVE_WORK: "corrective work is, or may be, live for this ticket",
1650
+ CI_RESUME_SPEC_CHANGED: "the ticket's specification changed or could not be verified",
1651
+ CI_RESUME_POLICY_CHANGED: "the run's required-check policy changed, or is unavailable or unconfigured",
1652
+ CI_RESUME_BINDING_CHANGED: "the pull request, its head, or its target branch changed since the stop",
1653
+ CI_RESUME_CHECKS_NOT_GREEN: "the bound head's required checks are not currently green",
1654
+ CI_RESUME_VERDICT_UNAVAILABLE: "the CI provider's verdict could not be read in time",
1655
+ CI_RESUME_BUSY: "the ticket or run stayed busy for the whole bounded wait",
1656
+ };
1657
+ /** The next step for a refusal, where one is useful. */
1658
+ const RESUME_CI_REFUSAL_GUIDANCE = {
1659
+ CI_STOP_NOT_ELIGIBLE: "The existing explicit path is `epic-implementer unpark`, which RESETS the CI gate's " +
1660
+ "clock and repair allowance. If the park's origin is unclear, ask the run operator " +
1661
+ "to assess it first.",
1662
+ CI_RESUME_STALE_OPERATION: "Read `epic-implementer explain-run` for the ticket; start a new resume-ci only if " +
1663
+ "its current stop independently qualifies.",
1664
+ CI_RESUME_CHECKS_NOT_GREEN: "Wait for the required checks to finish green, then retry.",
1665
+ CI_RESUME_VERDICT_UNAVAILABLE: "Retry later. If the provider stays unavailable, escalate to the CI-platform operator.",
1666
+ CI_RESUME_BUSY: "Retry the same command; it reuses the same operation.",
1667
+ ROW_VERSION_CONFLICT: "Retry the same command; it reuses the same operation.",
1668
+ RUN_NOT_DISPATCHABLE: "Resume the run first (`epic-implementer resume-run`), then retry.",
1669
+ CI_RESUME_LIVE_WORK: "Retry when the running work has finished.",
1670
+ };
1671
+ const RESUME_CI_STOP_REASON_TEXT = {
1672
+ wait_expired: "an expired CI wait",
1673
+ repair_exhausted: "exhausted required-check repairs",
1674
+ verdict_unavailable: "an unavailable CI verdict",
1675
+ };
1676
+ const RESUME_CI_NOT_ELIGIBLE_TEXT = {
1677
+ ticket_not_found: "the ticket is not part of this run",
1678
+ not_parked: "the ticket is not parked",
1679
+ not_ci_park: "the ticket is not parked at the CI gate",
1680
+ no_typed_stop: "the CI park has no complete qualified stop record (a legacy or incomplete park)",
1681
+ stop_superseded: "the CI stop belongs to an earlier park episode",
1682
+ };
1683
+ /** The one consequence statement every committed result carries. */
1684
+ const RESUME_CI_CONSEQUENCE = "CI is back in evaluation at the same head; its verdict is pending. This action did NOT " +
1685
+ "mark CI passed, authorize review or merge, create a job, extend the deadline, or refund " +
1686
+ "any repair.";
1687
+ export function parseResumeCiArgs(argv) {
1688
+ return parseTicketRecoveryArgs(argv);
1689
+ }
1690
+ function resumeCiOperationSummary(op) {
1691
+ return {
1692
+ resumed_at: op.resumed_at,
1693
+ stop_reason: op.stop_reason,
1694
+ park_generation: op.park_generation,
1695
+ resumed_gate: op.resumed_gate,
1696
+ ci_gate_status: op.ci_gate_status,
1697
+ allowance_effect: op.allowance_effect,
1698
+ preserved_attempts: op.preserved_attempts,
1699
+ preserved_ci_infra_rerun_attempts_used: op.preserved_ci_infra_rerun_attempts_used,
1700
+ preserved_entry_attempts: op.preserved_entry_attempts,
1701
+ deadline_at: op.deadline_at,
1702
+ deadline_source: op.deadline_source,
1703
+ deadline_extended: op.deadline_extended,
1704
+ jobs_created: op.jobs_created,
1705
+ ci_passed_by_action: op.ci_passed_by_action,
1706
+ };
1707
+ }
1708
+ function allowanceLine(op) {
1709
+ const infra = op.preserved_ci_infra_rerun_attempts_used ?? 0;
1710
+ const entry = op.preserved_entry_attempts ?? 0;
1711
+ return (`Preserved allowances: repair attempts spent ${op.preserved_attempts}, ` +
1712
+ `infrastructure retries used ${infra}, entry attempts ${entry}.`);
1713
+ }
1714
+ function deadlineLine(op) {
1715
+ if (op.deadline_at === null)
1716
+ return "Deadline unchanged (not recorded).";
1717
+ const source = op.deadline_source === "post_repair" ? "post-repair window" : "original gate window";
1718
+ return `Deadline unchanged: ${op.deadline_at} (${source}).`;
1719
+ }
1720
+ function currentStateLine(current) {
1721
+ return (`Current state (read now, separate from the history above): ticket ${current.ticket_status ?? "unknown"}, ` +
1722
+ `gate ${current.current_gate ?? "unknown"}, CI gate ${current.ci_gate_status ?? "unknown"}, ` +
1723
+ `held by run control: ${current.held_by_run_control ? "yes" : "no"}.`);
1724
+ }
1725
+ /**
1726
+ * Exported for tests: render a `resume-ci` result.
1727
+ *
1728
+ * The outcome word leads the first line. Committed and replayed results go to
1729
+ * stdout with exit 0; refused, unconfirmed, and failed outcomes go to stderr with
1730
+ * their own non-zero codes. No operation key, stop binding, row version, SHA,
1731
+ * credential, provider error, or URL is ever printed, in either mode.
1732
+ */
1733
+ export function renderResumeCiResult(result, target, json) {
1734
+ const label = `ticket ${target.ticketKey} (run ${target.epicRunId})`;
1735
+ if (result.kind === "committed" || result.kind === "replayed") {
1736
+ const r = result.result;
1737
+ if (json) {
1738
+ console.log(JSON.stringify({
1739
+ ok: true,
1740
+ outcome: result.kind,
1741
+ historical: result.kind === "replayed",
1742
+ epic_run_id: r.epic_run_id,
1743
+ ticket_key: r.ticket_key,
1744
+ operation: resumeCiOperationSummary(r.operation),
1745
+ current: r.current,
1746
+ audit_recorded: r.audit_recorded,
1747
+ audit_warning: r.audit_warning,
1748
+ reference_reused: result.reusedReference,
1749
+ }));
1750
+ return RESUME_CI_EXIT_OK;
1751
+ }
1752
+ const reason = RESUME_CI_STOP_REASON_TEXT[r.operation.stop_reason] ?? "a CI stop";
1753
+ if (result.kind === "committed") {
1754
+ console.log(`Committed: resume-ci for ${label}, which was parked on ${reason}.`);
1755
+ console.log(RESUME_CI_CONSEQUENCE);
1756
+ console.log(allowanceLine(r.operation));
1757
+ console.log(deadlineLine(r.operation));
1758
+ console.log("Next: the reconciler's next identical green poll confirms CI; review and merge checks still apply.");
1759
+ }
1760
+ else {
1761
+ console.log(`Replayed historical result: resume-ci for ${label} committed earlier, at ` +
1762
+ `${r.operation.resumed_at}. Nothing was changed by this call.`);
1763
+ console.log(`History: the ticket was parked on ${reason} and was returned to CI evaluation.`);
1764
+ console.log(allowanceLine(r.operation));
1765
+ console.log(deadlineLine(r.operation));
1766
+ console.log(currentStateLine(r.current));
1767
+ console.log("This history does not show that CI is green now, or that review or merge may proceed.");
1768
+ }
1769
+ if (!r.audit_recorded && r.audit_warning) {
1770
+ console.log(`Warning: ${r.audit_warning}`);
1771
+ }
1772
+ return RESUME_CI_EXIT_OK;
1773
+ }
1774
+ if (result.kind === "refused" || result.kind === "not_eligible" || result.kind === "stale_reference") {
1775
+ const code = result.kind === "refused"
1776
+ ? result.errorCode
1777
+ : result.kind === "stale_reference"
1778
+ ? "CI_RESUME_STALE_OPERATION"
1779
+ : "CI_STOP_NOT_ELIGIBLE";
1780
+ const sentence = result.kind === "not_eligible"
1781
+ ? RESUME_CI_NOT_ELIGIBLE_TEXT[result.reason] ?? "resume-ci does not apply"
1782
+ : RESUME_CI_REFUSAL_SENTENCES[code] ?? "the server refused the request";
1783
+ const guidance = RESUME_CI_REFUSAL_GUIDANCE[code];
1784
+ if (json) {
1785
+ console.error(JSON.stringify({
1786
+ ok: false,
1787
+ outcome: "refused",
1788
+ refusal_code: code,
1789
+ ...(result.kind === "not_eligible" ? { local_reason: result.reason } : {}),
1790
+ message: sentence,
1791
+ ...(guidance ? { guidance } : {}),
1792
+ nothing_written: true,
1793
+ }));
1794
+ return RESUME_CI_EXIT_REFUSED;
1795
+ }
1796
+ console.error(`Refused: resume-ci for ${label} — ${sentence}. Nothing was written.`);
1797
+ if (guidance)
1798
+ console.error(guidance);
1799
+ return RESUME_CI_EXIT_REFUSED;
1800
+ }
1801
+ if (result.kind === "unconfirmed") {
1802
+ if (json) {
1803
+ console.error(JSON.stringify({
1804
+ ok: false,
1805
+ outcome: "unconfirmed",
1806
+ transport: result.transport,
1807
+ attempts: result.attempts,
1808
+ }));
1809
+ return RESUME_CI_EXIT_UNCONFIRMED;
1810
+ }
1811
+ console.error(`Unconfirmed: resume-ci for ${label} — no durable answer arrived after ${result.attempts} ` +
1812
+ `attempts (${result.transport}). The resume may or may not have committed.`);
1813
+ console.error("Re-run the same command later: it reuses this same operation and reports its original " +
1814
+ "result. Do not use unpark or another recovery in the meantime.");
1815
+ return RESUME_CI_EXIT_UNCONFIRMED;
1816
+ }
1817
+ const message = result.kind === "unavailable" ? result.message : "the outcome could not be read";
1818
+ if (json) {
1819
+ console.error(JSON.stringify({ ok: false, outcome: "failed", message }));
1820
+ }
1821
+ else {
1822
+ console.error(`Failed: resume-ci for ${label} — ${message}.`);
1823
+ }
1824
+ return RESUME_CI_EXIT_FAILED;
1825
+ }
1826
+ export async function runResumeCiCommand(argv) {
1827
+ try {
1828
+ const parsed = parseResumeCiArgs(argv);
1829
+ if (parsed.help) {
1830
+ console.log(RESUME_CI_USAGE);
1831
+ return 0;
1832
+ }
1833
+ const access = await resolveRecoveryAccess(parsed.json);
1834
+ if (!access)
1835
+ return RESUME_CI_EXIT_FAILED;
1836
+ const { resumeCiOnce } = await import("./recovery-operations.js");
1837
+ const result = await resumeCiOnce(access, {
1838
+ epicRunId: parsed.epicRunId,
1839
+ ticketKey: parsed.ticketKey,
1840
+ });
1841
+ return renderResumeCiResult(result, parsed, parsed.json);
1842
+ }
1843
+ catch (error) {
1844
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
1845
+ return RESUME_CI_EXIT_FAILED;
1846
+ }
1847
+ }