@bridge_gpt/mcp-server 0.2.68 → 0.2.70

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 (166) hide show
  1. package/README.md +226 -320
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +650 -98
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-review-workflow-drift.js +3 -3
  9. package/build/claude-settings.js +84 -2
  10. package/build/cli-help.js +7 -1
  11. package/build/codex-bridge-profile.js +1 -1
  12. package/build/codex-home.js +9 -1
  13. package/build/codex-launcher-pin.js +598 -0
  14. package/build/commands.generated.js +8 -8
  15. package/build/conductor/bridge-api-client.js +598 -19
  16. package/build/conductor/bring-up-facts.js +9 -9
  17. package/build/conductor/doctor.js +466 -18
  18. package/build/conductor/epic-child-continuation.js +54 -3
  19. package/build/conductor/epic-implementer-cli.js +132 -34
  20. package/build/conductor/epic-runtime.js +2 -2
  21. package/build/conductor/epic-state.js +1 -1
  22. package/build/conductor/hook-runtime-qualification.js +1 -1
  23. package/build/conductor/install-doctor.js +4 -2
  24. package/build/conductor/launch-need-probes.js +337 -0
  25. package/build/conductor/local-merge.js +241 -56
  26. package/build/conductor/merge-credential.js +172 -0
  27. package/build/conductor/merge-execution-eligibility.js +1 -1
  28. package/build/conductor/merged-child-check.js +95 -0
  29. package/build/conductor/plan-trim.js +30 -0
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +11 -6
  32. package/build/conductor/readiness-prerequisites.js +41 -0
  33. package/build/conductor/readiness-sections.js +345 -55
  34. package/build/conductor/readiness.js +99 -87
  35. package/build/conductor/recovery-cli.js +606 -21
  36. package/build/conductor/recovery-operations.js +306 -7
  37. package/build/conductor/repair-contract.js +47 -2
  38. package/build/conductor/repository-workflow-remedy.js +8 -6
  39. package/build/conductor/stored-merge-credential.js +76 -0
  40. package/build/conductor/stored-merge-token-probe.js +28 -0
  41. package/build/conductor/temporary-plan.js +50 -0
  42. package/build/conductor/wait-run-cli.js +759 -0
  43. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  44. package/build/conductor-bin.js +2 -2
  45. package/build/conductor-claude-hook-bin.js +2 -2
  46. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  47. package/build/conductor-removed-stub-bin.js +2 -5
  48. package/build/connect-github-api.js +33 -14
  49. package/build/credential-store.js +356 -13
  50. package/build/docs.generated.js +27 -14
  51. package/build/doctor.js +203 -38
  52. package/build/drive-epic-help.js +212 -0
  53. package/build/drive-epic.js +1840 -373
  54. package/build/epic-ci-workflow-drift.js +77 -0
  55. package/build/epic-ci-workflow-install.js +183 -0
  56. package/build/epic-ci-workflow.js +157 -0
  57. package/build/epic-contract-context.js +326 -0
  58. package/build/epic-implementer-bin.js +131 -120
  59. package/build/epic-implementer-claude-hook-bin.js +2 -2
  60. package/build/escalation-reachability.js +134 -0
  61. package/build/executor/cleanup-report.js +99 -0
  62. package/build/executor/cli.js +12 -1
  63. package/build/executor/completion-contract.js +68 -11
  64. package/build/executor/completion-supervision.js +99 -0
  65. package/build/executor/delivery-outcome.js +179 -0
  66. package/build/executor/deps.js +3 -0
  67. package/build/executor/env.js +71 -0
  68. package/build/executor/failure-reason-codes.js +126 -0
  69. package/build/executor/heartbeat.js +38 -0
  70. package/build/executor/http-client.js +115 -4
  71. package/build/executor/integration-job.js +298 -5
  72. package/build/executor/job-errors.js +36 -6
  73. package/build/executor/job-runner.js +720 -36
  74. package/build/executor/likely-tests.js +36 -2
  75. package/build/executor/merge-job.js +145 -2
  76. package/build/executor/observation.js +73 -4
  77. package/build/executor/permissions.js +193 -97
  78. package/build/executor/preflight.js +14 -3
  79. package/build/executor/process-containment.js +28 -2
  80. package/build/executor/prompt-spec.js +50 -5
  81. package/build/executor/rebase-finalization.js +38 -3
  82. package/build/executor/reconcile-overlap-job.js +35 -7
  83. package/build/executor/required-work-ledger.js +236 -81
  84. package/build/executor/resume-pre-spawn.js +38 -1
  85. package/build/executor/runner.js +91 -3
  86. package/build/executor/test-clock.js +8 -0
  87. package/build/executor/types.js +37 -2
  88. package/build/executor/worker-cli-launcher.js +259 -0
  89. package/build/executor/worker-command.js +41 -4
  90. package/build/executor/worker-database-target.js +91 -0
  91. package/build/executor/worker-database.js +908 -0
  92. package/build/executor/worker-finalization.js +106 -0
  93. package/build/executor/worker-guard-hook.js +203 -0
  94. package/build/executor/worker-resources.js +47 -0
  95. package/build/executor/worker-verification-guidance.js +15 -2
  96. package/build/executor/worktree.js +303 -15
  97. package/build/executor-launch.js +11 -1
  98. package/build/generated-assets-doctor.js +9 -1
  99. package/build/generated-assets-health.js +13 -4
  100. package/build/generated-assets-legacy.js +39 -0
  101. package/build/generated-assets-paths.js +12 -1
  102. package/build/generated-assets-repair.js +4 -1
  103. package/build/generated-assets-test-support.js +12 -0
  104. package/build/github-remote.js +33 -0
  105. package/build/github-sign-in.js +62 -0
  106. package/build/index.js +1214 -465
  107. package/build/init.js +39 -1
  108. package/build/install-bridge-conductor-gate.js +78 -0
  109. package/build/install-bridge-conductor-setup.js +413 -0
  110. package/build/install-bridge-conductor.js +1226 -267
  111. package/build/install-bridge.js +629 -350
  112. package/build/launcher-config-inspection.js +184 -19
  113. package/build/mcp-host-targets.js +4 -0
  114. package/build/mcp-identity.js +7 -0
  115. package/build/mcp-install-state.js +8 -2
  116. package/build/merge-pull-request.js +6 -0
  117. package/build/merge-workflow-install.js +763 -345
  118. package/build/phase-result-artifacts.js +75 -20
  119. package/build/pipeline-orchestrator.js +247 -4
  120. package/build/pipelines.generated.js +35 -35
  121. package/build/plan-discovery.js +15 -2
  122. package/build/plan-execution-ledger.js +306 -17
  123. package/build/plan-phase-routing.js +48 -13
  124. package/build/plane/cli.js +105 -51
  125. package/build/plane/defaults.js +3 -0
  126. package/build/plane/plane-freshness.js +294 -0
  127. package/build/plane/schema-compatibility.js +137 -0
  128. package/build/plane/shutdown-deps.js +38 -0
  129. package/build/plane/shutdown.js +83 -8
  130. package/build/plane/terminal-run-cleanup.js +303 -0
  131. package/build/printed-remedy-commands.js +26 -4
  132. package/build/readiness-check.js +6 -2
  133. package/build/readiness-prerequisites.generated.js +532 -0
  134. package/build/readme.generated.js +1 -1
  135. package/build/session-upgrade-notice.js +86 -0
  136. package/build/setup-epic.js +400 -30
  137. package/build/shared-resources.generated.js +5 -0
  138. package/build/start-tickets.js +43 -15
  139. package/build/test-command-detection.js +247 -0
  140. package/build/test-command-validation.js +73 -0
  141. package/build/testing-config-client.js +150 -0
  142. package/build/ticket-delivery-artifacts.js +258 -0
  143. package/build/ticket-delivery-enrollment.js +306 -0
  144. package/build/ticket-delivery.js +205 -0
  145. package/build/ticket-review-summary.js +14 -0
  146. package/build/tool-call-advisory.js +162 -0
  147. package/build/tool-surface-gating.js +28 -89
  148. package/build/update-check.js +97 -16
  149. package/build/update-status.js +61 -71
  150. package/build/upgrade-advice.js +15 -11
  151. package/build/upgrade-cli.js +360 -20
  152. package/build/v2-readiness-requirements.js +552 -0
  153. package/build/version.generated.js +3 -3
  154. package/build/worker-guard-hook-bin.js +2 -2
  155. package/build/worktree-core.js +22 -7
  156. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  157. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  158. package/docs/EPIC-IMPLEMENTER.md +104 -63
  159. package/docs/epic-implementer/getting-started.md +131 -0
  160. package/docs/epic-implementer/self-hosting.md +133 -0
  161. package/package.json +3 -3
  162. package/pipelines/idea-to-ticket.json +1 -1
  163. package/pipelines/implement-ticket.json +8 -8
  164. package/pipelines/review-ticket.json +2 -2
  165. package/design-assets/favicon/favicon-32x32.png +0 -0
  166. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -8,6 +8,11 @@
8
8
  * inside the DAG — every one of those is a way to hand the wrong plan to a run
9
9
  * that then creates real tickets' worth of work.
10
10
  *
11
+ * A folder holding a regular DAG but no regular metadata is reported as a skipped
12
+ * `missing-meta` entry so the operator can see it. It is only reported: the DAG
13
+ * supplies no identity, so such a folder is never a candidate and never changes
14
+ * the result.
15
+ *
11
16
  * Every filesystem read goes through the injected {@link PlanDiscoveryFs}, so a
12
17
  * unit test reaches no real disk. Nothing here throws for a filesystem failure:
13
18
  * an unreadable or malformed entry is named in `skipped` with a fixed reason and
@@ -22,6 +27,7 @@ export const PLAN_DISCOVERY_SKIP_REASONS = [
22
27
  "not-an-object",
23
28
  "missing-or-non-string-epic-key",
24
29
  "missing-dag",
30
+ "missing-meta",
25
31
  "unlistable-directory",
26
32
  ];
27
33
  const META_FILE = "epic-plan.meta.json";
@@ -77,9 +83,16 @@ export async function discoverEpicPlan(epicKey, docsDir, fs) {
77
83
  }
78
84
  const isRegular = (entry) => entry.isFile() && entry.isSymbolicLink?.() !== true;
79
85
  const meta = files.find((entry) => entry.name === META_FILE && isRegular(entry));
80
- if (meta === undefined)
81
- continue;
82
86
  const metaPath = path.join(dirPath, META_FILE);
87
+ if (meta === undefined) {
88
+ // A folder with a regular DAG but no regular metadata is expected-but-
89
+ // missing metadata: name it, never count it. The DAG supplies no identity.
90
+ // A folder with neither regular file stays silent.
91
+ if (files.some((entry) => entry.name === DAG_FILE && isRegular(entry))) {
92
+ skipped.push({ path: metaPath, reason: "missing-meta" });
93
+ }
94
+ continue;
95
+ }
83
96
  let raw;
84
97
  try {
85
98
  raw = await fs.readFile(metaPath);
@@ -26,6 +26,14 @@
26
26
  * chronological evidence and refuses to silently downgrade or drop a settled step,
27
27
  * so the record survives an approval pause or a resumed session — the conversational
28
28
  * hand-off it replaces did not.
29
+ * - **Post-PR ownership is server-accepted, never self-granted (BAPI-1426).** A
30
+ * pre-PR verification step that genuinely needs an open PR escalates with a
31
+ * `prDependency` mark. `post_pr_gap_close` owns exactly the steps whose effective
32
+ * *server-accepted* record is such an escalation ({@link derivePostPrOwnedSteps}).
33
+ * The effective record is the latest one, except that a same-identity `remote_ci`
34
+ * deferral survives plain post-PR re-escalations (BAPI-1488). Without an accepted ledger context it owns nothing, so a local mark alone can
35
+ * never route work past the PR boundary. This mirrors
36
+ * `api/library/vcs/ticket_delivery_identity.py`; the server stays the authority.
29
37
  *
30
38
  * Pure domain logic: no I/O, no git, no network, no filesystem, no MCP transport.
31
39
  */
@@ -73,6 +81,46 @@ export const MECHANICAL_ADAPTATION_KINDS = [
73
81
  "repository-command-correction",
74
82
  "equivalent-implementation-recognized",
75
83
  ];
84
+ /**
85
+ * BAPI-1426: the closed set of open-PR checks a pre-PR verification step may wait on.
86
+ *
87
+ * Mirrored from `PR_DEPENDENCY_VALUES` in `api/library/vcs/ticket_delivery_identity.py`.
88
+ * A mark is accepted only on an escalated `pre_pr_verification` record for a step whose
89
+ * class that phase owns. Missing implementation can never cross the pre-PR boundary.
90
+ */
91
+ export const PR_DEPENDENCY_VALUES = [
92
+ "remote_ci",
93
+ "pr_review",
94
+ "pr_preview_environment",
95
+ ];
96
+ /**
97
+ * BAPI-1492: the verification hand-off vocabulary. Mirrored from `HANDOFF_CAUSES`,
98
+ * `HANDOFF_ELIGIBLE_CLASSES`, and `HANDOFF_FIELDS` in
99
+ * `api/library/vcs/ticket_delivery_identity.py`; both sides must agree.
100
+ *
101
+ * A hand-off is additive metadata on an escalated `pre_pr_verification` record for
102
+ * an E2E or rendered-UI step whose check could not run because a prerequisite was
103
+ * missing. It is never a disposition and never a `prDependency` value. Whether it
104
+ * may satisfy the step is decided only by the server, from persisted run state.
105
+ */
106
+ export const HANDOFF_CAUSES = [
107
+ "no_live_server",
108
+ "no_database",
109
+ "toolchain_unavailable",
110
+ "credential_unavailable",
111
+ ];
112
+ export const HANDOFF_ELIGIBLE_CLASSES = ["e2e_tests", "rendered_ui_review"];
113
+ export const HANDOFF_FIELDS = [
114
+ "cause",
115
+ "criterion",
116
+ "observation",
117
+ "observed_head_sha",
118
+ "next_action",
119
+ ];
120
+ /** Mirrors the server's `MAX_EVIDENCE_TEXT`: longer text is truncated, not refused. */
121
+ export const HANDOFF_MAX_TEXT = 4000;
122
+ const HANDOFF_TEXT_FIELDS = ["criterion", "observation", "next_action"];
123
+ const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/;
76
124
  /**
77
125
  * Change categories that may never be adapted. Meeting one of these means the step's
78
126
  * premise is wrong in a way only a human should resolve, so the run escalates.
@@ -198,13 +246,12 @@ export function validatePlanMetadata(value) {
198
246
  if (startStep <= previousEnd) {
199
247
  throw new PlanLedgerError(`plan metadata part '${partId}' range ${startStep}-${endStep} overlaps or precedes '${previousId}' ending at ${previousEnd}; ranges must be disjoint and ascending`);
200
248
  }
201
- if (startStep > previousEnd + 1) {
202
- // A gap is as dangerous as an overlap and far less visible. Routing iterates
203
- // only the declared ranges, so a step falling in the gap is owned by no phase
204
- // and settled by nobody — Miss 20's shape, reached by arithmetic rather than by
205
- // a missing registry entry. Rejected here, before any phase runs.
206
- throw new PlanLedgerError(`plan metadata part '${partId}' starts at step ${startStep} but '${previousId}' ended at ${previousEnd}; steps ${previousEnd + 1}-${startStep - 1} are claimed by no part and would be executed by no phase`);
207
- }
249
+ // A numbering SKIP between parts (one ends at 16, the next starts at 18) is
250
+ // legal (BAPI-1487). Ranges come from the step numbers in each part's own body,
251
+ // so a skipped number is a step no part wrote. resolveOwnedSteps iterates only
252
+ // declared ranges, so it is owned by no phase, which is correct: it does not
253
+ // exist. The plan generator separately refuses a numbered step that sits in
254
+ // the assembled plan outside every declared range.
208
255
  previousEnd = endStep;
209
256
  previousId = partId;
210
257
  parts.push({
@@ -244,14 +291,164 @@ export function validatePlanMetadata(value) {
244
291
  total_steps: totalSteps,
245
292
  };
246
293
  }
247
- // ---------------------------------------------------------------------------
248
- // Routing
249
- // ---------------------------------------------------------------------------
250
- /** Inclusive step IDs owned by `phase`, in plan order. */
251
- export function resolveOwnedSteps(metadata, phase) {
294
+ /** True when an accepted record is a pre-PR escalation carrying an accepted mark. */
295
+ export function isAcceptedPrDependentEscalation(record) {
296
+ return (record.disposition === "escalated" &&
297
+ typeof record.pr_dependency === "string" &&
298
+ PR_DEPENDENCY_VALUES.includes(record.pr_dependency) &&
299
+ record.phase === "pre_pr_verification");
300
+ }
301
+ /** An absent or empty text marker; any other type is malformed. Mirrors the server. */
302
+ function isEmptyMarker(value) {
303
+ return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
304
+ }
305
+ /** The record's (plan digest, specification identity) pair, or undefined unless both are real. */
306
+ function identityPair(record) {
307
+ const { plan_digest: planDigest, specification_identity: specification } = record;
308
+ if (typeof planDigest === "string" &&
309
+ planDigest.trim() !== "" &&
310
+ typeof specification === "string" &&
311
+ specification.trim() !== "") {
312
+ return [planDigest, specification];
313
+ }
314
+ return undefined;
315
+ }
316
+ /** An unmarked, unfailed escalation in `phase` that carries real identities. */
317
+ function isCleanEscalation(record, phase) {
318
+ return (record.phase === phase &&
319
+ record.disposition === "escalated" &&
320
+ record.verification_failed === false &&
321
+ isEmptyMarker(record.invalidated_by) &&
322
+ identityPair(record) !== undefined);
323
+ }
324
+ /** A post-PR escalation that repeats no dependency mark and names no failure. */
325
+ function isPlainPostPrReescalation(record) {
326
+ return isCleanEscalation(record, "post_pr_gap_close") && isEmptyMarker(record.pr_dependency);
327
+ }
328
+ /** A clean pre-PR escalation that deferred the step to the PR's remote CI. */
329
+ function isRemoteCiDeferral(record) {
330
+ return isCleanEscalation(record, "pre_pr_verification") && record.pr_dependency === "remote_ci";
331
+ }
332
+ function sameIdentity(a, b) {
333
+ return a !== undefined && b !== undefined && a[0] === b[0] && a[1] === b[1];
334
+ }
335
+ /**
336
+ * The record that currently decides each step (BAPI-1488). Mirrors
337
+ * `effective_accepted_records_by_step` in `api/library/vcs/ticket_delivery_identity.py`.
338
+ *
339
+ * Normally that is the latest accepted record: highest `revision`, with ties (and
340
+ * absent revisions) broken by array order. When the latest is a plain post-PR
341
+ * re-escalation, the walk goes backward through the contiguous run of them and returns
342
+ * the preceding `remote_ci` deferral, but only if every record in the chain shares its
343
+ * non-empty plan digest and specification identity. The first record that does not
344
+ * fit is a barrier, and the literal latest record stays effective.
345
+ */
346
+ function effectiveAcceptedRecords(records) {
347
+ const histories = new Map();
348
+ for (const record of records) {
349
+ if (!record || !isPositiveInteger(record.step_id))
350
+ continue;
351
+ const revision = typeof record.revision === "number" ? record.revision : -Infinity;
352
+ const history = histories.get(record.step_id) ?? [];
353
+ history.push({ record, revision });
354
+ histories.set(record.step_id, history);
355
+ }
356
+ const effective = new Map();
357
+ for (const [stepId, history] of histories) {
358
+ // Array.prototype.sort is stable, so equal revisions keep array order.
359
+ const ordered = history
360
+ .map((entry, index) => ({ ...entry, index }))
361
+ .sort((a, b) => (a.revision === b.revision ? a.index - b.index : a.revision < b.revision ? -1 : 1))
362
+ .map((entry) => entry.record);
363
+ const latest = ordered[ordered.length - 1];
364
+ effective.set(stepId, latest);
365
+ if (!isPlainPostPrReescalation(latest))
366
+ continue;
367
+ const identity = identityPair(latest);
368
+ for (let i = ordered.length - 2; i >= 0; i -= 1) {
369
+ const earlier = ordered[i];
370
+ if (!sameIdentity(identityPair(earlier), identity))
371
+ break;
372
+ if (isRemoteCiDeferral(earlier)) {
373
+ effective.set(stepId, earlier);
374
+ break;
375
+ }
376
+ if (!isPlainPostPrReescalation(earlier))
377
+ break;
378
+ }
379
+ }
380
+ return effective;
381
+ }
382
+ /**
383
+ * The steps `post_pr_gap_close` owns: those whose EFFECTIVE accepted record is an
384
+ * escalated `pre_pr_verification` record with an accepted `pr_dependency`.
385
+ *
386
+ * The effective record is the latest one (highest `revision`, ties and absent
387
+ * revisions broken by array order), except that a same-identity `remote_ci` deferral
388
+ * survives plain post-PR re-escalations: the server refuses `prDependency` on post-PR
389
+ * records, so a restated step cannot repeat the mark. Failures, invalidation markers,
390
+ * resolutions, other dependencies, and missing or mismatched identities stop the
391
+ * carry-forward. No context means no ownership — a local mark alone never routes work.
392
+ */
393
+ export function derivePostPrOwnedSteps(accepted) {
394
+ if (!accepted || !Array.isArray(accepted.acceptedRecords))
395
+ return [];
396
+ return [...effectiveAcceptedRecords(accepted.acceptedRecords).entries()]
397
+ .filter(([, record]) => isAcceptedPrDependentEscalation(record))
398
+ .map(([stepId]) => stepId)
399
+ .sort((a, b) => a - b);
400
+ }
401
+ /**
402
+ * BAPI-1473: the latest server-accepted record per step, limited to `steps`.
403
+ *
404
+ * A head-moving engine job (`ci_fix`, `remediate`, `rebase`) can run in a fresh
405
+ * worktree whose local journal is empty. Its renewal call must still settle every
406
+ * step the phase owns, re-reporting a record that is still current as it stands,
407
+ * so it needs to see what the server last accepted. Latest uses the same rule as
408
+ * {@link derivePostPrOwnedSteps}: highest `revision`, ties broken by array order.
409
+ * No context means an empty list; the local journal is never rewritten from it.
410
+ */
411
+ export function latestAcceptedRecordsForSteps(accepted, steps) {
412
+ if (!accepted || !Array.isArray(accepted.acceptedRecords))
413
+ return [];
414
+ const wanted = new Set(steps);
415
+ const latest = new Map();
416
+ for (const record of accepted.acceptedRecords) {
417
+ if (!record || !isPositiveInteger(record.step_id) || !wanted.has(record.step_id))
418
+ continue;
419
+ const revision = typeof record.revision === "number" ? record.revision : -Infinity;
420
+ const prior = latest.get(record.step_id);
421
+ if (prior === undefined || revision >= prior.revision) {
422
+ latest.set(record.step_id, { record, revision });
423
+ }
424
+ }
425
+ return [...latest.values()]
426
+ .map(({ record }) => ({
427
+ step_id: record.step_id,
428
+ phase: record.phase,
429
+ disposition: record.disposition,
430
+ pr_dependency: record.pr_dependency ?? null,
431
+ ...(typeof record.revision === "number" ? { revision: record.revision } : {}),
432
+ }))
433
+ .sort((a, b) => a.step_id - b.step_id);
434
+ }
435
+ /** Every step present in some part of the plan. */
436
+ function stepIsInPlan(metadata, stepId) {
437
+ return metadata.parts.some((part) => stepId >= part.start_step && stepId <= part.end_step);
438
+ }
439
+ /**
440
+ * Inclusive step IDs owned by `phase`, in plan order.
441
+ *
442
+ * `produce` and `pre_pr_verification` own by provenance class. `post_pr_gap_close`
443
+ * owns dynamically, from the server-accepted ledger, limited to steps the plan has.
444
+ */
445
+ export function resolveOwnedSteps(metadata, phase, accepted) {
252
446
  if (!PLAN_PHASES.includes(phase)) {
253
447
  throw new PlanLedgerError(`unknown phase '${phase}'`);
254
448
  }
449
+ if (phase === "post_pr_gap_close") {
450
+ return derivePostPrOwnedSteps(accepted).filter((step) => stepIsInPlan(metadata, step));
451
+ }
255
452
  const owned = [];
256
453
  for (const part of metadata.parts) {
257
454
  if (PLAN_CLASS_OWNERSHIP[part.provenance_class] !== phase)
@@ -298,7 +495,7 @@ export function assertStepClassCoverage(metadata, ownership = PLAN_CLASS_OWNERSH
298
495
  // ---------------------------------------------------------------------------
299
496
  // Phase results
300
497
  // ---------------------------------------------------------------------------
301
- function validateRecordShape(record, metadata) {
498
+ function validateRecordShape(record, metadata, phase) {
302
499
  if (!isPositiveInteger(record.stepId)) {
303
500
  throw new PlanLedgerError(`ledger record has a non-positive stepId ${String(record.stepId)}`);
304
501
  }
@@ -328,6 +525,12 @@ function validateRecordShape(record, metadata) {
328
525
  throw new PlanLedgerError(`step ${record.stepId} is 'escalated' but gives no blocking reason; an escalation must never read as completion`);
329
526
  }
330
527
  }
528
+ if (record.handoff !== undefined) {
529
+ validateHandoff(record, metadata, phase);
530
+ }
531
+ if (record.prDependency !== undefined) {
532
+ validatePrDependency(record, metadata, phase);
533
+ }
331
534
  if (record.disposition === "unrun-advisory") {
332
535
  const owningPart = metadata.parts.find((part) => record.stepId >= part.start_step && record.stepId <= part.end_step);
333
536
  if (!owningPart || !owningPart.declared_advisory) {
@@ -356,14 +559,98 @@ function validateRecordShape(record, metadata) {
356
559
  }
357
560
  }
358
561
  }
562
+ /**
563
+ * Structural rules for the BAPI-1426 `prDependency` mark, mirroring the server's
564
+ * `_validate_pr_dependency`. The server also checks that the reason names the check;
565
+ * here an escalation reason is already required, so only the shape is enforced.
566
+ */
567
+ function validatePrDependency(record, metadata, phase) {
568
+ const value = record.prDependency;
569
+ if (typeof value !== "string" || !PR_DEPENDENCY_VALUES.includes(value)) {
570
+ throw new PlanLedgerError(`step ${record.stepId} has unsupported prDependency '${String(value)}'; expected one of ${PR_DEPENDENCY_VALUES.join(", ")}`);
571
+ }
572
+ if (record.disposition !== "escalated") {
573
+ throw new PlanLedgerError(`step ${record.stepId} carries prDependency but is '${record.disposition}'; the mark is accepted only on an escalated record`);
574
+ }
575
+ if (phase !== "pre_pr_verification") {
576
+ throw new PlanLedgerError(`step ${record.stepId} carries prDependency in phase '${phase}'; the mark is accepted only on a pre_pr_verification record, and post-PR records must omit it`);
577
+ }
578
+ const owningPart = metadata.parts.find((part) => record.stepId >= part.start_step && record.stepId <= part.end_step);
579
+ if (!owningPart || PLAN_CLASS_OWNERSHIP[owningPart.provenance_class] !== "pre_pr_verification") {
580
+ throw new PlanLedgerError(`step ${record.stepId} carries prDependency, which is never accepted on implementation or documentation work; missing implementation cannot cross the pre-PR boundary`);
581
+ }
582
+ if (typeof record.escalationReason !== "string" || record.escalationReason.trim() === "") {
583
+ throw new PlanLedgerError(`step ${record.stepId} carries prDependency but gives no escalationReason naming the check that needs an open PR`);
584
+ }
585
+ }
586
+ /**
587
+ * BAPI-1492: structural rules for a verification hand-off, mirroring the server's
588
+ * `_validate_handoff` with the same wording. Authority, prior-ledger history,
589
+ * verification failures, and the observed head are server-only decisions.
590
+ */
591
+ export function validateHandoff(record, metadata, phase) {
592
+ const fail = (reason) => {
593
+ throw new PlanLedgerError(`step ${record.stepId}: ${reason}`);
594
+ };
595
+ const value = record.handoff;
596
+ const isObject = typeof value === "object" && value !== null && !Array.isArray(value);
597
+ const keys = isObject ? Object.keys(value).sort() : [];
598
+ const expected = [...HANDOFF_FIELDS].sort();
599
+ if (!isObject || keys.length !== expected.length || keys.some((k, i) => k !== expected[i])) {
600
+ fail(`handoff must be an object with exactly ${HANDOFF_FIELDS.join(", ")}`);
601
+ }
602
+ const handoff = value;
603
+ if (!HANDOFF_CAUSES.includes(handoff.cause)) {
604
+ fail(`handoff cause must be one of ${HANDOFF_CAUSES.join(", ")}`);
605
+ }
606
+ for (const field of HANDOFF_TEXT_FIELDS) {
607
+ const text = handoff[field];
608
+ if (typeof text !== "string" || text.trim() === "") {
609
+ fail(`handoff is missing ${field}`);
610
+ }
611
+ }
612
+ const sha = handoff.observed_head_sha;
613
+ if (typeof sha !== "string" || !FULL_SHA_PATTERN.test(sha.trim().toLowerCase())) {
614
+ fail("handoff observed_head_sha must be a full commit SHA");
615
+ }
616
+ if (record.disposition !== "escalated") {
617
+ fail("handoff is accepted only on an escalated record");
618
+ }
619
+ const owningPart = metadata.parts.find((part) => record.stepId >= part.start_step && record.stepId <= part.end_step);
620
+ if (phase !== "pre_pr_verification" ||
621
+ !owningPart ||
622
+ PLAN_CLASS_OWNERSHIP[owningPart.provenance_class] !== "pre_pr_verification") {
623
+ fail("handoff is accepted only on a pre_pr_verification record");
624
+ }
625
+ if (!HANDOFF_ELIGIBLE_CLASSES.includes(owningPart.provenance_class)) {
626
+ fail("handoff is accepted only for e2e_tests or rendered_ui_review steps; review and other verification steps always run");
627
+ }
628
+ if (record.prDependency !== undefined) {
629
+ fail("handoff cannot be combined with prDependency");
630
+ }
631
+ }
632
+ /** Trim, bound, and lowercase a validated hand-off the way the server stores it. */
633
+ function normalizeHandoff(handoff) {
634
+ const bound = (text) => text.trim().slice(0, HANDOFF_MAX_TEXT);
635
+ return {
636
+ cause: handoff.cause,
637
+ criterion: bound(handoff.criterion),
638
+ observation: bound(handoff.observation),
639
+ observed_head_sha: handoff.observed_head_sha.trim().toLowerCase(),
640
+ next_action: bound(handoff.next_action),
641
+ };
642
+ }
359
643
  /**
360
644
  * Validate one phase's reported outcome against the steps it owns.
361
645
  *
362
646
  * Every owned step must be settled exactly once. An omission is the historical bug
363
647
  * (work quietly not done), a duplicate makes the record ambiguous, and an unowned
364
648
  * step means the phase acted outside its routing.
649
+ *
650
+ * For `post_pr_gap_close`, `accepted` is the server-accepted ledger as it stood
651
+ * BEFORE this envelope, so an envelope cannot grant itself post-PR ownership.
365
652
  */
366
- export function validatePhaseResult(result, metadata, phase) {
653
+ export function validatePhaseResult(result, metadata, phase, accepted) {
367
654
  if (!result || typeof result !== "object") {
368
655
  throw new PlanLedgerError("phase result is missing");
369
656
  }
@@ -376,10 +663,10 @@ export function validatePhaseResult(result, metadata, phase) {
376
663
  if (!Array.isArray(result.records)) {
377
664
  throw new PlanLedgerError(`phase '${phase}' result has no records array`);
378
665
  }
379
- const owned = new Set(resolveOwnedSteps(metadata, phase));
666
+ const owned = new Set(resolveOwnedSteps(metadata, phase, accepted));
380
667
  const settled = new Set();
381
668
  for (const record of result.records) {
382
- validateRecordShape(record, metadata);
669
+ validateRecordShape(record, metadata, phase);
383
670
  if (!owned.has(record.stepId)) {
384
671
  throw new PlanLedgerError(`phase '${phase}' reported step ${record.stepId}, which it does not own`);
385
672
  }
@@ -392,7 +679,9 @@ export function validatePhaseResult(result, metadata, phase) {
392
679
  if (missing.length > 0) {
393
680
  throw new PlanLedgerError(`phase '${phase}' left owned steps unsettled: ${missing.join(", ")}. Every owned step must end as executed, adapted, escalated, or declared-advisory — there is no silent skip.`);
394
681
  }
395
- return result.records.map((record) => ({ ...record, phase }));
682
+ return result.records.map((record) => record.handoff !== undefined
683
+ ? { ...record, phase, handoff: normalizeHandoff(record.handoff) }
684
+ : { ...record, phase });
396
685
  }
397
686
  // ---------------------------------------------------------------------------
398
687
  // Durable merge
@@ -89,17 +89,43 @@ function findPlanMetadataCandidate(value, depth) {
89
89
  }
90
90
  return undefined;
91
91
  }
92
- export function buildPhaseRoutingContext(metadata, phase, ledger, checkpoint) {
92
+ /**
93
+ * The ranges a phase owns, for display alongside `ownedSteps`.
94
+ *
95
+ * Class-owned phases report whole canonical parts. `post_pr_gap_close` owns single
96
+ * steps picked from the accepted ledger (BAPI-1426), so it reports one single-step
97
+ * range per owned step and never claims the rest of the part.
98
+ */
99
+ export function resolveOwnedRanges(metadata, phase, accepted) {
100
+ if (phase === "post_pr_gap_close") {
101
+ const ranges = [];
102
+ for (const step of resolveOwnedSteps(metadata, phase, accepted)) {
103
+ const part = metadata.parts.find((entry) => step >= entry.start_step && step <= entry.end_step);
104
+ if (!part)
105
+ continue;
106
+ ranges.push({
107
+ partId: part.part_id,
108
+ provenanceClass: part.provenance_class,
109
+ startStep: step,
110
+ endStep: step,
111
+ declaredAdvisory: part.declared_advisory,
112
+ });
113
+ }
114
+ return ranges;
115
+ }
116
+ return resolveOwnedParts(metadata, phase).map((part) => ({
117
+ partId: part.part_id,
118
+ provenanceClass: part.provenance_class,
119
+ startStep: part.start_step,
120
+ endStep: part.end_step,
121
+ declaredAdvisory: part.declared_advisory,
122
+ }));
123
+ }
124
+ export function buildPhaseRoutingContext(metadata, phase, ledger, checkpoint, accepted) {
93
125
  return {
94
126
  phase,
95
- ownedSteps: resolveOwnedSteps(metadata, phase),
96
- ownedRanges: resolveOwnedParts(metadata, phase).map((part) => ({
97
- partId: part.part_id,
98
- provenanceClass: part.provenance_class,
99
- startStep: part.start_step,
100
- endStep: part.end_step,
101
- declaredAdvisory: part.declared_advisory,
102
- })),
127
+ ownedSteps: resolveOwnedSteps(metadata, phase, accepted),
128
+ ownedRanges: resolveOwnedRanges(metadata, phase, accepted),
103
129
  ledger: sanitizePlanStepRecords(ledger),
104
130
  ...(checkpoint ? { checkpoint } : {}),
105
131
  };
@@ -120,7 +146,14 @@ export function injectPhaseRoutingContext(instruction, context) {
120
146
  `provenance routing and the durable ledger as it stands right now. Settle every ` +
121
147
  `step in \`ownedSteps\` — each must end as \`executed\`, \`adapted\`, \`escalated\`, ` +
122
148
  `or \`unrun-advisory\`. Steps outside \`ownedSteps\` belong to another phase.\n\n` +
123
- `Entries already present in \`ledger\` are done; do not redo them.\n\n` +
149
+ `In \`ledger\`, a step whose latest record is \`executed\` or \`adapted\` is ` +
150
+ `settled; do not redo it. A step whose latest record is \`escalated\` is an ` +
151
+ `unresolved obligation, not finished work. A record marked \`invalidatedBy\` is ` +
152
+ `history only.\n\n` +
153
+ `Only an \`escalated\` \`pre_pr_verification\` record for a check that genuinely ` +
154
+ `needs an open PR may add \`"prDependency"\` (one of \`remote_ci\`, \`pr_review\`, ` +
155
+ `\`pr_preview_environment\`), with an \`escalationReason\` naming that check. ` +
156
+ `Post-PR records that resolve those steps omit \`prDependency\`.\n\n` +
124
157
  "```json\n" +
125
158
  `${block}\n` +
126
159
  "```\n\n" +
@@ -216,10 +249,12 @@ export function parseCheckpointResult(agentResult) {
216
249
  * as `escalated` with the reason, which is what surfaces as unresolved. The failure
217
250
  * this ticket exists to fix was work vanishing with no trace, not work being flagged.
218
251
  */
219
- export function settlePhaseResult(agentResult, metadata, phase, existingLedger) {
252
+ export function settlePhaseResult(agentResult, metadata, phase, existingLedger, accepted) {
220
253
  const envelope = parsePhaseResultEnvelope(agentResult);
221
254
  if (!envelope) {
222
- const owned = resolveOwnedSteps(metadata, phase);
255
+ // Generated escalations cover only steps owned right now; for post-PR work that
256
+ // means only the server-accepted PR-dependent steps.
257
+ const owned = resolveOwnedSteps(metadata, phase, accepted);
223
258
  if (owned.length === 0) {
224
259
  return { ledger: [...existingLedger], contractViolation: false };
225
260
  }
@@ -239,7 +274,7 @@ export function settlePhaseResult(agentResult, metadata, phase, existingLedger)
239
274
  };
240
275
  }
241
276
  try {
242
- const validated = validatePhaseResult(envelope, metadata, phase);
277
+ const validated = validatePhaseResult(envelope, metadata, phase, accepted);
243
278
  return {
244
279
  ledger: mergePlanStepRecords(existingLedger, sanitizePlanStepRecords(validated)),
245
280
  contractViolation: false,