@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
@@ -3,7 +3,13 @@
3
3
  *
4
4
  * Ad-hoc, READ-ONLY attachment to a running (or recently-run) job's worker log:
5
5
  * it resolves the local job-log registry record, verifies the log file exists,
6
- * and `tail -f`s it with inherited stdio. It NEVER sends a signal to the worker
6
+ * and `tail -f`s it with inherited stdio.
7
+ *
8
+ * BAPI-1330: each attempt has its own stable log file. The newest attempt is
9
+ * watched by default and `--attempt <n>` selects an earlier one. A reference the
10
+ * registry cannot vouch for — a legacy fixed-alias record, an unknown attempt,
11
+ * a transcript that was never recorded — is reported by its named reason; the
12
+ * watch never follows a guessed or shared file. It NEVER sends a signal to the worker
7
13
  * process and never owns the execution lifecycle — closing the watch leaves the
8
14
  * worker untouched. Missing registry records or missing log files fail loud with
9
15
  * actionable stderr text and a non-zero exit code.
@@ -15,7 +21,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
15
21
  import { readExecutorJobLogRecord, } from "./job-log-registry.js";
16
22
  export function getExecutorWatchUsage() {
17
23
  return [
18
- "Usage: mcp-server executor watch <job-id>",
24
+ "Usage: mcp-server executor watch <job-id> [--attempt <n>]",
19
25
  "",
20
26
  "Attach read-only to a running (or recently-run) executor job's worker log",
21
27
  "via `tail -f`. Closing the watch never affects the worker process.",
@@ -24,15 +30,32 @@ export function getExecutorWatchUsage() {
24
30
  " <job-id> Numeric executor job id to watch.",
25
31
  "",
26
32
  "Options:",
33
+ " --attempt <n> Watch that attempt's log instead of the newest one.",
27
34
  " -h, --help Show this help.",
28
35
  ].join("\n");
29
36
  }
30
- /** Accept EXACTLY one numeric job id; reject missing/non-numeric/extra args. */
37
+ /**
38
+ * Accept EXACTLY one numeric job id plus an optional `--attempt <n>` (or
39
+ * `--attempt=<n>`); reject missing/non-numeric/extra args.
40
+ */
31
41
  export function parseExecutorWatchArgs(argv) {
32
42
  const positionals = [];
33
- for (const arg of argv) {
43
+ let attemptRaw;
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const arg = argv[i];
34
46
  if (arg === "-h" || arg === "--help")
35
47
  return { kind: "help" };
48
+ if (arg === "--attempt" || arg.startsWith("--attempt=")) {
49
+ if (attemptRaw !== undefined) {
50
+ return { kind: "error", message: "--attempt may be given only once" };
51
+ }
52
+ const value = arg === "--attempt" ? argv[++i] : arg.slice("--attempt=".length);
53
+ if (value === undefined || !/^[0-9]+$/.test(value)) {
54
+ return { kind: "error", message: "--attempt requires a non-negative attempt number" };
55
+ }
56
+ attemptRaw = value;
57
+ continue;
58
+ }
36
59
  if (arg.startsWith("-"))
37
60
  return { kind: "error", message: `unknown argument: ${arg}` };
38
61
  positionals.push(arg);
@@ -47,7 +70,9 @@ export function parseExecutorWatchArgs(argv) {
47
70
  if (!/^[0-9]+$/.test(raw)) {
48
71
  return { kind: "error", message: `job id must be numeric: '${raw}'` };
49
72
  }
50
- return { kind: "ok", jobId: Number(raw) };
73
+ return attemptRaw === undefined
74
+ ? { kind: "ok", jobId: Number(raw) }
75
+ : { kind: "ok", jobId: Number(raw), attempt: Number(attemptRaw) };
51
76
  }
52
77
  /**
53
78
  * Spawn `tail -f <log_path>` with inherited stdio for interactive CLI attachment.
@@ -98,14 +123,16 @@ export async function runExecutorWatchCli(argv, overrides = {}) {
98
123
  }
99
124
  const registryDeps = overrides.registryDeps ?? createDefaultWatchRegistryDeps();
100
125
  const readRecord = overrides.readRecord ?? readExecutorJobLogRecord;
101
- const record = await readRecord(parsed.jobId, registryDeps);
126
+ const selector = parsed.attempt === undefined ? {} : { attempt: parsed.attempt };
127
+ const record = await readRecord(parsed.jobId, registryDeps, selector);
102
128
  if (!record.ok) {
103
- errorLog(`Error: no worker log registered for job ${parsed.jobId}: ${record.reason}`);
129
+ errorLog(`Error: worker log for job ${parsed.jobId} is unavailable (${record.code}): ${record.reason}`);
104
130
  return 1;
105
131
  }
106
132
  const logExists = overrides.logExists ?? defaultLogExists;
107
133
  if (!(await logExists(record.record.log_path))) {
108
- errorLog(`Error: worker log for job ${parsed.jobId} is missing at ${record.record.log_path}`);
134
+ errorLog(`Error: worker log for job ${parsed.jobId} attempt ${record.record.attempt} is unavailable ` +
135
+ `(log_file_missing): expected at ${record.record.log_path}`);
109
136
  return 1;
110
137
  }
111
138
  const spawnTail = overrides.spawnTail ?? ((logPath) => spawnTailFollow(logPath));
@@ -17,13 +17,19 @@
17
17
  * distinct from the argv vocabulary is what stops a future flag rename in one
18
18
  * agent's CLI from becoming an operator-visible policy break across all of them.
19
19
  */
20
- import { ExecutorNamedError } from "./job-errors.js";
21
- import { isRecoveryJobType } from "./job-types.js";
20
+ import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
21
+ import { isRemainingWork, validateCorrectionIntent } from "./correction-dispatch.js";
22
+ /**
23
+ * BAPI-1296: `ci_fix` is the one recovery job with a FOCUSED prompt route. It
24
+ * renders its server-authored `payload.prompt_spec` (the dispatched failing and
25
+ * missing checks) in `job-runner.ts`, and is never given the full recipe.
26
+ */
27
+ export const CI_FIX_JOB_TYPE = "ci_fix";
22
28
  /**
23
29
  * Resolve the worker prompt for the explicit-prompt and legacy synthesis paths.
24
- * Uses an explicit non-empty `payload.prompt` verbatim; otherwise, for
25
- * `implement` and the recovery jobs, builds the conservative
26
- * `/implement-ticket <ticket_key> --auto` prompt.
30
+ * Remaining work first requires a validated bounded correction intent. Only an
31
+ * initial `implement` job may synthesize `/implement-ticket <ticket_key> --auto`.
32
+ * `ci_fix` uses the focused prompt-spec route after pre-worktree validation.
27
33
  *
28
34
  * Structured `payload.prompt_spec` rendering is NOT done here: it needs the
29
35
  * prepared worktree/git state, so `job-runner.ts` renders it in the prepared-spawn
@@ -38,46 +44,76 @@ import { isRecoveryJobType } from "./job-types.js";
38
44
  * explicit prompt. It no longer depends on the prompt-spec fall-through.
39
45
  */
40
46
  /**
41
- * True for the job types that synthesize `/implement-ticket <KEY> --auto` when no
42
- * explicit `payload.prompt` is present: the first-pass `implement` plus the
43
- * recovery jobs (`remediate`/`ci_fix`/`rebase`), which re-enter the SAME
44
- * correction loop on the ticket's existing branch/PR. Recovery classification is
45
- * shared with dispatch and worktree reuse via `isRecoveryJobType` (`job-types.ts`)
46
- * so eligibility cannot drift. Verdict jobs (`spec_review`/`smoke`) are NOT here —
47
- * they carry an explicit `payload.prompt` and write a fixed-path artifact (T6/T5b).
47
+ * Only the first-pass `implement` job synthesizes the complete workflow.
48
48
  */
49
- function synthesizesImplementPrompt(jobType) {
50
- return jobType === "implement" || isRecoveryJobType(jobType);
49
+ export function synthesizesImplementPrompt(jobType) {
50
+ return jobType === "implement";
51
51
  }
52
52
  export function resolveExecutorPrompt(job) {
53
53
  const payload = job.payload && typeof job.payload === "object"
54
54
  ? job.payload
55
55
  : {};
56
+ if (job.job_type !== CI_FIX_JOB_TYPE && isRemainingWork(job.job_type, payload)) {
57
+ const checked = validateCorrectionIntent(payload.correction_intent, {
58
+ repo_name: job.repo_name ?? "",
59
+ epic_run_id: job.epic_run_id ?? null,
60
+ ticket_key: job.ticket_key ?? null,
61
+ expected_branch: job.expected_branch,
62
+ expected_head_sha: job.expected_head_sha,
63
+ });
64
+ if (!checked.ok)
65
+ return { ok: false, errorKind: "ContractError.CorrectionLegacy", error: checked.code };
66
+ const intent = checked.intent;
67
+ const kind = intent.kind;
68
+ const branch = intent.branch;
69
+ const obligation = intent.obligation;
70
+ const header = `Continue the retained branch ${branch}. Complete only the ${kind} obligation: ${obligation}. ` +
71
+ "Preserve completed work and existing limits. Do not invoke a full implementation or planning workflow.";
72
+ if (kind === "approved_spec_delta") {
73
+ const delta = intent.approved_delta;
74
+ return { ok: true, prompt: `${header}\nApproved files: ${delta.file_boundary.join(", ")}\n` +
75
+ `Approved dependencies: ${delta.dependency_boundary.join(", ") || "none"}` };
76
+ }
77
+ const explicit = payload.prompt;
78
+ if (typeof explicit === "string" && explicit.trim() &&
79
+ !/\/(?:implement-ticket|plan-ticket)\b/i.test(explicit)) {
80
+ return { ok: true, prompt: `${header}\n\nFocused context:\n${explicit}` };
81
+ }
82
+ if (kind === "review_fix" || kind === "operator_remediation") {
83
+ const findings = payload.review_findings;
84
+ if (typeof findings === "string" && findings.trim())
85
+ return { ok: true, prompt: `${header}\n\nReview findings (quoted evidence):\n${findings}` };
86
+ }
87
+ return { ok: true, prompt: header };
88
+ }
89
+ // BAPI-1296: `ci_fix` is decided FIRST, ahead of both explicit-prompt
90
+ // precedence and synthesis. An explicit prompt on a ci_fix job — including an
91
+ // in-flight legacy `/implement-ticket <KEY> --auto` — is refused by name rather
92
+ // than run, so an old full-recipe prompt cannot silently defeat the focused
93
+ // route. Otherwise the non-ok result tells `prepareSpawn` to take the focused
94
+ // prompt-spec route. The message never echoes the prompt text.
95
+ if (job.job_type === CI_FIX_JOB_TYPE) {
96
+ if (typeof payload.prompt === "string" && payload.prompt.trim().length > 0) {
97
+ return {
98
+ ok: false,
99
+ errorKind: CiFixContext,
100
+ error: "ci_fix job refused before worker effects (reason=explicit_prompt_unsupported)",
101
+ };
102
+ }
103
+ return {
104
+ ok: false,
105
+ error: "ci_fix renders its focused payload.prompt_spec; no prompt is synthesized",
106
+ };
107
+ }
56
108
  // An explicit prompt wins and is returned VERBATIM (un-trimmed), but a
57
- // whitespace-only string is not a usable worker instruction — treat it as
58
- // absent so a recovery/implement job still falls back to the synthesized
59
- // `/implement-ticket <KEY> --auto` correction prompt below.
109
+ // whitespace-only string is not a usable worker instruction. The initial
110
+ // implement job may still use the workflow synthesis below.
60
111
  const explicit = payload.prompt;
61
112
  if (typeof explicit === "string" && explicit.trim().length > 0) {
62
113
  return { ok: true, prompt: explicit };
63
114
  }
64
- // `/implement-ticket <KEY> --auto` is the full correction engine, not just a
65
- // first-pass implementer: its post-PR loop owns CI-failure correction, review-
66
- // change addressing, and merge-conflict resolution (see .claude/commands/
67
- // implement-ticket.md "Clean session exit"). So the recovery jobs that operate
68
- // on the ticket's existing branch/PR — `remediate`/`ci_fix`/`rebase` — resume
69
- // by RE-ENTERING THE COMPLETE RECIPE from step 1, not a distinct resume path.
70
- // That re-entry is safe (BAPI-746), backed by re-entry handling already
71
- // present in three of the recipe's own steps, not a new assumption: plan
72
- // generation is idempotent because the server reuses a fresh existing plan
73
- // instead of regenerating it (BAPI-745, `reused: true`); commit-and-push.md
74
- // treats a clean tree with an already-pushed branch/PR as a legitimate
75
- // re-entry rather than an empty implementation; and create-pr.md treats an
76
- // already-open pull request on the head branch as satisfying its success
77
- // condition. Without synthesizing this command, a code_review
78
- // `changes_requested` (near-certain in real runs) enqueues a `remediate` job
79
- // that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap
80
- // #3).
115
+ // This final synthesis route is for initial feature work only. The remaining
116
+ // work branch returned a focused prompt or a named refusal above.
81
117
  const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
82
118
  ? job.ticket_key.trim()
83
119
  : null;
@@ -45,9 +45,20 @@
45
45
  * so an omitted body opens a PR with no description at all), and — when the
46
46
  * provider reports it — sitting at the same pushed head. Anything short of that
47
47
  * is `WorkerFinalizationPrNotAttached`, whose guidance names attachment and never
48
- * suggests removing or re-writing the branch: the commits ARE the completed
49
- * implementation, and both of the stale-worktree guard's historical remedies
50
- * would have destroyed them.
48
+ * suggests removing or re-writing the branch: the commits are durable pushed
49
+ * work, and both of the stale-worktree guard's historical remedies would have
50
+ * destroyed them.
51
+ *
52
+ * BAPI-1330: publication is not completion. A pushed head and an attached pull
53
+ * request prove the work was PUBLISHED; they say nothing about whether the
54
+ * required work is finished or whether any check passed, so no message here
55
+ * claims either. A clean exit that fails finalization keeps the `clean_exit`
56
+ * classification the process actually earned — the finalization failure rides
57
+ * separately as the error kind. And a COVERED attempt (one the server enrolled
58
+ * in the completion-evidence contract) may not use the existence-only fallback:
59
+ * without a full worker HEAD, a matching origin tip and a pull request that
60
+ * reports its head, publication is `unknown`
61
+ * (`WorkerFinalizationPublicationUnverified`), never `ok`.
51
62
  *
52
63
  * This check is deliberately fail-CLOSED, unlike the BAPI-586 base check it now
53
64
  * shares a lookup with. "The PR could not be verified" and "there is no PR" are
@@ -56,8 +67,20 @@
56
67
  * this ticket removes. The reconciler stays the decision-maker — the executor
57
68
  * only reports the observation (R14 rule 3).
58
69
  */
59
- import { ReconcileOverlapPushUnfinalized, secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, WorkerFinalizationPrNotAttached, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
70
+ import { ReconcileOverlapPushUnfinalized, secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, WorkerFinalizationPrNotAttached, WorkerFinalizationPublicationUnverified, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
60
71
  import { isImplementationStyleJobType } from "./job-types.js";
72
+ /** A full, lower-case 40-hex commit SHA. */
73
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/;
74
+ /** The `runCommand` options for one bounded read; `{ cwd }` alone when unbounded. */
75
+ function callOptions(worktreePath, bound) {
76
+ return bound && typeof bound.timeoutMs === "number"
77
+ ? { cwd: worktreePath, timeoutMs: Math.max(1, Math.floor(bound.timeoutMs)) }
78
+ : { cwd: worktreePath };
79
+ }
80
+ /** True when a bounded call has no time left and must not be issued at all. */
81
+ function callExpired(bound) {
82
+ return bound !== undefined && typeof bound.timeoutMs === "number" && bound.timeoutMs <= 0;
83
+ }
61
84
  /** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
62
85
  const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
63
86
  const DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS = 500;
@@ -113,9 +136,11 @@ function parseLsRemoteHeadSha(stdout, expectedRef) {
113
136
  * on a recovery worktree. A non-zero exit is treated as "branch not found"
114
137
  * without copying raw stderr into diagnostics.
115
138
  */
116
- export async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
139
+ export async function resolveOriginBranchSha(runCommand, worktreePath, branch, bound) {
117
140
  const normalizedRef = normalizeBranchRef(branch);
118
- const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], { cwd: worktreePath });
141
+ if (callExpired(bound))
142
+ return null;
143
+ const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], callOptions(worktreePath, bound));
119
144
  if (result.exitCode !== 0)
120
145
  return null;
121
146
  return parseLsRemoteHeadSha(result.stdout, normalizedRef);
@@ -129,11 +154,11 @@ export async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
129
154
  * yet match. Returns the LAST authoritative observation — the caller decides
130
155
  * loud-failure from that final value, so true negatives are preserved.
131
156
  */
132
- async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep) {
157
+ async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep, bound) {
133
158
  const total = Math.max(1, attempts);
134
159
  let remoteSha = null;
135
160
  for (let attempt = 0; attempt < total; attempt++) {
136
- remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch);
161
+ remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch, bound?.());
137
162
  if (remoteSha !== null) {
138
163
  // Existence-only success when HEAD is unknown; exact-match success otherwise.
139
164
  if (!trimmedHeadSha || remoteSha === trimmedHeadSha) {
@@ -170,14 +195,16 @@ const PR_LOOKUP_ABSENT = {
170
195
  * collapses to {@link PR_LOOKUP_ABSENT} without copying raw stderr, an
171
196
  * authenticated remote, or the command line into anything the caller can render.
172
197
  */
173
- async function resolvePrForFinalization(runCommand, worktreePath, branch) {
198
+ async function resolvePrForFinalization(runCommand, worktreePath, branch, bound) {
174
199
  const args = ["pr", "view"];
175
200
  if (branch)
176
201
  args.push(branch);
177
202
  args.push("--json", PR_VIEW_JSON_FIELDS);
203
+ if (callExpired(bound))
204
+ return PR_LOOKUP_ABSENT;
178
205
  let result;
179
206
  try {
180
- result = await runCommand("gh", args, { cwd: worktreePath });
207
+ result = await runCommand("gh", args, callOptions(worktreePath, bound));
181
208
  }
182
209
  catch {
183
210
  return PR_LOOKUP_ABSENT;
@@ -254,7 +281,7 @@ function evaluatePrBase(job, pr, expectedBase) {
254
281
  * means the provider has not yet reflected the push, not that the push is in
255
282
  * doubt.
256
283
  */
257
- function evaluatePrContract(pr, workerHeadSha) {
284
+ function evaluatePrContract(pr, workerHeadSha, coveredAttempt = false) {
258
285
  if (!pr.found) {
259
286
  return {
260
287
  ok: false,
@@ -282,6 +309,17 @@ function evaluatePrContract(pr, workerHeadSha) {
282
309
  retryable: false,
283
310
  };
284
311
  }
312
+ if (coveredAttempt && pr.headRefOid === null) {
313
+ // BAPI-1330: a legacy attempt accepts a PR whose head the provider did not
314
+ // report; a covered one cannot, because the PR head is part of what binds
315
+ // the published work to this attempt's final head.
316
+ return {
317
+ ok: false,
318
+ detail: "its pull request reports no head commit",
319
+ retryable: true,
320
+ unverified: true,
321
+ };
322
+ }
285
323
  if (workerHeadSha && pr.headRefOid !== null && pr.headRefOid !== workerHeadSha) {
286
324
  return {
287
325
  ok: false,
@@ -303,9 +341,16 @@ function evaluatePrContract(pr, workerHeadSha) {
303
341
  * so the executor failure and the reconciler's observation are searchable as
304
342
  * one state instead of two vocabularies for the same thing.
305
343
  * 2. It recommends attachment and says plainly that removing or re-writing the
306
- * branch destroys completed work. In the F4 incident the pushed branch WAS the
307
- * finished implementation, and both remedies the stale-worktree guard offered
308
- * would have thrown it away.
344
+ * branch destroys pushed work. In the F4 incident the pushed branch held the
345
+ * implementation, and both remedies the stale-worktree guard offered would
346
+ * have thrown it away.
347
+ *
348
+ * BAPI-1330: it claims ONLY what finalization observed — durable pushed commits
349
+ * and an unmet pull-request attachment. Whether the required work is finished,
350
+ * and whether any check passed, is stated as not established here; only the
351
+ * completion evidence (when the attempt was covered) can speak to either. The
352
+ * classification is `clean_exit`, because that is how the process ended; the
353
+ * finalization failure is carried by the error kind, not by rewriting the exit.
309
354
  */
310
355
  function prNotAttachedFailure(job, branch, workerHeadSha, expectedBase, detail) {
311
356
  const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
@@ -314,14 +359,31 @@ function prNotAttachedFailure(job, branch, workerHeadSha, expectedBase, detail)
314
359
  return {
315
360
  error_kind: WorkerFinalizationPrNotAttached,
316
361
  error_message: `${label} exited cleanly and its HEAD${headLabel} is durable on origin branch '${branch}', ` +
317
- `but ${detail}. This is the pr_not_attached state: the implementation is COMPLETE and ` +
318
- `published, only its pull request is missing. Recovery: open or attach a pull request for ` +
319
- `'${branch}'${baseLabel} with a non-empty description. Removing or re-writing that branch ` +
320
- `would destroy finished work — the commits on it are the implementation.`,
321
- classification: "crashed",
362
+ `but ${detail}. This is the pr_not_attached state: the commits are pushed and the pull ` +
363
+ `request attachment is unmet. Whether the required work is finished, and whether any ` +
364
+ `check passed, is not established by this observation. Recovery: open or attach a pull ` +
365
+ `request for '${branch}'${baseLabel} with a non-empty description. Removing or re-writing ` +
366
+ `that branch would destroy pushed work.`,
367
+ classification: "clean_exit",
322
368
  last_commit_sha: workerHeadSha || undefined,
323
369
  };
324
370
  }
371
+ /**
372
+ * BAPI-1330: a covered attempt whose publication could not be PROVEN. Built only
373
+ * from public identifiers and a fixed detail phrase. It never says a pull request
374
+ * is absent, because none was observed to be — the verdict is `unknown`.
375
+ */
376
+ function publicationUnverifiedFailure(job, branch, workerHeadSha, detail) {
377
+ const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
378
+ return {
379
+ error_kind: WorkerFinalizationPublicationUnverified,
380
+ error_message: `${label} exited cleanly, but publication of branch '${branch}' could not be verified: ` +
381
+ `${detail}. Publication, required-work completion and check status are unknown for this ` +
382
+ `attempt; nothing was observed to be missing and nothing on the branch was changed.`,
383
+ classification: "clean_exit",
384
+ last_commit_sha: FULL_SHA_RE.test(workerHeadSha) ? workerHeadSha : undefined,
385
+ };
386
+ }
325
387
  /**
326
388
  * `detail` describes a state with NO evidence any work reached origin (branch
327
389
  * unresolved, branch absent from origin, or the origin lookup itself failed).
@@ -398,6 +460,8 @@ export async function validateWorkerFinalization(input) {
398
460
  if (!isImplementationStyleJobType(job.job_type)) {
399
461
  return { ok: true };
400
462
  }
463
+ // BAPI-1311: resolved fresh for every call so each read gets only the time left.
464
+ const bound = () => input.callTimeoutMs ? { timeoutMs: input.callTimeoutMs() } : undefined;
401
465
  const hasPrUrl = Boolean(extractPrUrl(result));
402
466
  // BAPI-762: `branch`/`headSha` are normalized once here and reused through the
403
467
  // rest of the check — including by the diagnostics below — instead of being
@@ -418,7 +482,7 @@ export async function validateWorkerFinalization(input) {
418
482
  // unconditional. Their dispositions still differ on purpose (see `PrLookup`),
419
483
  // and the ORDER is unchanged: a wrong-base PR is still reported before any
420
484
  // origin lookup, exactly as BAPI-586 specified.
421
- let pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
485
+ let pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch, bound());
422
486
  // BAPI-586: a wrong-base PR (opened against a dependency's feature branch or the
423
487
  // repo default instead of the run base) never fires `claude-review.yml` and
424
488
  // strands the ticket at `code_review`. When the run base is known, fail loud on
@@ -443,12 +507,23 @@ export async function validateWorkerFinalization(input) {
443
507
  // runs unconditionally — including when `hasPrUrl` is true (BAPI-762, AC-5): a
444
508
  // PR being open is never accepted as proof this worker's own commit landed.
445
509
  const trimmedHeadSha = typeof headSha === "string" ? headSha.trim().toLowerCase() : "";
510
+ const covered = input.coveredAttempt === true;
511
+ // BAPI-1330: the existence-only fallback below is a LEGACY concession to
512
+ // degraded telemetry. A covered attempt cannot bind its final head to anything
513
+ // without the worker's full HEAD, so it stops here with an `unknown` verdict
514
+ // rather than accepting "the branch exists" as publication.
515
+ if (covered && !FULL_SHA_RE.test(trimmedHeadSha)) {
516
+ return {
517
+ ok: false,
518
+ failure: publicationUnverifiedFailure(job, trimmedBranch, trimmedHeadSha, "the worker's full HEAD commit could not be established"),
519
+ };
520
+ }
446
521
  const attempts = normalizeAttempts(input.originResolveAttempts);
447
522
  const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
448
523
  const sleep = input.sleep ?? sleepMs;
449
524
  let origin;
450
525
  try {
451
- origin = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep);
526
+ origin = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep, bound);
452
527
  }
453
528
  catch (err) {
454
529
  return {
@@ -497,16 +572,22 @@ export async function validateWorkerFinalization(input) {
497
572
  // base check fail-opened on it, and the PR that has since appeared must not
498
573
  // reach success without that check.
499
574
  if (origin.attemptsUsed > 1) {
500
- pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
575
+ pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch, bound());
501
576
  const refreshedBaseVerdict = evaluatePrBase(job, pr, expectedBase);
502
577
  if (refreshedBaseVerdict)
503
578
  return { ok: false, failure: refreshedBaseVerdict };
504
579
  }
505
- let contract = evaluatePrContract(pr, trimmedHeadSha);
580
+ let contract = evaluatePrContract(pr, trimmedHeadSha, covered);
506
581
  for (let attempt = 1; attempt < attempts && !contract.ok && contract.retryable; attempt++) {
507
582
  await sleep(retryDelayMs);
508
- pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
509
- contract = evaluatePrContract(pr, trimmedHeadSha);
583
+ pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch, bound());
584
+ contract = evaluatePrContract(pr, trimmedHeadSha, covered);
585
+ }
586
+ if (!contract.ok && contract.unverified === true) {
587
+ return {
588
+ ok: false,
589
+ failure: publicationUnverifiedFailure(job, trimmedBranch, trimmedHeadSha, contract.detail),
590
+ };
510
591
  }
511
592
  if (!contract.ok) {
512
593
  return {
@@ -514,5 +595,66 @@ export async function validateWorkerFinalization(input) {
514
595
  failure: prNotAttachedFailure(job, trimmedBranch, trimmedHeadSha, expectedBase, contract.detail),
515
596
  };
516
597
  }
517
- return { ok: true, prUrl: pr.url ?? undefined, prNumber: pr.prNumber };
598
+ return {
599
+ ok: true,
600
+ prUrl: pr.url ?? undefined,
601
+ prNumber: pr.prNumber,
602
+ // BAPI-1311: the verified origin tip — proven equal to the worker's HEAD above
603
+ // whenever that HEAD was known. Only for a caller that asked for it.
604
+ ...(input.exposePushedHead === true ? { pushedHeadSha: remoteSha } : {}),
605
+ };
606
+ }
607
+ // ---------------------------------------------------------------------------
608
+ // BAPI-1311 — the focused rebase wait's pull-request read
609
+ // ---------------------------------------------------------------------------
610
+ /**
611
+ * The bounded public projection the focused rebase wait asks for. Separate from
612
+ * {@link PR_VIEW_JSON_FIELDS} on purpose: legacy finalization keeps its exact
613
+ * field selection, and only the rebase wait pays for state and mergeability.
614
+ */
615
+ const FOCUSED_PR_VIEW_JSON_FIELDS = "number,state,headRefOid,headRefName,baseRefName,mergeable";
616
+ /**
617
+ * Read the bound pull request's current state with ONE bounded `gh pr view`.
618
+ *
619
+ * Addresses the pull request by NUMBER, never by branch, so a second pull request
620
+ * opened for the same branch can never be mistaken for the bound one. Parses only
621
+ * {@link FOCUSED_PR_VIEW_JSON_FIELDS}; raw stdout/stderr never leave this
622
+ * function. An already-expired bound issues no command at all.
623
+ */
624
+ export async function readFocusedPullRequest(runCommand, worktreePath, prNumber, bound) {
625
+ if (!Number.isInteger(prNumber) || prNumber <= 0 || callExpired(bound)) {
626
+ return { available: false };
627
+ }
628
+ let result;
629
+ try {
630
+ result = await runCommand("gh", ["pr", "view", String(prNumber), "--json", FOCUSED_PR_VIEW_JSON_FIELDS], callOptions(worktreePath, bound));
631
+ }
632
+ catch {
633
+ return { available: false };
634
+ }
635
+ if (result.exitCode !== 0)
636
+ return { available: false };
637
+ let parsed;
638
+ try {
639
+ parsed = JSON.parse(result.stdout);
640
+ }
641
+ catch {
642
+ return { available: false };
643
+ }
644
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
645
+ return { available: false };
646
+ const obj = parsed;
647
+ const rawState = nonBlankString(obj.state);
648
+ const rawHead = nonBlankString(obj.headRefOid);
649
+ const rawMergeable = nonBlankString(obj.mergeable);
650
+ const mergeable = rawMergeable === "MERGEABLE" ? "mergeable" : rawMergeable === "CONFLICTING" ? "conflicting" : "unknown";
651
+ return {
652
+ available: true,
653
+ number: typeof obj.number === "number" && Number.isInteger(obj.number) ? obj.number : null,
654
+ state: rawState === null ? null : rawState.toUpperCase(),
655
+ headRefOid: rawHead === null ? null : rawHead.toLowerCase(),
656
+ headRefName: nonBlankString(obj.headRefName),
657
+ baseRefName: nonBlankString(obj.baseRefName),
658
+ mergeable,
659
+ };
518
660
  }