@bridge_gpt/mcp-server 0.2.62 → 0.2.66

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 (113) hide show
  1. package/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
@@ -1,13 +1,15 @@
1
1
  /**
2
- * `conductor` operator recovery and repair verbs — ten CLI-only commands.
2
+ * `conductor` operator recovery and repair verbs — thirteen 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
- * `scope-catch-up`, `patch-policy`, `resume-run`.
7
+ * `scope-catch-up`, `patch-policy`, `resume-run`. BAPI-1300's parked-child
8
+ * continuation pair: `continue-child` and the read-only `continuation-receipt`.
9
+ * BAPI-1326's allowance-preserving CI resume: `resume-ci`.
8
10
  *
9
11
  * CLI-only by design — this is an explicit ticket non-goal, not an oversight:
10
- * none of these ten command names is ever registered as an MCP tool, tool
12
+ * none of these thirteen command names is ever registered as an MCP tool, tool
11
13
  * description, or generated tool-schema input (`index-static.test.ts` guards
12
14
  * this). They exist so an operator (or `plane down`, via
13
15
  * `recovery-operations.ts`) can recover a stuck run or ticket WITHOUT
@@ -23,7 +25,8 @@
23
25
  */
24
26
  import { readFile } from "node:fs/promises";
25
27
  import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
26
- import { DRIVER_CONTEXT_MAX_BYTES, POLICY_PATCH_MAX_BYTES } from "./repair-contract.js";
28
+ import { DRIVER_CONTEXT_MAX_BYTES, POLICY_PATCH_MAX_BYTES, RESUME_RUN_UNCONFIRMED_MESSAGE, } from "./repair-contract.js";
29
+ import { decodeGuidedBinding, GuidedBindingError, } from "./guided-repair.js";
27
30
  // ---------------------------------------------------------------------------
28
31
  // Flag parsing
29
32
  // ---------------------------------------------------------------------------
@@ -43,6 +46,12 @@ const REJECTED_VERSION_FLAGS = new Set([
43
46
  "--expected_row_version",
44
47
  "--expected-generation",
45
48
  "--expected_generation",
49
+ // BAPI-1300: the continuation's park-episode binding is read once and stored
50
+ // by the client itself; an operator never supplies it.
51
+ "--park-generation",
52
+ "--park_generation",
53
+ "--source-park-generation",
54
+ "--source_park_generation",
46
55
  ]);
47
56
  /**
48
57
  * Tokenize `--flag value` / `--flag=value` / boolean flags for the recovery
@@ -266,16 +275,52 @@ const ADOPT_CURRENT_HEAD_USAGE = [
266
275
  "off its anchored commit: adopt the CURRENT PR head and unpark in one step.",
267
276
  "Retries a bounded number of times on its own if the ticket changes",
268
277
  "concurrently — never accepts a version counter.",
278
+ "",
279
+ "For a ticket parked as unexplained_review_head_drift this is \"Adopt current",
280
+ "head and resume CI\": the server reads the provider's current head, accepts it,",
281
+ "keeps every repair allowance already spent (no repair attempt is granted), and",
282
+ "resumes the ticket at CI. No worker is started and nothing is marked passed —",
283
+ "CI and review must pass again on the accepted head. A repeat of the same",
284
+ "request reports the recorded result instead of adopting twice.",
269
285
  ].join("\n");
270
- function renderTicketRecoveryResult(result, json) {
286
+ /** BAPI-1310 (E1): the fixed allowance sentence the adoption result carries. */
287
+ const REVIEW_HEAD_ALLOWANCE_LINE = "Allowance effect: repair allowances preserved; acceptance does not grant another repair.";
288
+ /** BAPI-1310 (E1): the warning shown when run control still holds the run. */
289
+ export const REVIEW_HEAD_HELD_WARNING = "Head accepted; run remains held by run control";
290
+ /** Render the E1 adoption result (committed or replayed) to stdout. */
291
+ function renderReviewHeadRecovery(ticketKey, epicRunId, recovery) {
292
+ const outcome = recovery.result === "replayed"
293
+ ? "replayed — this operation already committed; nothing changed now"
294
+ : "committed";
295
+ console.log(`Ticket ${ticketKey} (run ${epicRunId}): Adopt current head and resume CI — ${outcome}.`);
296
+ console.log(` Previous expected head: ${recovery.previous_expected_head_sha}`);
297
+ console.log(` Committed accepted head: ${recovery.accepted_head_sha}`);
298
+ console.log(` ${REVIEW_HEAD_ALLOWANCE_LINE}`);
299
+ console.log(` Resumed at: ${recovery.resumed_gate}`);
300
+ console.log(` Current gate: ${recovery.current_gate ?? "unavailable"}`);
301
+ console.log(" CI and review must pass again on the accepted head; no worker was started and nothing was marked passed.");
302
+ if (recovery.held_by_run_control) {
303
+ console.log(` WARNING: ${REVIEW_HEAD_HELD_WARNING}.`);
304
+ }
305
+ }
306
+ export function renderTicketRecoveryResult(result, json) {
271
307
  if (json) {
272
308
  console.log(JSON.stringify(result));
273
309
  return result.ok ? 0 : 1;
274
310
  }
275
311
  if (result.ok) {
312
+ if (result.reviewHeadRecovery) {
313
+ renderReviewHeadRecovery(result.ticketKey, result.epicRunId, result.reviewHeadRecovery);
314
+ return 0;
315
+ }
276
316
  console.log(`Ticket ${result.ticketKey} (run ${result.epicRunId}): unparked — status: ${result.status}.`);
277
317
  return 0;
278
318
  }
319
+ if (result.kind === "refused") {
320
+ console.error(`Ticket ${result.ticketKey} (run ${result.epicRunId}): refused (${result.errorCode}) — ` +
321
+ `${result.message}. The ticket remains parked; nothing was changed.`);
322
+ return 1;
323
+ }
279
324
  if (result.kind === "ticket-not-found") {
280
325
  console.error(`Ticket ${result.ticketKey}: not found in run ${result.epicRunId}.`);
281
326
  return 1;
@@ -335,13 +380,26 @@ export async function runAdoptCurrentHeadAndUnparkCommand(argv) {
335
380
  // ---------------------------------------------------------------------------
336
381
  // BAPI-1154 — repair verbs: scope-reparse, dispatch-remediation, merge-child
337
382
  // ---------------------------------------------------------------------------
338
- const SCOPE_REPARSE_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id"]);
383
+ // BAPI-1333 — `--binding` is the ONE guided-repair input: the opaque token
384
+ // ExplainRun rendered, carrying the subject the command is pinned to. It is not
385
+ // a version counter the operator composes, so REJECTED_VERSION_FLAGS is unchanged.
386
+ const SCOPE_REPARSE_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id", "--binding"]);
339
387
  const DISPATCH_REMEDIATION_VALUE_FLAGS = new Set([
340
388
  "--epic-run-id",
341
389
  "--ticket-key",
342
390
  "--context-file",
391
+ "--binding",
343
392
  ]);
344
393
  const MERGE_CHILD_VALUE_FLAGS = new Set(["--epic-run-id", "--pr"]);
394
+ /**
395
+ * BAPI-1333 — attach `binding` ONLY when `--binding` was given, so an unguided
396
+ * parse result is exactly what it was before.
397
+ */
398
+ function withBinding(parsed, values) {
399
+ if (!values.has("--binding"))
400
+ return parsed;
401
+ return { ...parsed, binding: requireFlag(values, "--binding") };
402
+ }
345
403
  function requireFlag(values, flag) {
346
404
  const value = values.get(flag);
347
405
  if (value === undefined || value.trim().length === 0) {
@@ -354,25 +412,25 @@ export function parseScopeReparseArgs(argv) {
354
412
  if (bools.has("--help")) {
355
413
  return { epicRunId: "", scopeId: "", json: bools.has("--json"), help: true };
356
414
  }
357
- return {
415
+ return withBinding({
358
416
  epicRunId: requireFlag(values, "--epic-run-id"),
359
417
  scopeId: requireFlag(values, "--scope-id"),
360
418
  json: bools.has("--json"),
361
419
  help: false,
362
- };
420
+ }, values);
363
421
  }
364
422
  export function parseDispatchRemediationArgs(argv) {
365
423
  const { values, bools } = tokenizeRecoveryFlags(argv, DISPATCH_REMEDIATION_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
366
424
  if (bools.has("--help")) {
367
425
  return { epicRunId: "", ticketKey: "", contextFile: "", json: bools.has("--json"), help: true };
368
426
  }
369
- return {
427
+ return withBinding({
370
428
  epicRunId: requireFlag(values, "--epic-run-id"),
371
429
  ticketKey: requireFlag(values, "--ticket-key"),
372
430
  contextFile: requireFlag(values, "--context-file"),
373
431
  json: bools.has("--json"),
374
432
  help: false,
375
- };
433
+ }, values);
376
434
  }
377
435
  export function parseMergeChildArgs(argv) {
378
436
  const { values, bools } = tokenizeRecoveryFlags(argv, MERGE_CHILD_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
@@ -414,6 +472,124 @@ export function decodeDriverContextFile(bytes) {
414
472
  }
415
473
  return text;
416
474
  }
475
+ /** One repair result, however it ended, as the renderer needs it. */
476
+ // ---------------------------------------------------------------------------
477
+ // BAPI-1333 — guided (binding-pinned) repairs
478
+ // ---------------------------------------------------------------------------
479
+ /** Exit codes shared with `resume-ci`: 0 applied/no-op, 2 refused/stale, 3 unconfirmed. */
480
+ export const GUIDED_REPAIR_EXIT_OK = 0;
481
+ export const GUIDED_REPAIR_EXIT_FAILED = 1;
482
+ export const GUIDED_REPAIR_EXIT_REFUSED = 2;
483
+ export const GUIDED_REPAIR_EXIT_UNCONFIRMED = 3;
484
+ export const GUIDED_REPAIR_UNCONFIRMED_MESSAGE = "the outcome could not be confirmed: the server may or may not have applied this " +
485
+ "guided repair. Re-run the SAME command to re-send the same pinned request, or " +
486
+ "inspect the run read-only";
487
+ const GUIDED_STALE_GUIDANCE = "Nothing was written. The run changed since this command was rendered (possibly " +
488
+ "by this command's own earlier delivery). Re-read the run explanation before " +
489
+ "choosing another action.";
490
+ /**
491
+ * Decode `--binding` and prove it names THIS command, run, and target. Runs
492
+ * before credentials are resolved, so a mismatched token reaches no one.
493
+ */
494
+ function checkGuidedBinding(token, verb, expected) {
495
+ let binding;
496
+ try {
497
+ binding = decodeGuidedBinding(token);
498
+ }
499
+ catch (err) {
500
+ throw new ConductorValidationError(err instanceof GuidedBindingError ? err.message : "--binding could not be decoded; nothing was sent.");
501
+ }
502
+ if (binding.verb !== verb ||
503
+ binding.epicRunId !== expected.epicRunId ||
504
+ (expected.scopeId !== undefined && binding.scopeId !== expected.scopeId) ||
505
+ (expected.ticketKey !== undefined && binding.ticketKey !== expected.ticketKey)) {
506
+ throw new ConductorValidationError("--binding was rendered for a different command, run, or target; nothing was sent.");
507
+ }
508
+ return binding;
509
+ }
510
+ /**
511
+ * Send one guided repair and render its closed outcome. Success goes to stdout;
512
+ * every failure goes to stderr (JSON mode included), with the exit codes above.
513
+ */
514
+ async function runGuidedRepairCommand(input, json) {
515
+ const { guidedRepairOnce } = await import("./recovery-operations.js");
516
+ const { guidedPayloadDigest } = await import("./guided-repair.js");
517
+ const outcome = await guidedRepairOnce({
518
+ binding: input.binding,
519
+ payloadDigest: guidedPayloadDigest(input.payload),
520
+ send: input.send,
521
+ unconfirmedMessage: GUIDED_REPAIR_UNCONFIRMED_MESSAGE,
522
+ });
523
+ const verb = input.binding.verb;
524
+ const runId = input.binding.epicRunId;
525
+ if (outcome.kind === "committed" || outcome.kind === "no_op") {
526
+ const result = outcome.result;
527
+ if (outcome.kind === "committed" && !result.event_recorded) {
528
+ console.error(`Warning: ${verb} on run ${runId} (${input.label}) COMMITTED, but its ` +
529
+ "operator_verb_applied audit event was not recorded. The action stands.");
530
+ }
531
+ if (json) {
532
+ console.log(JSON.stringify({
533
+ ok: true,
534
+ outcome: outcome.kind,
535
+ request_id: outcome.requestId,
536
+ reference_reused: outcome.referenceReused,
537
+ result,
538
+ }));
539
+ return GUIDED_REPAIR_EXIT_OK;
540
+ }
541
+ console.log([
542
+ `${verb} (guided) — run ${runId}`,
543
+ ` target: ${input.label}`,
544
+ ` ${input.stateLabel}: ${result.before ?? "none"} → ${result.after ?? "none"}`,
545
+ ` outcome: ${outcome.kind === "committed" ? "committed" : "no-op (already in effect; nothing written)"}`,
546
+ ` request: ${outcome.requestId}${outcome.referenceReused ? " (re-sent from the local reference)" : ""}`,
547
+ ` event recorded: ${result.event_recorded}`,
548
+ ].join("\n"));
549
+ return GUIDED_REPAIR_EXIT_OK;
550
+ }
551
+ if (outcome.kind === "stale" || outcome.kind === "refused") {
552
+ const payload = {
553
+ ok: false,
554
+ outcome: outcome.kind,
555
+ code: outcome.code,
556
+ message: outcome.message,
557
+ request_id: outcome.requestId,
558
+ nothing_written: true,
559
+ };
560
+ if (json) {
561
+ console.error(JSON.stringify(payload));
562
+ }
563
+ else {
564
+ console.error(`Error: ${verb} ${outcome.kind === "stale" ? "is stale" : "refused"} on run ${runId} ` +
565
+ `(${input.label}) — ${outcome.code}: ${outcome.message}` +
566
+ (outcome.kind === "stale" ? ` ${GUIDED_STALE_GUIDANCE}` : ""));
567
+ }
568
+ return GUIDED_REPAIR_EXIT_REFUSED;
569
+ }
570
+ if (outcome.kind === "unconfirmed") {
571
+ if (json) {
572
+ console.error(JSON.stringify({
573
+ ok: false,
574
+ outcome: "unconfirmed",
575
+ message: outcome.message,
576
+ request_id: outcome.requestId,
577
+ reference_kept: true,
578
+ }));
579
+ }
580
+ else {
581
+ console.error(`Error: ${verb} on run ${runId} (${input.label}) — ${outcome.message}.`);
582
+ }
583
+ return GUIDED_REPAIR_EXIT_UNCONFIRMED;
584
+ }
585
+ if (json) {
586
+ console.error(JSON.stringify({ ok: false, outcome: "unavailable", message: outcome.message }));
587
+ }
588
+ else {
589
+ console.error(`Error: ${verb} on run ${runId} (${input.label}) — ${outcome.message}.`);
590
+ }
591
+ return GUIDED_REPAIR_EXIT_FAILED;
592
+ }
417
593
  /**
418
594
  * Render a repair outcome. Human mode prints the verb and run first, the target
419
595
  * second, ONE labeled `Before → After` pair third, then whether this call
@@ -440,7 +616,7 @@ function renderRepairResult(outcome, input, json, extraLines = () => []) {
440
616
  }
441
617
  console.log(JSON.stringify(outcome.kind === "refused"
442
618
  ? { ok: false, kind: "refused", code: outcome.code, message: outcome.message }
443
- : { ok: false, kind: "unavailable", message: outcome.message }));
619
+ : { ok: false, kind: "unavailable", message: input.unavailableMessage ?? outcome.message }));
444
620
  return 1;
445
621
  }
446
622
  if (!outcome.ok) {
@@ -448,6 +624,10 @@ function renderRepairResult(outcome, input, json, extraLines = () => []) {
448
624
  console.error(`Error: ${input.verb} refused on run ${input.epicRunId} (${input.targetLabel}) — ` +
449
625
  `${outcome.code}: ${outcome.message}`);
450
626
  }
627
+ else if (input.unavailableMessage) {
628
+ console.error(`Error: ${input.verb} on run ${input.epicRunId} (${input.targetLabel}) — ` +
629
+ `${input.unavailableMessage}.`);
630
+ }
451
631
  else {
452
632
  console.error(`Error: ${input.verb} could not complete on run ${input.epicRunId} ` +
453
633
  `(${input.targetLabel}) — ${outcome.message}.`);
@@ -474,6 +654,12 @@ const SCOPE_REPARSE_USAGE = [
474
654
  "schedule its parse. The scope must be the one bound to the named run, or the",
475
655
  "server refuses it (TICKET_NOT_IN_RUN) with nothing written. Prints the scope",
476
656
  "lifecycle before → after; it does not wait for the parse to finish.",
657
+ "",
658
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
659
+ "rendered it. The request is pinned to the subject that explanation showed and",
660
+ "is stored locally before it is sent, so re-running the same command after a",
661
+ "lost response re-sends the SAME request. A subject that has since changed is",
662
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
477
663
  ].join("\n");
478
664
  export async function runScopeReparseCommand(argv) {
479
665
  try {
@@ -482,9 +668,25 @@ export async function runScopeReparseCommand(argv) {
482
668
  console.log(SCOPE_REPARSE_USAGE);
483
669
  return 0;
484
670
  }
671
+ const binding = parsed.binding === undefined
672
+ ? null
673
+ : checkGuidedBinding(parsed.binding, "scope-reparse", {
674
+ epicRunId: parsed.epicRunId,
675
+ scopeId: parsed.scopeId,
676
+ });
485
677
  const access = await resolveRecoveryAccess(parsed.json);
486
678
  if (!access)
487
679
  return 1;
680
+ if (binding !== null) {
681
+ const { scopeReparse } = await import("./bridge-api-client.js");
682
+ return await runGuidedRepairCommand({
683
+ binding,
684
+ payload: { scope_id: parsed.scopeId },
685
+ send: (guard) => scopeReparse(access, { epicRunId: parsed.epicRunId, scopeId: parsed.scopeId, guard }),
686
+ label: `scope ${parsed.scopeId}`,
687
+ stateLabel: "scope lifecycle",
688
+ }, parsed.json);
689
+ }
488
690
  const { scopeReparseRepair } = await import("./recovery-operations.js");
489
691
  const outcome = await scopeReparseRepair(access, {
490
692
  epicRunId: parsed.epicRunId,
@@ -516,6 +718,12 @@ const DISPATCH_REMEDIATION_USAGE = [
516
718
  `--context-file must be non-empty UTF-8 text of at most DRIVER_CONTEXT_MAX_BYTES`,
517
719
  `(${DRIVER_CONTEXT_MAX_BYTES} bytes). It is rejected, never truncated, and is never`,
518
720
  "printed, logged, or echoed — it reaches only the worker's prompt.",
721
+ "",
722
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
723
+ "rendered it. The request is pinned to the subject that explanation showed and",
724
+ "is stored locally before it is sent, so re-running the same command after a",
725
+ "lost response re-sends the SAME request. A subject that has since changed is",
726
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
519
727
  ].join("\n");
520
728
  export async function runDispatchRemediationCommand(argv, deps = {}) {
521
729
  try {
@@ -534,9 +742,31 @@ export async function runDispatchRemediationCommand(argv, deps = {}) {
534
742
  throw new ConductorValidationError("--context-file could not be read; nothing was sent.");
535
743
  }
536
744
  const context = decodeDriverContextFile(bytes);
745
+ const binding = parsed.binding === undefined
746
+ ? null
747
+ : checkGuidedBinding(parsed.binding, "dispatch-remediation", {
748
+ epicRunId: parsed.epicRunId,
749
+ ticketKey: parsed.ticketKey,
750
+ });
537
751
  const access = await resolveRecoveryAccess(parsed.json);
538
752
  if (!access)
539
753
  return 1;
754
+ if (binding !== null) {
755
+ const { dispatchRemediation } = await import("./bridge-api-client.js");
756
+ return await runGuidedRepairCommand({
757
+ binding,
758
+ // Digested, never stored: the context stays out of the reference file.
759
+ payload: { ticket_key: parsed.ticketKey, context: context },
760
+ send: (guard) => dispatchRemediation(access, {
761
+ epicRunId: parsed.epicRunId,
762
+ ticketKey: parsed.ticketKey,
763
+ context,
764
+ guard,
765
+ }),
766
+ label: `ticket ${parsed.ticketKey}`,
767
+ stateLabel: "code_review gate",
768
+ }, parsed.json);
769
+ }
540
770
  const { dispatchRemediationRepair } = await import("./recovery-operations.js");
541
771
  const outcome = await dispatchRemediationRepair(access, {
542
772
  epicRunId: parsed.epicRunId,
@@ -604,9 +834,9 @@ export async function runMergeChildCommand(argv) {
604
834
  // ---------------------------------------------------------------------------
605
835
  // BAPI-1176 — repair verbs: scope-catch-up, patch-policy, resume-run
606
836
  // ---------------------------------------------------------------------------
607
- const SCOPE_CATCH_UP_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id"]);
608
- const PATCH_POLICY_VALUE_FLAGS = new Set(["--epic-run-id", "--patch-file"]);
609
- const RESUME_RUN_VALUE_FLAGS = new Set(["--epic-run-id"]);
837
+ const SCOPE_CATCH_UP_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id", "--binding"]);
838
+ const PATCH_POLICY_VALUE_FLAGS = new Set(["--epic-run-id", "--patch-file", "--binding"]);
839
+ const RESUME_RUN_VALUE_FLAGS = new Set(["--epic-run-id", "--binding"]);
610
840
  /** `--clear-quarantine` is a `resume-run` switch only; no other verb accepts it. */
611
841
  const RESUME_RUN_BOOL_FLAGS = new Set(["--json", "--help", "--clear-quarantine"]);
612
842
  export function parseScopeCatchUpArgs(argv) {
@@ -614,24 +844,24 @@ export function parseScopeCatchUpArgs(argv) {
614
844
  if (bools.has("--help")) {
615
845
  return { epicRunId: "", scopeId: "", json: bools.has("--json"), help: true };
616
846
  }
617
- return {
847
+ return withBinding({
618
848
  epicRunId: requireFlag(values, "--epic-run-id"),
619
849
  scopeId: requireFlag(values, "--scope-id"),
620
850
  json: bools.has("--json"),
621
851
  help: false,
622
- };
852
+ }, values);
623
853
  }
624
854
  export function parsePatchPolicyArgs(argv) {
625
855
  const { values, bools } = tokenizeRecoveryFlags(argv, PATCH_POLICY_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
626
856
  if (bools.has("--help")) {
627
857
  return { epicRunId: "", patchFile: "", json: bools.has("--json"), help: true };
628
858
  }
629
- return {
859
+ return withBinding({
630
860
  epicRunId: requireFlag(values, "--epic-run-id"),
631
861
  patchFile: requireFlag(values, "--patch-file"),
632
862
  json: bools.has("--json"),
633
863
  help: false,
634
- };
864
+ }, values);
635
865
  }
636
866
  export function parseResumeRunArgs(argv) {
637
867
  const { values, bools } = tokenizeRecoveryFlags(argv, RESUME_RUN_VALUE_FLAGS, RESUME_RUN_BOOL_FLAGS);
@@ -643,12 +873,12 @@ export function parseResumeRunArgs(argv) {
643
873
  help: true,
644
874
  };
645
875
  }
646
- return {
876
+ return withBinding({
647
877
  epicRunId: requireFlag(values, "--epic-run-id"),
648
878
  clearQuarantine: bools.has("--clear-quarantine"),
649
879
  json: bools.has("--json"),
650
880
  help: false,
651
- };
881
+ }, values);
652
882
  }
653
883
  /**
654
884
  * Validate and decode a `patch-policy` file's BYTES into a merge-patch object.
@@ -702,6 +932,12 @@ const SCOPE_CATCH_UP_USAGE = [
702
932
  "refused (SCOPE_CATCH_UP_REFUSED). The scope must be the one bound to the named",
703
933
  "run, or the server refuses it (TICKET_NOT_IN_RUN) with nothing written. Prints",
704
934
  "the scope lifecycle before → after; it does not wait for a scheduled parse.",
935
+ "",
936
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
937
+ "rendered it. The request is pinned to the subject that explanation showed and",
938
+ "is stored locally before it is sent, so re-running the same command after a",
939
+ "lost response re-sends the SAME request. A subject that has since changed is",
940
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
705
941
  ].join("\n");
706
942
  export async function runScopeCatchUpCommand(argv) {
707
943
  try {
@@ -710,9 +946,25 @@ export async function runScopeCatchUpCommand(argv) {
710
946
  console.log(SCOPE_CATCH_UP_USAGE);
711
947
  return 0;
712
948
  }
949
+ const binding = parsed.binding === undefined
950
+ ? null
951
+ : checkGuidedBinding(parsed.binding, "scope-catch-up", {
952
+ epicRunId: parsed.epicRunId,
953
+ scopeId: parsed.scopeId,
954
+ });
713
955
  const access = await resolveRecoveryAccess(parsed.json);
714
956
  if (!access)
715
957
  return 1;
958
+ if (binding !== null) {
959
+ const { scopeCatchUp } = await import("./bridge-api-client.js");
960
+ return await runGuidedRepairCommand({
961
+ binding,
962
+ payload: { scope_id: parsed.scopeId },
963
+ send: (guard) => scopeCatchUp(access, { epicRunId: parsed.epicRunId, scopeId: parsed.scopeId, guard }),
964
+ label: `scope ${parsed.scopeId}`,
965
+ stateLabel: "scope lifecycle",
966
+ }, parsed.json);
967
+ }
716
968
  const { scopeCatchUpRepair } = await import("./recovery-operations.js");
717
969
  const outcome = await scopeCatchUpRepair(access, {
718
970
  epicRunId: parsed.epicRunId,
@@ -745,6 +997,12 @@ const PATCH_POLICY_USAGE = [
745
997
  "--patch-file must hold a non-empty JSON object of at most POLICY_PATCH_MAX_BYTES",
746
998
  `(${POLICY_PATCH_MAX_BYTES} bytes). Its contents are never printed; the result names only`,
747
999
  "the changed dotted paths and before/after policy digest tokens.",
1000
+ "",
1001
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
1002
+ "rendered it. The request is pinned to the subject that explanation showed and",
1003
+ "is stored locally before it is sent, so re-running the same command after a",
1004
+ "lost response re-sends the SAME request. A subject that has since changed is",
1005
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
748
1006
  ].join("\n");
749
1007
  export async function runPatchPolicyCommand(argv, deps = {}) {
750
1008
  try {
@@ -763,9 +1021,23 @@ export async function runPatchPolicyCommand(argv, deps = {}) {
763
1021
  throw new ConductorValidationError("--patch-file could not be read; nothing was sent.");
764
1022
  }
765
1023
  const mergePatch = decodePolicyPatchFile(bytes);
1024
+ const binding = parsed.binding === undefined
1025
+ ? null
1026
+ : checkGuidedBinding(parsed.binding, "patch-policy", { epicRunId: parsed.epicRunId });
766
1027
  const access = await resolveRecoveryAccess(parsed.json);
767
1028
  if (!access)
768
1029
  return 1;
1030
+ if (binding !== null) {
1031
+ const { patchPolicy } = await import("./bridge-api-client.js");
1032
+ return await runGuidedRepairCommand({
1033
+ binding,
1034
+ // Digested, never stored: no policy value reaches the reference file.
1035
+ payload: { merge_patch: mergePatch },
1036
+ send: (guard) => patchPolicy(access, { epicRunId: parsed.epicRunId, mergePatch, guard }),
1037
+ label: "run policy",
1038
+ stateLabel: "policy digest",
1039
+ }, parsed.json);
1040
+ }
769
1041
  const { patchPolicyRepair } = await import("./recovery-operations.js");
770
1042
  const outcome = await patchPolicyRepair(access, {
771
1043
  epicRunId: parsed.epicRunId,
@@ -788,11 +1060,22 @@ export async function runPatchPolicyCommand(argv, deps = {}) {
788
1060
  const RESUME_RUN_USAGE = [
789
1061
  "Usage: epic-implementer resume-run --epic-run-id <id> [--clear-quarantine] [--json]",
790
1062
  "",
791
- "Clear a paused run's control record and return it to active, after the server",
792
- "re-checks the run's activation requirements. A quarantined run resumes only with",
793
- "--clear-quarantine; a stopped run never resumes (start a new run). A run that is",
794
- "already active with no control record is a successful no-op. The server retries",
795
- "a concurrent control change itself — never supply a generation.",
1063
+ "Return a held run to active, after the server re-checks the run's activation",
1064
+ "requirements. A paused run's control record is cleared. A run blocked by an",
1065
+ "eligible integration block has that block lifted (there is no control record",
1066
+ "to clear); the server decides eligibility. A quarantined run resumes only with",
1067
+ "--clear-quarantine; a stopped run never resumes (start a new run). A run that",
1068
+ "is already active and unheld is a successful no-op (already current). The",
1069
+ "server retries a concurrent control change itself — never supply a generation.",
1070
+ "",
1071
+ "If the response is lost or cannot be read, the resume may or may not have",
1072
+ "committed: inspect the run read-only before any separately authorized retry.",
1073
+ "",
1074
+ "Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
1075
+ "rendered it. The request is pinned to the subject that explanation showed and",
1076
+ "is stored locally before it is sent, so re-running the same command after a",
1077
+ "lost response re-sends the SAME request. A subject that has since changed is",
1078
+ "refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
796
1079
  ].join("\n");
797
1080
  export async function runResumeRunCommand(argv) {
798
1081
  try {
@@ -801,9 +1084,29 @@ export async function runResumeRunCommand(argv) {
801
1084
  console.log(RESUME_RUN_USAGE);
802
1085
  return 0;
803
1086
  }
1087
+ const binding = parsed.binding === undefined
1088
+ ? null
1089
+ : checkGuidedBinding(parsed.binding, "resume-run", { epicRunId: parsed.epicRunId });
1090
+ if (binding !== null && binding.controlState === "quarantined" && !parsed.clearQuarantine) {
1091
+ throw new ConductorValidationError("This binding names a quarantined run; --clear-quarantine is required. Nothing was sent.");
1092
+ }
804
1093
  const access = await resolveRecoveryAccess(parsed.json);
805
1094
  if (!access)
806
1095
  return 1;
1096
+ if (binding !== null) {
1097
+ const { resumeRun } = await import("./bridge-api-client.js");
1098
+ return await runGuidedRepairCommand({
1099
+ binding,
1100
+ payload: { clear_quarantine: parsed.clearQuarantine },
1101
+ send: (guard) => resumeRun(access, {
1102
+ epicRunId: parsed.epicRunId,
1103
+ clearQuarantine: parsed.clearQuarantine,
1104
+ guard,
1105
+ }),
1106
+ label: parsed.clearQuarantine ? "run (clear quarantine)" : "run",
1107
+ stateLabel: "run state",
1108
+ }, parsed.json);
1109
+ }
807
1110
  const { resumeRunRepair } = await import("./recovery-operations.js");
808
1111
  const outcome = await resumeRunRepair(access, {
809
1112
  epicRunId: parsed.epicRunId,
@@ -812,8 +1115,9 @@ export async function runResumeRunCommand(argv) {
812
1115
  return renderRepairResult(outcome, {
813
1116
  verb: "resume-run",
814
1117
  epicRunId: parsed.epicRunId,
815
- targetLabel: parsed.clearQuarantine ? "run control (clear quarantine)" : "run control",
816
- stateLabel: "run control",
1118
+ targetLabel: parsed.clearQuarantine ? "run (clear quarantine)" : "run",
1119
+ stateLabel: "run state",
1120
+ unavailableMessage: RESUME_RUN_UNCONFIRMED_MESSAGE,
817
1121
  }, parsed.json);
818
1122
  }
819
1123
  catch (error) {
@@ -821,3 +1125,531 @@ export async function runResumeRunCommand(argv) {
821
1125
  return 1;
822
1126
  }
823
1127
  }
1128
+ // ---------------------------------------------------------------------------
1129
+ // continue-child / continuation-receipt (BAPI-1300)
1130
+ // ---------------------------------------------------------------------------
1131
+ const CONTINUE_CHILD_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key", "--reason"]);
1132
+ const CONTINUATION_RECEIPT_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key", "--request-id"]);
1133
+ const MAX_REASON_CHARS = 500;
1134
+ const CONTINUE_CHILD_USAGE = [
1135
+ "Usage: epic-implementer continue-child --epic-run-id <id> --ticket-key <key> [--reason <text>] [--json]",
1136
+ "",
1137
+ "Continue a parked child's CURRENT park episode without resetting it: the same",
1138
+ "attempt resumes on its retained work with only the allowance it has left (no",
1139
+ "fresh budget, no counter or clock reset — use unpark for that). The server",
1140
+ "commits one job and one durable receipt together, at most once per episode.",
1141
+ "",
1142
+ "If the response is lost, the outcome is reported UNCONFIRMED, never refused or",
1143
+ "committed. Re-running continue-child reuses the SAME request (it never",
1144
+ "approves a new head or episode); continuation-receipt looks the receipt up.",
1145
+ "Never accepts a version counter or a park generation.",
1146
+ ].join("\n");
1147
+ const CONTINUATION_RECEIPT_USAGE = [
1148
+ "Usage: epic-implementer continuation-receipt --epic-run-id <id> --ticket-key <key> [--request-id <id>] [--json]",
1149
+ "",
1150
+ "Read-only. Show the child's current park episode and, for the pending local",
1151
+ "continuation request (or --request-id), whether its receipt proves a commit.",
1152
+ "A missing receipt is not a refusal. Looking history up authorizes nothing.",
1153
+ ].join("\n");
1154
+ function parseContinuationArgs(argv, valueFlags) {
1155
+ const { values, bools } = tokenizeRecoveryFlags(argv, valueFlags, RECOVERY_BOOL_FLAGS);
1156
+ if (bools.has("--help")) {
1157
+ return { epicRunId: "", ticketKey: "", json: bools.has("--json"), help: true };
1158
+ }
1159
+ const epicRunId = requireFlag(values, "--epic-run-id");
1160
+ const ticketKey = requireFlag(values, "--ticket-key");
1161
+ const reason = values.get("--reason")?.trim();
1162
+ if (reason !== undefined && reason.length > MAX_REASON_CHARS) {
1163
+ throw new ConductorValidationError(`Flag "--reason" must be at most ${MAX_REASON_CHARS} characters.`);
1164
+ }
1165
+ const requestId = values.get("--request-id")?.trim();
1166
+ if (requestId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$/.test(requestId)) {
1167
+ throw new ConductorValidationError('Flag "--request-id" is not a valid request id.');
1168
+ }
1169
+ return {
1170
+ epicRunId,
1171
+ ticketKey,
1172
+ ...(reason ? { reason } : {}),
1173
+ ...(requestId ? { requestId } : {}),
1174
+ json: bools.has("--json"),
1175
+ help: false,
1176
+ };
1177
+ }
1178
+ /** Exported for tests: parse `continue-child` arguments. */
1179
+ export function parseContinueChildArgs(argv) {
1180
+ return parseContinuationArgs(argv, CONTINUE_CHILD_VALUE_FLAGS);
1181
+ }
1182
+ /** Exported for tests: parse `continuation-receipt` arguments. */
1183
+ export function parseContinuationReceiptArgs(argv) {
1184
+ return parseContinuationArgs(argv, CONTINUATION_RECEIPT_VALUE_FLAGS);
1185
+ }
1186
+ function receiptSummary(receipt) {
1187
+ return {
1188
+ receipt_id: receipt.receipt_id,
1189
+ request_id: receipt.request_id,
1190
+ gate: receipt.gate,
1191
+ job_type: receipt.job_type,
1192
+ continuation_job_id: receipt.continuation_job_id,
1193
+ original_job_id: receipt.original_job_id,
1194
+ source_park_generation: receipt.source_park_generation,
1195
+ resulting_park_generation: receipt.resulting_park_generation,
1196
+ committed_at: receipt.committed_at,
1197
+ runtime_outcome: receipt.runtime_outcome,
1198
+ };
1199
+ }
1200
+ function currentSummary(current) {
1201
+ if (current === null)
1202
+ return null;
1203
+ return {
1204
+ status: current.status,
1205
+ parked: current.parked,
1206
+ parked_gate: current.parked_gate,
1207
+ park_generation: current.park_generation,
1208
+ parked_age_seconds: current.parked_age_seconds,
1209
+ needs_human_reason: current.needs_human_reason,
1210
+ unresolved_obligations: current.unresolved_obligations,
1211
+ continuations_used: current.continuations_used,
1212
+ continuation_limit: current.continuation_limit,
1213
+ continuation_available: current.continuation_available,
1214
+ continuation_unavailable_reason: current.continuation_unavailable_reason,
1215
+ };
1216
+ }
1217
+ function currentLine(current) {
1218
+ if (!current.parked) {
1219
+ return `Current: status ${current.status} (not parked).`;
1220
+ }
1221
+ const age = current.parked_age_seconds === null ? "" : `, parked ${current.parked_age_seconds}s`;
1222
+ const available = current.continuation_available
1223
+ ? "continuation available"
1224
+ : `continuation unavailable (${current.continuation_unavailable_reason ?? "unknown"})`;
1225
+ return (`Current: parked at gate ${current.parked_gate} (episode ${current.park_generation}${age}); ` +
1226
+ `${available}.`);
1227
+ }
1228
+ /** Exported for tests: render a `continue-child` result. Returns the exit code. */
1229
+ export function renderContinueChildResult(result, target, json) {
1230
+ const label = `Ticket ${target.ticketKey} (run ${target.epicRunId})`;
1231
+ if (result.kind === "decided") {
1232
+ const r = result.result;
1233
+ const ok = r.outcome === "committed" || r.outcome === "already_continued";
1234
+ if (json) {
1235
+ console.log(JSON.stringify({
1236
+ ok,
1237
+ kind: r.outcome,
1238
+ reason_code: r.reason_code,
1239
+ explanation: r.explanation,
1240
+ request_id: result.requestId,
1241
+ replayed: r.replayed,
1242
+ retryable: r.retryable,
1243
+ reference_reused: result.reusedReference,
1244
+ receipt: r.receipt === null ? null : receiptSummary(r.receipt),
1245
+ current: currentSummary(r.current),
1246
+ }));
1247
+ return ok ? 0 : 1;
1248
+ }
1249
+ const receipt = r.receipt;
1250
+ switch (r.outcome) {
1251
+ case "committed":
1252
+ console.log(`${label}: continuation COMMITTED${r.replayed ? " (replayed — already committed earlier)" : ""} — ` +
1253
+ `job #${receipt?.continuation_job_id} (${receipt?.job_type}) dispatched for park episode ` +
1254
+ `${receipt?.source_park_generation}; receipt ${receipt?.receipt_id}. This proves the dispatch, ` +
1255
+ "not that the work has finished.");
1256
+ return 0;
1257
+ case "already_continued":
1258
+ console.log(`${label}: this park episode was ALREADY CONTINUED by another request — job ` +
1259
+ `#${receipt?.continuation_job_id}, receipt ${receipt?.receipt_id}. Nothing was dispatched ` +
1260
+ "for this request.");
1261
+ return 0;
1262
+ case "stale_historical_episode":
1263
+ console.error(`${label}: STALE HISTORICAL EPISODE — receipt ${receipt?.receipt_id} (job ` +
1264
+ `#${receipt?.continuation_job_id}) belongs to park episode ${receipt?.source_park_generation}, ` +
1265
+ "which is history. The child has parked again and was NOT resumed by it.");
1266
+ if (r.current)
1267
+ console.error(currentLine(r.current));
1268
+ return 1;
1269
+ case "unsupported":
1270
+ console.error(`${label}: continuation UNSUPPORTED (${r.reason_code}) — ${r.explanation}`);
1271
+ return 1;
1272
+ default:
1273
+ console.error(`${label}: continuation REFUSED (${r.reason_code}) — ${r.explanation}`);
1274
+ if (r.retryable)
1275
+ console.error("This refusal is retryable; nothing was written.");
1276
+ return 1;
1277
+ }
1278
+ }
1279
+ if (result.kind === "refused_http") {
1280
+ if (json) {
1281
+ console.log(JSON.stringify({
1282
+ ok: false,
1283
+ kind: "refused",
1284
+ status: result.status,
1285
+ code: result.errorCode,
1286
+ request_id: result.requestId,
1287
+ }));
1288
+ return 1;
1289
+ }
1290
+ console.error(`${label}: request REJECTED before anything was written (status ${result.status}` +
1291
+ `${result.errorCode ? `, ${result.errorCode}` : ""}).`);
1292
+ return 1;
1293
+ }
1294
+ if (result.kind === "not_continuable") {
1295
+ if (json) {
1296
+ console.log(JSON.stringify({ ok: false, kind: "not_continuable", current: currentSummary(result.current) }));
1297
+ return 1;
1298
+ }
1299
+ console.error(`${label}: not continuable right now. Nothing was sent.`);
1300
+ console.error(currentLine(result.current));
1301
+ // BAPI-1344: the park's own guidance names the exit that remains (for a
1302
+ // first attempt with no recorded retained head, the run operator).
1303
+ if (result.current.parked && result.current.needs_human_reason) {
1304
+ console.error(`Park: ${result.current.needs_human_reason}`);
1305
+ }
1306
+ return 1;
1307
+ }
1308
+ if (result.kind === "unconfirmed") {
1309
+ if (json) {
1310
+ console.log(JSON.stringify({
1311
+ ok: false,
1312
+ kind: "unconfirmed",
1313
+ transport: result.transport,
1314
+ request_id: result.requestId,
1315
+ reference_reused: result.reusedReference,
1316
+ }));
1317
+ return 1;
1318
+ }
1319
+ console.error(`${label}: continuation UNCONFIRMED after ${result.transport} — the request may or may not have ` +
1320
+ `committed, and no receipt proves it yet (request ${result.requestId}).`);
1321
+ console.error(`Look it up: epic-implementer continuation-receipt --epic-run-id ${target.epicRunId} ` +
1322
+ `--ticket-key ${target.ticketKey}. Re-running continue-child reuses this same request; it ` +
1323
+ "never starts a new one.");
1324
+ return 1;
1325
+ }
1326
+ if (json) {
1327
+ console.log(JSON.stringify({ ok: false, kind: "unavailable", message: result.message }));
1328
+ return 1;
1329
+ }
1330
+ console.error(`${label}: could not continue — ${result.message}. Nothing was sent.`);
1331
+ return 1;
1332
+ }
1333
+ /** Exported for tests: render a `continuation-receipt` lookup. */
1334
+ export function renderContinuationReceiptResult(result, target, json) {
1335
+ const label = `Ticket ${target.ticketKey} (run ${target.epicRunId})`;
1336
+ if (result.kind === "unavailable") {
1337
+ if (json) {
1338
+ console.log(JSON.stringify({ ok: false, kind: "unavailable", message: result.message }));
1339
+ }
1340
+ else {
1341
+ console.error(`${label}: could not read — ${result.message}.`);
1342
+ }
1343
+ return 1;
1344
+ }
1345
+ const status = result.status;
1346
+ if (json) {
1347
+ console.log(JSON.stringify({
1348
+ ok: true,
1349
+ kind: result.kind,
1350
+ request_id: result.requestId,
1351
+ outcome: status.outcome,
1352
+ receipt: status.receipt === null ? null : receiptSummary(status.receipt),
1353
+ current: currentSummary(status.current),
1354
+ }));
1355
+ return 0;
1356
+ }
1357
+ if (result.kind === "found" && status.receipt !== null) {
1358
+ const r = status.receipt;
1359
+ console.log(`${label}: receipt ${r.receipt_id} — continuation COMMITTED at ${r.committed_at}: job ` +
1360
+ `#${r.continuation_job_id} (${r.job_type}, runtime ${r.runtime_outcome ?? "unknown"}) for park ` +
1361
+ `episode ${r.source_park_generation}.`);
1362
+ if (status.outcome === "stale_historical_episode") {
1363
+ console.log("That episode is history: the child has parked again since.");
1364
+ }
1365
+ }
1366
+ else if (result.requestId !== null) {
1367
+ console.log(`${label}: no receipt for request ${result.requestId} — its commit is NOT proven (this is not a refusal).`);
1368
+ }
1369
+ else {
1370
+ console.log(`${label}: no pending local continuation request.`);
1371
+ }
1372
+ console.log(currentLine(status.current));
1373
+ return 0;
1374
+ }
1375
+ export async function runContinueChildCommand(argv) {
1376
+ try {
1377
+ const parsed = parseContinueChildArgs(argv);
1378
+ if (parsed.help) {
1379
+ console.log(CONTINUE_CHILD_USAGE);
1380
+ return 0;
1381
+ }
1382
+ const access = await resolveRecoveryAccess(parsed.json);
1383
+ if (!access)
1384
+ return 1;
1385
+ const { continueChildOnce } = await import("./recovery-operations.js");
1386
+ const result = await continueChildOnce(access, {
1387
+ epicRunId: parsed.epicRunId,
1388
+ ticketKey: parsed.ticketKey,
1389
+ ...(parsed.reason ? { reason: parsed.reason } : {}),
1390
+ });
1391
+ return renderContinueChildResult(result, parsed, parsed.json);
1392
+ }
1393
+ catch (error) {
1394
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
1395
+ return 1;
1396
+ }
1397
+ }
1398
+ export async function runContinuationReceiptCommand(argv) {
1399
+ try {
1400
+ const parsed = parseContinuationReceiptArgs(argv);
1401
+ if (parsed.help) {
1402
+ console.log(CONTINUATION_RECEIPT_USAGE);
1403
+ return 0;
1404
+ }
1405
+ const access = await resolveRecoveryAccess(parsed.json);
1406
+ if (!access)
1407
+ return 1;
1408
+ const { lookupContinuationReceipt } = await import("./recovery-operations.js");
1409
+ const result = await lookupContinuationReceipt(access, {
1410
+ epicRunId: parsed.epicRunId,
1411
+ ticketKey: parsed.ticketKey,
1412
+ ...(parsed.requestId ? { requestId: parsed.requestId } : {}),
1413
+ });
1414
+ return renderContinuationReceiptResult(result, parsed, parsed.json);
1415
+ }
1416
+ catch (error) {
1417
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
1418
+ return 1;
1419
+ }
1420
+ }
1421
+ // ---------------------------------------------------------------------------
1422
+ // BAPI-1326 — resume-ci
1423
+ // ---------------------------------------------------------------------------
1424
+ const RESUME_CI_USAGE = [
1425
+ "Usage: epic-implementer resume-ci --epic-run-id <id> --ticket-key <key> [--json]",
1426
+ "",
1427
+ "Resume a child parked on a qualified CI stop (an expired CI wait, exhausted",
1428
+ "required-check repairs, or an unavailable verdict) once CI has finished green.",
1429
+ "The server re-reads the pull request, the specification, and the required",
1430
+ "checks, then returns CI to evaluation at the same head. It creates no job,",
1431
+ "grants no repair or new wait, keeps the original deadline, and does not mark",
1432
+ "CI passed or authorize review or merge.",
1433
+ "",
1434
+ "The operation is pinned locally before it is sent; re-running after a lost",
1435
+ "response reuses it and reports the original result. Nothing else is tried.",
1436
+ "",
1437
+ "Exit codes: 0 committed or replayed; 2 refused; 3 unconfirmed; 1 other failure.",
1438
+ ].join("\n");
1439
+ /** Distinct exit codes, so automation can tell a refusal from an unknown outcome. */
1440
+ export const RESUME_CI_EXIT_OK = 0;
1441
+ export const RESUME_CI_EXIT_FAILED = 1;
1442
+ export const RESUME_CI_EXIT_REFUSED = 2;
1443
+ export const RESUME_CI_EXIT_UNCONFIRMED = 3;
1444
+ /**
1445
+ * Fixed operator sentences, chosen by the server's structured code. No server,
1446
+ * provider, or exception text ever reaches the terminal.
1447
+ */
1448
+ const RESUME_CI_REFUSAL_SENTENCES = {
1449
+ TICKET_NOT_IN_RUN: "the ticket is not part of this run",
1450
+ CI_STOP_NOT_ELIGIBLE: "this CI park has no complete qualified stop record, so resume-ci does not apply",
1451
+ CI_RESUME_STALE_OPERATION: "the ticket is no longer on the CI stop this operation was made for, and its result is not retained",
1452
+ CI_RESUME_OPERATION_CONFLICT: "this operation already names a different CI recovery",
1453
+ ROW_VERSION_CONFLICT: "the ticket kept changing while the resume was being applied",
1454
+ RUN_NOT_DISPATCHABLE: "the run is paused, stopped, quarantined, or not active",
1455
+ CI_RESUME_BUDGET_EXHAUSTED: "the run's wall-clock or cost budget is spent, or could not be read",
1456
+ CI_RESUME_HELD: "the ticket is held by a completion, permission, declassification, or stale-specification hold",
1457
+ CI_RESUME_LIVE_WORK: "corrective work is, or may be, live for this ticket",
1458
+ CI_RESUME_SPEC_CHANGED: "the ticket's specification changed or could not be verified",
1459
+ CI_RESUME_POLICY_CHANGED: "the run's required-check policy changed, or is unavailable or unconfigured",
1460
+ CI_RESUME_BINDING_CHANGED: "the pull request, its head, or its target branch changed since the stop",
1461
+ CI_RESUME_CHECKS_NOT_GREEN: "the bound head's required checks are not currently green",
1462
+ CI_RESUME_VERDICT_UNAVAILABLE: "the CI provider's verdict could not be read in time",
1463
+ CI_RESUME_BUSY: "the ticket or run stayed busy for the whole bounded wait",
1464
+ };
1465
+ /** The next step for a refusal, where one is useful. */
1466
+ const RESUME_CI_REFUSAL_GUIDANCE = {
1467
+ CI_STOP_NOT_ELIGIBLE: "The existing explicit path is `epic-implementer unpark`, which RESETS the CI gate's " +
1468
+ "clock and repair allowance. If the park's origin is unclear, ask the run operator " +
1469
+ "to assess it first.",
1470
+ CI_RESUME_STALE_OPERATION: "Read `epic-implementer explain-run` for the ticket; start a new resume-ci only if " +
1471
+ "its current stop independently qualifies.",
1472
+ CI_RESUME_CHECKS_NOT_GREEN: "Wait for the required checks to finish green, then retry.",
1473
+ CI_RESUME_VERDICT_UNAVAILABLE: "Retry later. If the provider stays unavailable, escalate to the CI-platform operator.",
1474
+ CI_RESUME_BUSY: "Retry the same command; it reuses the same operation.",
1475
+ ROW_VERSION_CONFLICT: "Retry the same command; it reuses the same operation.",
1476
+ RUN_NOT_DISPATCHABLE: "Resume the run first (`epic-implementer resume-run`), then retry.",
1477
+ CI_RESUME_LIVE_WORK: "Retry when the running work has finished.",
1478
+ };
1479
+ const RESUME_CI_STOP_REASON_TEXT = {
1480
+ wait_expired: "an expired CI wait",
1481
+ repair_exhausted: "exhausted required-check repairs",
1482
+ verdict_unavailable: "an unavailable CI verdict",
1483
+ };
1484
+ const RESUME_CI_NOT_ELIGIBLE_TEXT = {
1485
+ ticket_not_found: "the ticket is not part of this run",
1486
+ not_parked: "the ticket is not parked",
1487
+ not_ci_park: "the ticket is not parked at the CI gate",
1488
+ no_typed_stop: "the CI park has no complete qualified stop record (a legacy or incomplete park)",
1489
+ stop_superseded: "the CI stop belongs to an earlier park episode",
1490
+ };
1491
+ /** The one consequence statement every committed result carries. */
1492
+ const RESUME_CI_CONSEQUENCE = "CI is back in evaluation at the same head; its verdict is pending. This action did NOT " +
1493
+ "mark CI passed, authorize review or merge, create a job, extend the deadline, or refund " +
1494
+ "any repair.";
1495
+ export function parseResumeCiArgs(argv) {
1496
+ return parseTicketRecoveryArgs(argv);
1497
+ }
1498
+ function resumeCiOperationSummary(op) {
1499
+ return {
1500
+ resumed_at: op.resumed_at,
1501
+ stop_reason: op.stop_reason,
1502
+ park_generation: op.park_generation,
1503
+ resumed_gate: op.resumed_gate,
1504
+ ci_gate_status: op.ci_gate_status,
1505
+ allowance_effect: op.allowance_effect,
1506
+ preserved_attempts: op.preserved_attempts,
1507
+ preserved_ci_infra_rerun_attempts_used: op.preserved_ci_infra_rerun_attempts_used,
1508
+ preserved_entry_attempts: op.preserved_entry_attempts,
1509
+ deadline_at: op.deadline_at,
1510
+ deadline_source: op.deadline_source,
1511
+ deadline_extended: op.deadline_extended,
1512
+ jobs_created: op.jobs_created,
1513
+ ci_passed_by_action: op.ci_passed_by_action,
1514
+ };
1515
+ }
1516
+ function allowanceLine(op) {
1517
+ const infra = op.preserved_ci_infra_rerun_attempts_used ?? 0;
1518
+ const entry = op.preserved_entry_attempts ?? 0;
1519
+ return (`Preserved allowances: repair attempts spent ${op.preserved_attempts}, ` +
1520
+ `infrastructure retries used ${infra}, entry attempts ${entry}.`);
1521
+ }
1522
+ function deadlineLine(op) {
1523
+ if (op.deadline_at === null)
1524
+ return "Deadline unchanged (not recorded).";
1525
+ const source = op.deadline_source === "post_repair" ? "post-repair window" : "original gate window";
1526
+ return `Deadline unchanged: ${op.deadline_at} (${source}).`;
1527
+ }
1528
+ function currentStateLine(current) {
1529
+ return (`Current state (read now, separate from the history above): ticket ${current.ticket_status ?? "unknown"}, ` +
1530
+ `gate ${current.current_gate ?? "unknown"}, CI gate ${current.ci_gate_status ?? "unknown"}, ` +
1531
+ `held by run control: ${current.held_by_run_control ? "yes" : "no"}.`);
1532
+ }
1533
+ /**
1534
+ * Exported for tests: render a `resume-ci` result.
1535
+ *
1536
+ * The outcome word leads the first line. Committed and replayed results go to
1537
+ * stdout with exit 0; refused, unconfirmed, and failed outcomes go to stderr with
1538
+ * their own non-zero codes. No operation key, stop binding, row version, SHA,
1539
+ * credential, provider error, or URL is ever printed, in either mode.
1540
+ */
1541
+ export function renderResumeCiResult(result, target, json) {
1542
+ const label = `ticket ${target.ticketKey} (run ${target.epicRunId})`;
1543
+ if (result.kind === "committed" || result.kind === "replayed") {
1544
+ const r = result.result;
1545
+ if (json) {
1546
+ console.log(JSON.stringify({
1547
+ ok: true,
1548
+ outcome: result.kind,
1549
+ historical: result.kind === "replayed",
1550
+ epic_run_id: r.epic_run_id,
1551
+ ticket_key: r.ticket_key,
1552
+ operation: resumeCiOperationSummary(r.operation),
1553
+ current: r.current,
1554
+ audit_recorded: r.audit_recorded,
1555
+ audit_warning: r.audit_warning,
1556
+ reference_reused: result.reusedReference,
1557
+ }));
1558
+ return RESUME_CI_EXIT_OK;
1559
+ }
1560
+ const reason = RESUME_CI_STOP_REASON_TEXT[r.operation.stop_reason] ?? "a CI stop";
1561
+ if (result.kind === "committed") {
1562
+ console.log(`Committed: resume-ci for ${label}, which was parked on ${reason}.`);
1563
+ console.log(RESUME_CI_CONSEQUENCE);
1564
+ console.log(allowanceLine(r.operation));
1565
+ console.log(deadlineLine(r.operation));
1566
+ console.log("Next: the reconciler's next identical green poll confirms CI; review and merge checks still apply.");
1567
+ }
1568
+ else {
1569
+ console.log(`Replayed historical result: resume-ci for ${label} committed earlier, at ` +
1570
+ `${r.operation.resumed_at}. Nothing was changed by this call.`);
1571
+ console.log(`History: the ticket was parked on ${reason} and was returned to CI evaluation.`);
1572
+ console.log(allowanceLine(r.operation));
1573
+ console.log(deadlineLine(r.operation));
1574
+ console.log(currentStateLine(r.current));
1575
+ console.log("This history does not show that CI is green now, or that review or merge may proceed.");
1576
+ }
1577
+ if (!r.audit_recorded && r.audit_warning) {
1578
+ console.log(`Warning: ${r.audit_warning}`);
1579
+ }
1580
+ return RESUME_CI_EXIT_OK;
1581
+ }
1582
+ if (result.kind === "refused" || result.kind === "not_eligible" || result.kind === "stale_reference") {
1583
+ const code = result.kind === "refused"
1584
+ ? result.errorCode
1585
+ : result.kind === "stale_reference"
1586
+ ? "CI_RESUME_STALE_OPERATION"
1587
+ : "CI_STOP_NOT_ELIGIBLE";
1588
+ const sentence = result.kind === "not_eligible"
1589
+ ? RESUME_CI_NOT_ELIGIBLE_TEXT[result.reason] ?? "resume-ci does not apply"
1590
+ : RESUME_CI_REFUSAL_SENTENCES[code] ?? "the server refused the request";
1591
+ const guidance = RESUME_CI_REFUSAL_GUIDANCE[code];
1592
+ if (json) {
1593
+ console.error(JSON.stringify({
1594
+ ok: false,
1595
+ outcome: "refused",
1596
+ refusal_code: code,
1597
+ ...(result.kind === "not_eligible" ? { local_reason: result.reason } : {}),
1598
+ message: sentence,
1599
+ ...(guidance ? { guidance } : {}),
1600
+ nothing_written: true,
1601
+ }));
1602
+ return RESUME_CI_EXIT_REFUSED;
1603
+ }
1604
+ console.error(`Refused: resume-ci for ${label} — ${sentence}. Nothing was written.`);
1605
+ if (guidance)
1606
+ console.error(guidance);
1607
+ return RESUME_CI_EXIT_REFUSED;
1608
+ }
1609
+ if (result.kind === "unconfirmed") {
1610
+ if (json) {
1611
+ console.error(JSON.stringify({
1612
+ ok: false,
1613
+ outcome: "unconfirmed",
1614
+ transport: result.transport,
1615
+ attempts: result.attempts,
1616
+ }));
1617
+ return RESUME_CI_EXIT_UNCONFIRMED;
1618
+ }
1619
+ console.error(`Unconfirmed: resume-ci for ${label} — no durable answer arrived after ${result.attempts} ` +
1620
+ `attempts (${result.transport}). The resume may or may not have committed.`);
1621
+ console.error("Re-run the same command later: it reuses this same operation and reports its original " +
1622
+ "result. Do not use unpark or another recovery in the meantime.");
1623
+ return RESUME_CI_EXIT_UNCONFIRMED;
1624
+ }
1625
+ const message = result.kind === "unavailable" ? result.message : "the outcome could not be read";
1626
+ if (json) {
1627
+ console.error(JSON.stringify({ ok: false, outcome: "failed", message }));
1628
+ }
1629
+ else {
1630
+ console.error(`Failed: resume-ci for ${label} — ${message}.`);
1631
+ }
1632
+ return RESUME_CI_EXIT_FAILED;
1633
+ }
1634
+ export async function runResumeCiCommand(argv) {
1635
+ try {
1636
+ const parsed = parseResumeCiArgs(argv);
1637
+ if (parsed.help) {
1638
+ console.log(RESUME_CI_USAGE);
1639
+ return 0;
1640
+ }
1641
+ const access = await resolveRecoveryAccess(parsed.json);
1642
+ if (!access)
1643
+ return RESUME_CI_EXIT_FAILED;
1644
+ const { resumeCiOnce } = await import("./recovery-operations.js");
1645
+ const result = await resumeCiOnce(access, {
1646
+ epicRunId: parsed.epicRunId,
1647
+ ticketKey: parsed.ticketKey,
1648
+ });
1649
+ return renderResumeCiResult(result, parsed, parsed.json);
1650
+ }
1651
+ catch (error) {
1652
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
1653
+ return RESUME_CI_EXIT_FAILED;
1654
+ }
1655
+ }