@bridge_gpt/mcp-server 0.2.57 → 0.2.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +112 -113
  2. package/build/agent-registry.js +15 -0
  3. package/build/agent-utils.js +16 -8
  4. package/build/claude-user-config-doctor.js +1 -35
  5. package/build/codex-bridge-profile.js +5 -1
  6. package/build/codex-launch-scope.js +13 -2
  7. package/build/commands.generated.js +5 -6
  8. package/build/conductor/bridge-api-client.js +149 -0
  9. package/build/conductor/bring-up-facts.js +1 -1
  10. package/build/conductor/claude-hook.js +16 -16
  11. package/build/conductor/doctor.js +24 -19
  12. package/build/conductor/epic-implementer-cli.js +38 -5
  13. package/build/conductor/epic-reconcile.js +1 -1
  14. package/build/conductor/epic-runtime.js +10 -10
  15. package/build/conductor/errors.js +3 -3
  16. package/build/conductor/file-scope-guard.js +4 -4
  17. package/build/conductor/git-ci-types.js +1 -1
  18. package/build/conductor/git-inspection.js +2 -2
  19. package/build/conductor/install-doctor.js +6 -6
  20. package/build/conductor/merge-ledger.js +2 -2
  21. package/build/conductor/pr-ci-producer.js +6 -6
  22. package/build/conductor/pr-review-producer.js +2 -2
  23. package/build/conductor/producer-ledger.js +2 -0
  24. package/build/conductor/readiness-sections.js +7 -7
  25. package/build/conductor/readiness.js +2 -2
  26. package/build/conductor/recovery-cli.js +243 -12
  27. package/build/conductor/recovery-operations.js +56 -4
  28. package/build/conductor/repair-contract.js +38 -4
  29. package/build/conductor/spec-review-producer.js +6 -6
  30. package/build/conductor/store.js +10 -10
  31. package/build/conductor/supervisor-config.js +14 -14
  32. package/build/conductor/supervisor-ledger.js +2 -2
  33. package/build/conductor/supervisor-message-relay.js +2 -2
  34. package/build/conductor/worker-ledger-cli.js +19 -19
  35. package/build/conductor-bundle-artifacts.js +2 -2
  36. package/build/cursor-agent-progress.js +72 -0
  37. package/build/doctor.js +20 -296
  38. package/build/drive-epic.js +65 -71
  39. package/build/epic-implementer-bin.js +22 -17
  40. package/build/epic-implementer-claude-hook-bin.js +4 -2
  41. package/build/epic-implementer-env.js +155 -0
  42. package/build/epic-integration-pr.js +2 -2
  43. package/build/epic-shared/bridge-client.js +773 -0
  44. package/build/epic-shared/cut-protocol.js +392 -0
  45. package/build/executor/env.js +19 -8
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/job-errors.js +3 -3
  48. package/build/executor/job-runner.js +1 -1
  49. package/build/executor/merge-job.js +35 -3
  50. package/build/executor/observation.js +1 -1
  51. package/build/executor/preflight.js +1 -1
  52. package/build/executor/prompt-spec.js +1 -1
  53. package/build/executor/reconcile-overlap-job.js +10 -130
  54. package/build/executor/reconcile-overlap-types.js +1 -1
  55. package/build/executor/runner.js +1 -1
  56. package/build/implement-epic/cli.js +1 -1
  57. package/build/index.js +187 -224
  58. package/build/init.js +20 -28
  59. package/build/install-bridge-conductor.js +7 -7
  60. package/build/install-bridge.js +203 -10
  61. package/build/install-doctor.js +1 -1
  62. package/build/mcp-host-entry-adapter.js +6 -0
  63. package/build/mcp-host-targets.js +2 -0
  64. package/build/mcp-invoke.js +5 -5
  65. package/build/mcp-profile.js +2 -2
  66. package/build/mcp-provisioning.js +2 -2
  67. package/build/mcp-server-invocation.js +3 -3
  68. package/build/merge-pull-request.js +15 -0
  69. package/build/model-routing-host.js +25 -0
  70. package/build/model-routing-preferences.js +99 -0
  71. package/build/pipelines.generated.js +11 -11
  72. package/build/plan-epic-implementer-eligibility.js +215 -0
  73. package/build/plan-execution-ledger.js +8 -0
  74. package/build/plane/member-roster.js +5 -5
  75. package/build/plane/supervisor.js +1 -1
  76. package/build/plane/types.js +5 -5
  77. package/build/readiness-check.js +1 -1
  78. package/build/readme.generated.js +1 -1
  79. package/build/review-tickets.js +4 -4
  80. package/build/setup-epic.js +8 -8
  81. package/build/start-tickets-conductor.js +32 -32
  82. package/build/start-tickets-prereqs.js +4 -9
  83. package/build/start-tickets.js +494 -98
  84. package/build/version.generated.js +3 -3
  85. package/docs/CONDUCTOR.md +69 -18
  86. package/docs/install/mcp-tool-integrations.md +2 -1
  87. package/package.json +2 -2
  88. package/pipelines/implement-ticket.json +2 -2
  89. package/pipelines/plan-epic.json +2 -2
  90. package/pipelines/review-ticket.json +1 -1
@@ -36,7 +36,7 @@ import { buildConductorDoctorReport, formatConductorDoctorReport, } from "./doct
36
36
  import { inspectBridgeApiProfileToken, } from "../mcp-host-config.js";
37
37
  import { collectExecutorServiceDiagnostics, formatExecutorServiceDiagnosticsReport, } from "../doctor.js";
38
38
  import { collectInstallStatusChecks, formatInstallStatusReport, } from "../install-doctor.js";
39
- import { bringUpSafetySections, conductorHookTargetsSection, conductorLedgerLoadabilitySection, conductorProfileTokenSection, CONDUCTOR_PROFILE_TOKEN, detectConductorContext, executorObservationSection, executorProvisioningSection, executorServiceStateSection, githubActionsSection, githubCredentialsSection, inspectWorkflowPresence, reconcilerSection, supervisorSection, workflowSection, } from "./readiness-sections.js";
39
+ import { bringUpSafetySections, conductorHookTargetsSection, conductorLedgerLoadabilitySection, conductorProfileTokenSection, EPIC_IMPLEMENTER_PROFILE_TOKEN, detectConductorContext, executorObservationSection, executorProvisioningSection, executorServiceStateSection, githubActionsSection, githubCredentialsSection, inspectWorkflowPresence, reconcilerSection, supervisorSection, workflowSection, } from "./readiness-sections.js";
40
40
  import { ConductorBridgeApiError, fetchConductorReadiness, } from "./bridge-api-client.js";
41
41
  /**
42
42
  * Run one optional collector, containing any throw.
@@ -86,8 +86,8 @@ export async function runConductorInstallDoctor(deps) {
86
86
  const legacyConductor = conductorSettled.value ?? null;
87
87
  if (conductorSettled.failed) {
88
88
  sections.push({
89
- id: "conductor-ledger",
90
- label: "Conductor ledger doctor",
89
+ id: "epic-implementer-ledger",
90
+ label: "Epic Implementer ledger doctor",
91
91
  status: "degraded",
92
92
  detail: "the local epic-implementer doctor could not be built",
93
93
  remediation: "run `epic-implementer doctor` directly to see why the local ledger report failed.",
@@ -219,7 +219,7 @@ export async function runConductorInstallDoctor(deps) {
219
219
  }));
220
220
  let profileToken = null;
221
221
  try {
222
- profileToken = await inspectProfile(deps.projectRoot ?? process.cwd(), CONDUCTOR_PROFILE_TOKEN);
222
+ profileToken = await inspectProfile(deps.projectRoot ?? process.cwd(), EPIC_IMPLEMENTER_PROFILE_TOKEN);
223
223
  }
224
224
  catch {
225
225
  // The shared inspector never throws for the ordinary absent/malformed
@@ -285,7 +285,7 @@ export function formatConductorInstallDoctorReport(report) {
285
285
  embed("Bridge install status", () => formatInstallStatusReport(report.legacyInstallChecks));
286
286
  }
287
287
  if (report.legacyConductor) {
288
- embed("Conductor ledger doctor", () => formatConductorDoctorReport(report.legacyConductor));
288
+ embed("Epic Implementer ledger doctor", () => formatConductorDoctorReport(report.legacyConductor));
289
289
  }
290
290
  if (report.legacyExecutorPreflight) {
291
291
  const p = report.legacyExecutorPreflight;
@@ -335,6 +335,6 @@ export function conductorInstallDoctorHasFatal(report) {
335
335
  // remediation constants, or the installer reporting APIs from
336
336
  // `conductor/install-doctor.js` keeps resolving exactly as before.
337
337
  // ---------------------------------------------------------------------------
338
- export { BRING_UP_SECTION_LABELS, bringUpSafetySections, databaseMigrationCurrentSection, singleTransitionAuthoritySection, workerPresentSection, CONDUCTOR_OPERATOR_RUNBOOK_POINTER, EXECUTOR_PROVISIONING_GUIDANCE, CONDUCTOR_PROFILE_TOKEN, CONDUCTOR_PROFILE_TOKEN_NO_ENTRY_REMEDIATION, CONDUCTOR_PROFILE_TOKEN_REMEDIATION, CONDUCTOR_HOOK_TARGET_REMEDIATION, CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION, EXECUTOR_INSTALL_SERVICE_REMEDIATION, EXECUTOR_SERVICE_START_REMEDIATION, } from "./readiness-sections.js";
338
+ export { BRING_UP_SECTION_LABELS, bringUpSafetySections, databaseMigrationCurrentSection, singleTransitionAuthoritySection, workerPresentSection, CONDUCTOR_OPERATOR_RUNBOOK_POINTER, EXECUTOR_PROVISIONING_GUIDANCE, EPIC_IMPLEMENTER_PROFILE_TOKEN, CONDUCTOR_PROFILE_TOKEN_NO_ENTRY_REMEDIATION, CONDUCTOR_PROFILE_TOKEN_REMEDIATION, CONDUCTOR_HOOK_TARGET_REMEDIATION, CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION, EXECUTOR_INSTALL_SERVICE_REMEDIATION, EXECUTOR_SERVICE_START_REMEDIATION, } from "./readiness-sections.js";
339
339
  // BAPI-1153 — the bring-up remediations the three sections above render.
340
340
  export { BRING_UP_RUNBOOK, BRING_UP_UNREPORTED_REMEDIATION, DATABASE_MIGRATION_BEHIND_REMEDIATION, DATABASE_MIGRATION_UNREADABLE_REMEDIATION, SECOND_RECONCILER_REMEDIATION, WORKER_HEARTBEAT_UNREADABLE_REMEDIATION, WORKER_NOT_PRESENT_REMEDIATION, } from "./bring-up-facts.js";
@@ -119,11 +119,11 @@ export async function emitMergeLedgerEventIfNew(input, deps = {}) {
119
119
  const eventId = makeMergeEventId(input.type, input.action_key);
120
120
  const event = {
121
121
  id: eventId,
122
- source: "conductor-supervisor",
122
+ source: "epic-implementer-supervisor",
123
123
  type: input.type,
124
124
  run_id: input.run_id ?? null,
125
125
  worker_id: input.worker_id ?? null,
126
- producer: "conductor-merge",
126
+ producer: "epic-implementer-merge",
127
127
  observed_via: "supervisor",
128
128
  data: {
129
129
  summary: input.summary ?? `${input.type} ${input.action_key}`,
@@ -109,7 +109,7 @@ export function buildGateMetEventInput(binding, evaluation, runId = null, worker
109
109
  if (!evaluation.met || !evaluation.gateEventData)
110
110
  return null;
111
111
  return {
112
- source: "conductor",
112
+ source: "epic-implementer",
113
113
  type: "gate.met",
114
114
  subject: binding.subject,
115
115
  run_id: runId,
@@ -142,8 +142,8 @@ async function observeWithResolved(binding, access, gateConfig, deps, expectedBa
142
142
  const now = deps.now ?? (() => new Date().toISOString());
143
143
  // Resolve run_id/worker_id for attribution. Env takes precedence; fall back
144
144
  // to the injected resolution seam when the env identity is absent.
145
- let run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
146
- const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
145
+ let run_id = deps.env?.BAPI_EPIC_IMPLEMENTER_RUN_ID?.trim() || null;
146
+ const worker_id = deps.env?.BAPI_EPIC_IMPLEMENTER_WORKER_ID?.trim() || null;
147
147
  if (run_id === null && deps.resolveRunId) {
148
148
  try {
149
149
  run_id = await deps.resolveRunId(access, binding) ?? null;
@@ -432,8 +432,8 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
432
432
  }
433
433
  // Env-stamp identity; fall back to the resolution seam when env is absent.
434
434
  // Access resolution for the seam is attempted inline within the gate block.
435
- let run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
436
- const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
435
+ let run_id = deps.env?.BAPI_EPIC_IMPLEMENTER_RUN_ID?.trim() || null;
436
+ const worker_id = deps.env?.BAPI_EPIC_IMPLEMENTER_WORKER_ID?.trim() || null;
437
437
  const result = {
438
438
  binding,
439
439
  pr_opened_emitted: false,
@@ -541,7 +541,7 @@ function extractTicketKeyFromRef(headRef) {
541
541
  * any step fails — the producer never crashes on a resolution failure.
542
542
  *
543
543
  * Intended to be injected as `PrCiProducerDeps.resolveRunId` at call sites
544
- * that lack `BAPI_CONDUCTOR_RUN_ID` in the environment (git-hook / CI-poll
544
+ * that lack `BAPI_EPIC_IMPLEMENTER_RUN_ID` in the environment (git-hook / CI-poll
545
545
  * contexts). A production `fetchImpl` is optional; defaults to the global fetch.
546
546
  */
547
547
  export async function resolveDispatchRunIdForBinding(access, binding, fetchImpl = fetch) {
@@ -58,8 +58,8 @@ export function buildReviewObservationEventInput(binding, snapshot, eventType, r
58
58
  export async function observeReviewWithResolved(binding, access, gateConfig, deps = {}) {
59
59
  const fetchStatus = deps.fetchReviewStatus ?? fetchPrReviewStatus;
60
60
  const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
61
- const run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
62
- const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
61
+ const run_id = deps.env?.BAPI_EPIC_IMPLEMENTER_RUN_ID?.trim() || null;
62
+ const worker_id = deps.env?.BAPI_EPIC_IMPLEMENTER_WORKER_ID?.trim() || null;
63
63
  const result = {
64
64
  snapshot: null,
65
65
  review_passed_emitted: false,
@@ -33,6 +33,8 @@ export function makeProducerDedupeKey(dimensions) {
33
33
  * the `events.id` UNIQUE constraint instead of appending a duplicate.
34
34
  */
35
35
  export function makeStableProducerEventId(dedupeKey) {
36
+ // Upgrade seam: preserve the legacy namespace so retries spanning releases
37
+ // collide with the event ID already written before the wire-name migration.
36
38
  const h = createHash("sha256").update(`conductor-producer:${dedupeKey}`).digest("hex");
37
39
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
38
40
  }
@@ -39,7 +39,7 @@ import { CLAUDE_REVIEW_WORKFLOW_DRIFT_REMEDIATION, summarizeClaudeReviewWorkflow
39
39
  import { describeNativeLedgerAvailability } from "./doctor.js";
40
40
  import { classifyDatabaseMigration, classifySingleTransitionAuthority, classifyWorkerPresent, } from "./bring-up-facts.js";
41
41
  /** Operator runbook pointer surfaced beside the executor gap. */
42
- export const CONDUCTOR_OPERATOR_RUNBOOK_POINTER = "docs/claude/epic-conductor-v2-operator-runbook.md";
42
+ export const CONDUCTOR_OPERATOR_RUNBOOK_POINTER = "docs/claude/epic-implementer-v2-operator-runbook.md";
43
43
  /**
44
44
  * Capability-matrix guidance for the executor leg.
45
45
  *
@@ -51,13 +51,13 @@ export const CONDUCTOR_OPERATOR_RUNBOOK_POINTER = "docs/claude/epic-conductor-v2
51
51
  export const EXECUTOR_PROVISIONING_GUIDANCE = "`install conductor` writes the unit and starts it, each behind its own consent; " +
52
52
  "readiness still comes only from a live server observation";
53
53
  /**
54
- * The `BRIDGE_MCP_PROFILE` token that makes the 8 conductor MCP tools visible.
54
+ * The `BRIDGE_MCP_PROFILE` token that makes the 8 Epic Implementer MCP tools visible.
55
55
  *
56
56
  * The tools are profile-gated, never core, and this is the only token that
57
57
  * un-gates them — so the doctor names it explicitly rather than describing the
58
58
  * gap abstractly (BAPI-775).
59
59
  */
60
- export const CONDUCTOR_PROFILE_TOKEN = "conductor";
60
+ export const EPIC_IMPLEMENTER_PROFILE_TOKEN = "epic_implementer";
61
61
  /** Remediation for a host MCP config that carries no Bridge entry at all. */
62
62
  export const CONDUCTOR_PROFILE_TOKEN_NO_ENTRY_REMEDIATION = "no supported host MCP config carries a Bridge entry — run " +
63
63
  `\`npx ${MCP_PACKAGE_NAME} install\` to register one, then re-run \`install conductor\`.`;
@@ -500,7 +500,7 @@ export function executorObservationSection(readiness) {
500
500
  * Whether this host looks like a conductor context.
501
501
  *
502
502
  * Composed from the two facts already collected: the resolved MCP-profile
503
- * inspection (`BAPI_CONDUCTOR_ENABLED` or a registered epic-tick schedule) and
503
+ * inspection (`BAPI_EPIC_IMPLEMENTER_ENABLED` or a registered epic-tick schedule) and
504
504
  * the read-only host token inspection. Outside such a context a missing local
505
505
  * capability is ADVISORY — reporting it as a gap would train operators of
506
506
  * ordinary repositories to ignore this whole part of the report.
@@ -522,7 +522,7 @@ export function detectConductorContext(legacyConductor, profile) {
522
522
  * that Codex or Copilot configuration was inspected or changed.
523
523
  */
524
524
  export function conductorProfileTokenSection(profile, conductorContext) {
525
- const base = { id: "conductor-profile-token", label: "MCP conductor profile token" };
525
+ const base = { id: "epic-implementer-profile-token", label: "Epic Implementer MCP profile token" };
526
526
  if (profile === null) {
527
527
  return {
528
528
  ...base,
@@ -632,8 +632,8 @@ export function conductorHookTargetsSection(legacyConductor, conductorContext) {
632
632
  */
633
633
  export function conductorLedgerLoadabilitySection(legacyConductor) {
634
634
  const base = {
635
- id: "conductor-ledger-loadability",
636
- label: "Conductor ledger loadability",
635
+ id: "epic-implementer-ledger-loadability",
636
+ label: "Epic Implementer ledger loadability",
637
637
  };
638
638
  if (legacyConductor === null || legacyConductor === undefined) {
639
639
  return {
@@ -626,8 +626,8 @@ const INSTALL_SECTION_IDS_OWNED_ELSEWHERE = new Set([
626
626
  "server-readiness",
627
627
  "bridge-access",
628
628
  // Local conductor facts — owned by `mapConductorDoctorReportToReadinessChecks`.
629
- "conductor-ledger",
630
- "conductor-ledger-loadability",
629
+ "epic-implementer-ledger",
630
+ "epic-implementer-ledger-loadability",
631
631
  // Legacy embedded reports — owned by their own adapters.
632
632
  "install-status",
633
633
  ]);
@@ -1,19 +1,21 @@
1
1
  /**
2
- * `conductor` operator recovery and repair verbs — seven CLI-only commands.
2
+ * `conductor` operator recovery and repair verbs — ten CLI-only commands.
3
3
  *
4
4
  * The four BAPI-872 recovery verbs: `stop-run`, `abandon-run`, `unpark`,
5
- * `adopt-current-head-and-unpark`. The three BAPI-1154 run-addressed repair
6
- * verbs: `scope-reparse`, `dispatch-remediation`, `merge-child`.
5
+ * `adopt-current-head-and-unpark`. The six run-addressed repair verbs: BAPI-1154's
6
+ * `scope-reparse`, `dispatch-remediation`, `merge-child`, and BAPI-1176's
7
+ * `scope-catch-up`, `patch-policy`, `resume-run`.
7
8
  *
8
9
  * CLI-only by design — this is an explicit ticket non-goal, not an oversight:
9
- * none of these seven command names is ever registered as an MCP tool, tool
10
+ * none of these ten command names is ever registered as an MCP tool, tool
10
11
  * description, or generated tool-schema input (`index-static.test.ts` guards
11
12
  * this). They exist so an operator (or `plane down`, via
12
13
  * `recovery-operations.ts`) can recover a stuck run or ticket WITHOUT
13
14
  * hand-rolling an HTTP request, a DAL call, or a code edit.
14
15
  *
15
16
  * Every ticket command's `row_version` CAS handling is entirely internal to
16
- * `recovery-operations.ts`. This surface never accepts, echoes, or asks the
17
+ * `recovery-operations.ts`, and `resume-run`'s control-generation CAS is entirely
18
+ * the server's. This surface never accepts, echoes, or asks the
17
19
  * operator for a version counter — see {@link REJECTED_VERSION_FLAGS}. Every
18
20
  * repair verb takes a mandatory `--epic-run-id`; the SERVER decides whether its
19
21
  * target belongs to that run, and a foreign target is refused with nothing
@@ -21,7 +23,7 @@
21
23
  */
22
24
  import { readFile } from "node:fs/promises";
23
25
  import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
24
- import { DRIVER_CONTEXT_MAX_BYTES } from "./repair-contract.js";
26
+ import { DRIVER_CONTEXT_MAX_BYTES, POLICY_PATCH_MAX_BYTES } from "./repair-contract.js";
25
27
  // ---------------------------------------------------------------------------
26
28
  // Flag parsing
27
29
  // ---------------------------------------------------------------------------
@@ -32,12 +34,15 @@ const RECOVERY_BOOL_FLAGS = new Set(["--json", "--help"]);
32
34
  * Every spelling of an operator-supplied version counter this surface must
33
35
  * refuse outright (BAPI-872) — row_version handling is internal to
34
36
  * {@link module:./recovery-operations}, and no ticket command may accept one.
37
+ * BAPI-1176 adds the run-control generation: `resume-run`'s CAS is the server's.
35
38
  */
36
39
  const REJECTED_VERSION_FLAGS = new Set([
37
40
  "--row-version",
38
41
  "--expected-row-version",
39
42
  "--row_version",
40
43
  "--expected_row_version",
44
+ "--expected-generation",
45
+ "--expected_generation",
41
46
  ]);
42
47
  /**
43
48
  * Tokenize `--flag value` / `--flag=value` / boolean flags for the recovery
@@ -64,6 +69,10 @@ function tokenizeRecoveryFlags(argv, valueFlags, boolFlags) {
64
69
  "current version itself.");
65
70
  }
66
71
  if (boolFlags.has(name)) {
72
+ if (bools.has(name)) {
73
+ // Refused like a repeated value flag rather than silently collapsed.
74
+ throw new ConductorValidationError(`Flag "${name}" may be given only once.`);
75
+ }
67
76
  bools.add(name);
68
77
  continue;
69
78
  }
@@ -407,12 +416,13 @@ export function decodeDriverContextFile(bytes) {
407
416
  }
408
417
  /**
409
418
  * Render a repair outcome. Human mode prints the verb and run first, the target
410
- * second, ONE labeled `Before → After` pair third, then `committed` and `event
411
- * recorded` — a replay is a successful no-op (`committed: false`), never a
412
- * refusal. `--json` prints the server's bounded result object exactly, or a
413
- * bounded `{ok:false, kind, code, message}` failure. Nothing here can print a
414
- * prompt, driver context, row version, action key, head SHA, or credential: the
415
- * results carry none of them.
419
+ * second, ONE labeled `Before → After` pair third, then whether this call
420
+ * `applied` a change or found it `already current / no change`, then `committed`
421
+ * and `event recorded` — a replay is a successful no-op (`committed: false`),
422
+ * never a refusal. `--json` prints the server's bounded result object exactly, or
423
+ * a bounded `{ok:false, kind, code, message}` failure. Nothing here can print a
424
+ * prompt, driver context, policy value, row version, generation, action key, head
425
+ * SHA, or credential: the results carry none of them.
416
426
  */
417
427
  function renderRepairResult(outcome, input, json, extraLines = () => []) {
418
428
  if (outcome.ok && outcome.result.committed && !outcome.result.event_recorded) {
@@ -449,6 +459,7 @@ function renderRepairResult(outcome, input, json, extraLines = () => []) {
449
459
  `${input.verb} — run ${input.epicRunId}`,
450
460
  ` target: ${input.targetLabel}`,
451
461
  ` ${input.stateLabel}: ${result.before ?? "none"} → ${result.after ?? "none"}`,
462
+ ` result: ${result.committed ? "applied" : "already current / no change"}`,
452
463
  ...extraLines(result),
453
464
  ` committed: ${result.committed}${result.committed ? "" : " (already in this state; no change made)"}`,
454
465
  ` event recorded: ${result.event_recorded}`,
@@ -590,3 +601,223 @@ export async function runMergeChildCommand(argv) {
590
601
  return 1;
591
602
  }
592
603
  }
604
+ // ---------------------------------------------------------------------------
605
+ // BAPI-1176 — repair verbs: scope-catch-up, patch-policy, resume-run
606
+ // ---------------------------------------------------------------------------
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"]);
610
+ /** `--clear-quarantine` is a `resume-run` switch only; no other verb accepts it. */
611
+ const RESUME_RUN_BOOL_FLAGS = new Set(["--json", "--help", "--clear-quarantine"]);
612
+ export function parseScopeCatchUpArgs(argv) {
613
+ const { values, bools } = tokenizeRecoveryFlags(argv, SCOPE_CATCH_UP_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
614
+ if (bools.has("--help")) {
615
+ return { epicRunId: "", scopeId: "", json: bools.has("--json"), help: true };
616
+ }
617
+ return {
618
+ epicRunId: requireFlag(values, "--epic-run-id"),
619
+ scopeId: requireFlag(values, "--scope-id"),
620
+ json: bools.has("--json"),
621
+ help: false,
622
+ };
623
+ }
624
+ export function parsePatchPolicyArgs(argv) {
625
+ const { values, bools } = tokenizeRecoveryFlags(argv, PATCH_POLICY_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
626
+ if (bools.has("--help")) {
627
+ return { epicRunId: "", patchFile: "", json: bools.has("--json"), help: true };
628
+ }
629
+ return {
630
+ epicRunId: requireFlag(values, "--epic-run-id"),
631
+ patchFile: requireFlag(values, "--patch-file"),
632
+ json: bools.has("--json"),
633
+ help: false,
634
+ };
635
+ }
636
+ export function parseResumeRunArgs(argv) {
637
+ const { values, bools } = tokenizeRecoveryFlags(argv, RESUME_RUN_VALUE_FLAGS, RESUME_RUN_BOOL_FLAGS);
638
+ if (bools.has("--help")) {
639
+ return {
640
+ epicRunId: "",
641
+ clearQuarantine: bools.has("--clear-quarantine"),
642
+ json: bools.has("--json"),
643
+ help: true,
644
+ };
645
+ }
646
+ return {
647
+ epicRunId: requireFlag(values, "--epic-run-id"),
648
+ clearQuarantine: bools.has("--clear-quarantine"),
649
+ json: bools.has("--json"),
650
+ help: false,
651
+ };
652
+ }
653
+ /**
654
+ * Validate and decode a `patch-policy` file's BYTES into a merge-patch object.
655
+ *
656
+ * Checked in the order that keeps a rejected file from being decoded, parsed,
657
+ * resolved against credentials, or transmitted: the raw byte length first (against
658
+ * the SAME `POLICY_PATCH_MAX_BYTES` the server enforces), then a FATAL UTF-8
659
+ * decode, then `JSON.parse`, then the shape — a non-empty JSON object. Every error
660
+ * names its category and never echoes the file's content. A `JSON.parse` message
661
+ * is never forwarded, because V8 quotes the offending text in it.
662
+ */
663
+ export function decodePolicyPatchFile(bytes) {
664
+ if (bytes.byteLength > POLICY_PATCH_MAX_BYTES) {
665
+ throw new ConductorValidationError(`--patch-file is ${bytes.byteLength} bytes, over POLICY_PATCH_MAX_BYTES ` +
666
+ `(${POLICY_PATCH_MAX_BYTES} UTF-8 bytes). Shorten it and retry; nothing was sent.`);
667
+ }
668
+ let text;
669
+ try {
670
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
671
+ }
672
+ catch {
673
+ throw new ConductorValidationError("--patch-file must be valid UTF-8 text; nothing was sent.");
674
+ }
675
+ let value;
676
+ try {
677
+ value = JSON.parse(text);
678
+ }
679
+ catch {
680
+ throw new ConductorValidationError("--patch-file is not valid JSON; nothing was sent.");
681
+ }
682
+ if (value === null) {
683
+ throw new ConductorValidationError("--patch-file must hold a JSON object, not null; nothing was sent.");
684
+ }
685
+ if (Array.isArray(value)) {
686
+ throw new ConductorValidationError("--patch-file must hold a JSON object, not an array; nothing was sent.");
687
+ }
688
+ if (typeof value !== "object") {
689
+ throw new ConductorValidationError("--patch-file must hold a JSON object, not a scalar value; nothing was sent.");
690
+ }
691
+ if (Object.keys(value).length === 0) {
692
+ throw new ConductorValidationError("--patch-file is an empty object; a merge patch must change at least one key. " +
693
+ "Nothing was sent.");
694
+ }
695
+ return value;
696
+ }
697
+ const SCOPE_CATCH_UP_USAGE = [
698
+ "Usage: epic-implementer scope-catch-up --epic-run-id <id> --scope-id <scope> [--json]",
699
+ "",
700
+ "Replay merges the run's OWN index scope did not observe, through the same guarded",
701
+ "advance gate the merge webhook uses. There is no force: a blocked advance is",
702
+ "refused (SCOPE_CATCH_UP_REFUSED). The scope must be the one bound to the named",
703
+ "run, or the server refuses it (TICKET_NOT_IN_RUN) with nothing written. Prints",
704
+ "the scope lifecycle before → after; it does not wait for a scheduled parse.",
705
+ ].join("\n");
706
+ export async function runScopeCatchUpCommand(argv) {
707
+ try {
708
+ const parsed = parseScopeCatchUpArgs(argv);
709
+ if (parsed.help) {
710
+ console.log(SCOPE_CATCH_UP_USAGE);
711
+ return 0;
712
+ }
713
+ const access = await resolveRecoveryAccess(parsed.json);
714
+ if (!access)
715
+ return 1;
716
+ const { scopeCatchUpRepair } = await import("./recovery-operations.js");
717
+ const outcome = await scopeCatchUpRepair(access, {
718
+ epicRunId: parsed.epicRunId,
719
+ scopeId: parsed.scopeId,
720
+ });
721
+ return renderRepairResult(outcome, {
722
+ verb: "scope-catch-up",
723
+ epicRunId: parsed.epicRunId,
724
+ targetLabel: `scope ${parsed.scopeId}`,
725
+ stateLabel: "scope lifecycle",
726
+ }, parsed.json, (result) => [
727
+ ` outcome: ${result.outcome}${result.parse_scheduled ? " (parse scheduled)" : ""}`,
728
+ ]);
729
+ }
730
+ catch (error) {
731
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
732
+ return 1;
733
+ }
734
+ }
735
+ const PATCH_POLICY_USAGE = [
736
+ "Usage: epic-implementer patch-policy --epic-run-id <id> --patch-file <path> [--json]",
737
+ "",
738
+ "Apply a JSON merge patch (RFC 7396) to the run's policy ON THE SERVER: it reads",
739
+ "the stored policy, merges your patch, validates the result, and writes it. You",
740
+ "never send or see the whole policy, so a value you cannot see is never dropped.",
741
+ "A null deletes a key. Protected paths (base_branch, notify.webhook_url, and",
742
+ "server-retained keys) are refused. For a whole replacement use",
743
+ "`setup-epic --replace-policy`.",
744
+ "",
745
+ "--patch-file must hold a non-empty JSON object of at most POLICY_PATCH_MAX_BYTES",
746
+ `(${POLICY_PATCH_MAX_BYTES} bytes). Its contents are never printed; the result names only`,
747
+ "the changed dotted paths and before/after policy digest tokens.",
748
+ ].join("\n");
749
+ export async function runPatchPolicyCommand(argv, deps = {}) {
750
+ try {
751
+ const parsed = parsePatchPolicyArgs(argv);
752
+ if (parsed.help) {
753
+ console.log(PATCH_POLICY_USAGE);
754
+ return 0;
755
+ }
756
+ // Read and validate the patch BEFORE credentials are resolved or the HTTP
757
+ // graph is loaded, so an invalid file costs nothing and reaches no one.
758
+ let bytes;
759
+ try {
760
+ bytes = await (deps.readFile ?? ((p) => readFile(p)))(parsed.patchFile);
761
+ }
762
+ catch {
763
+ throw new ConductorValidationError("--patch-file could not be read; nothing was sent.");
764
+ }
765
+ const mergePatch = decodePolicyPatchFile(bytes);
766
+ const access = await resolveRecoveryAccess(parsed.json);
767
+ if (!access)
768
+ return 1;
769
+ const { patchPolicyRepair } = await import("./recovery-operations.js");
770
+ const outcome = await patchPolicyRepair(access, {
771
+ epicRunId: parsed.epicRunId,
772
+ mergePatch,
773
+ });
774
+ return renderRepairResult(outcome, {
775
+ verb: "patch-policy",
776
+ epicRunId: parsed.epicRunId,
777
+ targetLabel: "run policy",
778
+ stateLabel: "policy digest",
779
+ }, parsed.json, (result) => [
780
+ ` policy paths: ${result.policy_paths.length > 0 ? result.policy_paths.join(", ") : "none"}`,
781
+ ]);
782
+ }
783
+ catch (error) {
784
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
785
+ return 1;
786
+ }
787
+ }
788
+ const RESUME_RUN_USAGE = [
789
+ "Usage: epic-implementer resume-run --epic-run-id <id> [--clear-quarantine] [--json]",
790
+ "",
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.",
796
+ ].join("\n");
797
+ export async function runResumeRunCommand(argv) {
798
+ try {
799
+ const parsed = parseResumeRunArgs(argv);
800
+ if (parsed.help) {
801
+ console.log(RESUME_RUN_USAGE);
802
+ return 0;
803
+ }
804
+ const access = await resolveRecoveryAccess(parsed.json);
805
+ if (!access)
806
+ return 1;
807
+ const { resumeRunRepair } = await import("./recovery-operations.js");
808
+ const outcome = await resumeRunRepair(access, {
809
+ epicRunId: parsed.epicRunId,
810
+ clearQuarantine: parsed.clearQuarantine,
811
+ });
812
+ return renderRepairResult(outcome, {
813
+ verb: "resume-run",
814
+ epicRunId: parsed.epicRunId,
815
+ targetLabel: parsed.clearQuarantine ? "run control (clear quarantine)" : "run control",
816
+ stateLabel: "run control",
817
+ }, parsed.json);
818
+ }
819
+ catch (error) {
820
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
821
+ return 1;
822
+ }
823
+ }
@@ -2,8 +2,9 @@
2
2
  * Shared Bridge API operator-recovery operations (BAPI-872).
3
3
  *
4
4
  * Centralizes stop, abandon, ticket unpark/adopt-current-head-and-unpark, and
5
- * (BAPI-1154) the three run-addressed repair verbs — scope-reparse,
6
- * dispatch-remediation, merge-child — so
5
+ * the six run-addressed repair verbs — (BAPI-1154) scope-reparse,
6
+ * dispatch-remediation, merge-child and (BAPI-1176) scope-catch-up,
7
+ * patch-policy, resume-run — so
7
8
  * the conductor CLI recovery verbs (`recovery-cli.ts`) and `plane down`
8
9
  * (`../plane/shutdown.ts`, wired through `../plane/cli.ts`) share EXACTLY the
9
10
  * same stop semantics, and so this module — never a caller — owns every
@@ -17,7 +18,7 @@
17
18
  * callers: `recovery-cli.ts` and `plane/shutdown.ts`.
18
19
  */
19
20
  import { randomUUID } from "crypto";
20
- import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, safeDiagnosticMessage, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
21
+ import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
21
22
  import { DEFAULT_GATE_NAME } from "./git-ci-types.js";
22
23
  import { resolveLocalMergeMethod, runApprovedLocalMerge } from "./local-merge.js";
23
24
  /**
@@ -223,7 +224,11 @@ export async function adoptCurrentHeadAndUnparkWithRetry(access, options) {
223
224
  }
224
225
  /**
225
226
  * Classify a thrown client error. A 4xx carrying a server `error_code` is a
226
- * NAMED refusal and keeps that code; everything else (transport, timeout, 5xx
227
+ * NAMED refusal and keeps that code — whether it is one of the closed repair
228
+ * codes in `repair-contract.ts` or an established pass-through code the server's
229
+ * own posture, activation, or run-control checks emit (`RUN_CONTROL_MALFORMED`,
230
+ * for example). Both are rendered from server-owned error data; this layer never
231
+ * rewrites or re-infers a code. Everything else (transport, timeout, 5xx
227
232
  * without a code, an unparseable body) is `unavailable` with a bounded
228
233
  * diagnostic. No raw body, URL, header, or credential ever reaches either shape.
229
234
  */
@@ -340,3 +345,50 @@ export async function mergeChildRepair(access, options, deps = {}) {
340
345
  return classifyRepairError(err, "merge-child completion failed");
341
346
  }
342
347
  }
348
+ // ---------------------------------------------------------------------------
349
+ // BAPI-1176 — scope-catch-up, patch-policy, resume-run
350
+ // ---------------------------------------------------------------------------
351
+ // Thin like scope-reparse: one mutation call, the server's result returned
352
+ // verbatim, every failure through the shared classifier.
353
+ /**
354
+ * Catch the run's own index scope up with merges it did not observe. The server
355
+ * decides ownership, and a blocked advance is a refusal — never a forced repair.
356
+ */
357
+ export async function scopeCatchUpRepair(access, options, deps = {}) {
358
+ try {
359
+ const call = deps.scopeCatchUp ?? scopeCatchUp;
360
+ return { ok: true, result: await call(access, options) };
361
+ }
362
+ catch (err) {
363
+ return classifyRepairError(err, "scope-catch-up request failed");
364
+ }
365
+ }
366
+ /**
367
+ * Apply a merge patch to the run's policy. `mergePatch` must already be a
368
+ * validated non-empty object within `POLICY_PATCH_MAX_BYTES`; the server does the
369
+ * read, merge, validation, and guarded write, and reports only path names and
370
+ * digest tokens.
371
+ */
372
+ export async function patchPolicyRepair(access, options, deps = {}) {
373
+ try {
374
+ const call = deps.patchPolicy ?? patchPolicy;
375
+ return { ok: true, result: await call(access, options) };
376
+ }
377
+ catch (err) {
378
+ return classifyRepairError(err, "patch-policy request failed");
379
+ }
380
+ }
381
+ /**
382
+ * Resume a paused run, or a quarantined one with `clearQuarantine`. The server
383
+ * re-evaluates activation and owns the control-generation CAS retry; nothing here
384
+ * reads or sends a generation.
385
+ */
386
+ export async function resumeRunRepair(access, options, deps = {}) {
387
+ try {
388
+ const call = deps.resumeRun ?? resumeRun;
389
+ return { ok: true, result: await call(access, options) };
390
+ }
391
+ catch (err) {
392
+ return classifyRepairError(err, "resume-run request failed");
393
+ }
394
+ }