@bridge_gpt/mcp-server 0.2.65 → 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 (71) hide show
  1. package/README.md +25 -13
  2. package/build/agent-launchers/claude-executor-adapter.js +12 -18
  3. package/build/agent-launchers/executor-adapter.js +27 -0
  4. package/build/bridge-config.js +104 -0
  5. package/build/codex-bridge-profile.js +10 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conductor/bridge-api-client.js +230 -10
  8. package/build/conductor/epic-child-continuation.js +61 -34
  9. package/build/conductor/epic-implementer-cli.js +12 -0
  10. package/build/conductor/guided-repair.js +323 -0
  11. package/build/conductor/readiness-cli.js +30 -9
  12. package/build/conductor/readiness.js +23 -1
  13. package/build/conductor/recovery-cli.js +507 -16
  14. package/build/conductor/recovery-operations.js +325 -2
  15. package/build/conductor/repair-contract.js +61 -3
  16. package/build/credential-store.js +5 -4
  17. package/build/diagnostic-text.js +36 -0
  18. package/build/docs.generated.js +1 -1
  19. package/build/doctor.js +70 -10
  20. package/build/drive-epic.js +395 -78
  21. package/build/epic-implementer-bin.js +24 -22
  22. package/build/epic-launch-diagnostic.js +31 -0
  23. package/build/epic-launch-profile.js +44 -14
  24. package/build/executor/completion-supervision.js +5 -0
  25. package/build/executor/correction-dispatch.js +60 -0
  26. package/build/executor/env.js +6 -0
  27. package/build/executor/http-client.js +15 -0
  28. package/build/executor/integration-job.js +6 -1
  29. package/build/executor/job-errors.js +13 -4
  30. package/build/executor/job-log-registry.js +340 -73
  31. package/build/executor/job-runner.js +260 -33
  32. package/build/executor/job-types.js +8 -13
  33. package/build/executor/preflight.js +91 -2
  34. package/build/executor/prompt-spec.js +113 -9
  35. package/build/executor/runner.js +23 -0
  36. package/build/executor/stale-artifacts.js +5 -4
  37. package/build/executor/types.js +1 -0
  38. package/build/executor/watch-cli.js +35 -8
  39. package/build/executor/worker-command.js +43 -43
  40. package/build/executor/worker-finalization.js +80 -15
  41. package/build/executor/worker-guard-hook.js +46 -10
  42. package/build/executor/worker-log.js +137 -70
  43. package/build/executor/worktree-gc.js +30 -6
  44. package/build/index.js +177 -329
  45. package/build/init.js +107 -4
  46. package/build/install-bridge.js +284 -20
  47. package/build/install-doctor.js +172 -30
  48. package/build/install-reexec.js +69 -7
  49. package/build/macos-terminal-permission-guidance.js +16 -0
  50. package/build/model-thresholds-tool.js +185 -0
  51. package/build/pipelines.generated.js +1 -1
  52. package/build/plane/status.js +4 -0
  53. package/build/plane/supervisor.js +4 -0
  54. package/build/plane/worker-exit-code.js +2 -0
  55. package/build/project-root.js +99 -0
  56. package/build/readme.generated.js +1 -1
  57. package/build/review-tickets.js +156 -13
  58. package/build/setup-epic.js +180 -108
  59. package/build/sfcc/setup-status.js +2 -0
  60. package/build/sfcc/tool-wrapper.js +2 -0
  61. package/build/sfcc/tools-configured.js +96 -0
  62. package/build/start-tickets.js +163 -58
  63. package/build/upgrade-cli.js +109 -24
  64. package/build/version.generated.js +3 -3
  65. package/build/windows-npm-entry.js +103 -0
  66. package/build/worker-guard-hook-bin.js +4 -3
  67. package/build/worktree-core.js +6 -5
  68. package/docs/install/mcp-tool-integrations.md +1 -0
  69. package/docs/install/sfcc-integration.md +12 -7
  70. package/package.json +1 -1
  71. package/pipelines/implement-ticket.json +1 -1
@@ -114,6 +114,93 @@ function hasControlCharacter(value) {
114
114
  }
115
115
  return false;
116
116
  }
117
+ function sanitizeCiText(value, maxBytes = 512) {
118
+ const redacted = value
119
+ .replace(/gh[pousr]_[A-Za-z0-9]{20,}/giu, "[REDACTED]")
120
+ .replace(/bearer\s+[^\s,;]+/giu, "Bearer [REDACTED]")
121
+ .replace(/authorization:\s*[^\s,;]+/giu, "Authorization: [REDACTED]")
122
+ .replace(/https?:\/\/[^\s/@<>]+:[^\s/@<>]+@[^\s<>]+/giu, "[REDACTED URL]")
123
+ .replace(/https?:\/\/[^\s<>]+\?[^\s<>]+/giu, "[REDACTED URL]");
124
+ return sliceUtf8(redacted, maxBytes).replace(/[\x00-\x08\x0B-\x1F\x7F]/gu, " ");
125
+ }
126
+ function safeCiSourceRef(raw, repository) {
127
+ if (typeof raw !== "string" || raw.length > 1024)
128
+ return null;
129
+ try {
130
+ const url = new URL(raw);
131
+ if (url.protocol !== "https:" || url.hostname !== "github.com" ||
132
+ url.username || url.password || url.search || url.hash ||
133
+ !url.pathname.toLowerCase().startsWith(`/${repository.toLowerCase()}/`))
134
+ return null;
135
+ return url.toString();
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ function readCiEvidence(raw, headSha, failing) {
142
+ if (raw === null || raw === undefined)
143
+ return { status: "unavailable", reason: "legacy_no_snapshot" };
144
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
145
+ return { status: "unavailable", reason: "malformed_snapshot" };
146
+ }
147
+ const item = raw;
148
+ let serializedLength;
149
+ try {
150
+ serializedLength = JSON.stringify(item).length;
151
+ }
152
+ catch {
153
+ return { status: "unavailable", reason: "malformed_snapshot" };
154
+ }
155
+ if (item.version !== 1 || item.head_sha !== headSha ||
156
+ typeof item.repository !== "string" || !item.repository ||
157
+ typeof item.target_branch !== "string" || !item.target_branch ||
158
+ typeof item.policy_source !== "string" ||
159
+ typeof item.policy_fingerprint !== "string" ||
160
+ !Array.isArray(item.required_failures) || item.required_failures.length === 0 ||
161
+ item.required_failures.length > 20 || serializedLength > 24_000) {
162
+ return { status: "unavailable", reason: "malformed_snapshot" };
163
+ }
164
+ const required = [];
165
+ for (const candidate of item.required_failures) {
166
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
167
+ return { status: "unavailable", reason: "malformed_snapshot" };
168
+ }
169
+ const record = candidate;
170
+ if (typeof record.name !== "string" || !failing.has(record.name)) {
171
+ return { status: "unavailable", reason: "malformed_snapshot" };
172
+ }
173
+ required.push({
174
+ name: record.name,
175
+ sourceKind: typeof record.source_kind === "string" ? sanitizeCiText(record.source_kind, 32) : "unknown",
176
+ conclusion: typeof record.conclusion === "string" ? sanitizeCiText(record.conclusion, 32) : "unknown",
177
+ runId: typeof record.run_id === "number" && Number.isSafeInteger(record.run_id) && record.run_id > 0 ? record.run_id : null,
178
+ summary: typeof record.summary === "string" ? sanitizeCiText(record.summary) : "",
179
+ sourceRef: safeCiSourceRef(record.source_ref, item.repository),
180
+ });
181
+ }
182
+ if (required.length !== failing.size ||
183
+ new Set(required.map(record => record.name)).size !== failing.size) {
184
+ return { status: "unavailable", reason: "malformed_snapshot" };
185
+ }
186
+ const related = Array.isArray(item.related_failures) ? item.related_failures.slice(0, 8)
187
+ .filter((record) => !!record && typeof record === "object" && !Array.isArray(record))
188
+ .map((record) => typeof record.name === "string" ? sanitizeCiText(record.name, 200) : "(unnamed)") : [];
189
+ return {
190
+ status: "available",
191
+ policySource: sanitizeCiText(item.policy_source, 32),
192
+ policyFingerprint: sanitizeCiText(item.policy_fingerprint, 64),
193
+ repository: sanitizeCiText(item.repository, 200),
194
+ targetBranch: sanitizeCiText(item.target_branch, 200),
195
+ headSha,
196
+ observedAt: typeof item.observed_at === "string" ? sanitizeCiText(item.observed_at, 64) : "unavailable",
197
+ freshness: typeof item.freshness === "string" ? sanitizeCiText(item.freshness, 64) : "unavailable",
198
+ coverage: typeof item.coverage === "string" ? sanitizeCiText(item.coverage, 64) : "unknown",
199
+ availability: typeof item.availability === "string" ? sanitizeCiText(item.availability, 64) : "unavailable",
200
+ required,
201
+ related,
202
+ };
203
+ }
117
204
  /** Validate + de-duplicate one check-name list; `null` when a name is unusable. */
118
205
  function readCheckNames(raw) {
119
206
  if (raw === undefined || raw === null)
@@ -286,21 +373,38 @@ export function readCiFixDispatchContext(job) {
286
373
  missingChecks: missing,
287
374
  requiredClaimChecks: required,
288
375
  logExcerpts,
376
+ evidence: readCiEvidence(raw.evidence_snapshot, headSha, new Set(failing)),
289
377
  };
290
378
  }
291
379
  function formatCheckList(names, none) {
292
- return names.length > 0 ? names.map((name) => `- ${name}`).join("\n") : none;
380
+ return names.length > 0 ? names.map((name) => `- ${sanitizeCiText(name, 200)}`).join("\n") : none;
293
381
  }
294
- function formatLogExcerpts(excerpts) {
382
+ function formatLogExcerpts(context) {
383
+ const evidence = context.evidence;
384
+ const header = evidence.status === "unavailable"
385
+ ? `CI snapshot UNAVAILABLE (${evidence.reason}).` : [
386
+ `Policy: ${evidence.policySource} (${evidence.policyFingerprint})`,
387
+ `Target: ${evidence.repository} ${evidence.targetBranch} @ ${evidence.headSha}`,
388
+ `Observed: ${evidence.observedAt}; freshness: ${evidence.freshness}`,
389
+ `Coverage: ${evidence.coverage}; detail: ${evidence.availability}`,
390
+ "Selected required failures:",
391
+ ...evidence.required.map((item) => `- ${sanitizeCiText(item.name, 200)} [${item.sourceKind}; run ${item.runId ?? "unavailable"}; ${item.conclusion}]${item.sourceRef ? ` ${item.sourceRef}` : ""}`),
392
+ "Related failures — contribution not established:",
393
+ ...evidence.related.map((name) => `- ${name}`),
394
+ ].join("\n");
395
+ const excerpts = context.logExcerpts;
295
396
  if (excerpts.status === "unavailable") {
296
- return ("UNAVAILABLE — no log excerpt was supplied with this job " +
397
+ return header + "\n## UNTRUSTED PROVIDER DIAGNOSTICS BEGIN ##\n" + ("UNAVAILABLE — no log excerpt was supplied with this job " +
297
398
  `(reason: ${excerpts.reason}). No log content is provided below or ` +
298
399
  "anywhere else in this prompt. Diagnose from the check names, the code, " +
299
- "and local reproduction; do not assume what any log said.");
300
- }
301
- return excerpts.excerpts
302
- .map((e) => `--- check: ${e.check} (source: ${e.source}) ---\n${e.text}`)
303
- .join("\n\n");
400
+ "and local reproduction; do not assume what any log said.") + "\n## UNTRUSTED PROVIDER DIAGNOSTICS END ##";
401
+ }
402
+ const summaries = evidence.status === "available"
403
+ ? evidence.required.filter((item) => item.summary).map((item) => `${sanitizeCiText(item.name, 200)} selected summary: ${item.summary}`)
404
+ : [];
405
+ return header + "\n## UNTRUSTED PROVIDER DIAGNOSTICS BEGIN ##\n" +
406
+ [...summaries, ...excerpts.excerpts.map((e) => `--- check: ${sanitizeCiText(e.check, 200)} (source: ${sanitizeCiText(e.source, 200)}) ---\n${sanitizeCiText(e.text)}`)].join("\n\n") +
407
+ "\n## UNTRUSTED PROVIDER DIAGNOSTICS END ##";
304
408
  }
305
409
  /**
306
410
  * Explicit "no stale-citation advisory" value (BAPI-722) — never an empty string.
@@ -487,7 +591,7 @@ export async function buildPromptSpecPlaceholderValues(job, spec, context) {
487
591
  const ci = readCiFixDispatchContext(job);
488
592
  values.FAILING_CHECKS = formatCheckList(ci.failingChecks, "(none — no check was observed failing on the dispatched head)");
489
593
  values.MISSING_CHECKS = formatCheckList(ci.missingChecks, "(none — every required check reported on the dispatched head)");
490
- values.LOG_EXCERPTS = formatLogExcerpts(ci.logExcerpts);
594
+ values.LOG_EXCERPTS = formatLogExcerpts(ci);
491
595
  break;
492
596
  }
493
597
  case "SPEC_CITATION_FRESHNESS_WARNING": {
@@ -151,6 +151,8 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
151
151
  // optional mtime boundary (keeps GC conservative → retains).
152
152
  statMtimeMs: deps.statMtimeMs ?? (async () => null),
153
153
  platform: deps.platform,
154
+ // BAPI-1330: finds the per-attempt worker logs for the activity probe.
155
+ ...(deps.readdir ? { listDir: deps.readdir } : {}),
154
156
  }));
155
157
  // BAPI-1301 — before the first claim, inspect ownership records left by a
156
158
  // previous executor process on this host. Each record holds the real per-job
@@ -557,6 +559,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
557
559
  * a failure is reported to stderr and the loop keeps going. It cannot fail a
558
560
  * claim, fail a job, or end the run.
559
561
  */
562
+ let latestCorrectionQualification = null;
560
563
  const runProcessHeartbeatLoop = async () => {
561
564
  const publish = httpClient.processHeartbeat;
562
565
  // A client without the capability (every object-literal test fake) simply
@@ -589,10 +592,20 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
589
592
  ? { claim_mode: "scoped", epic_run_id: observedEpicRunId }
590
593
  : { claim_mode: "scoped" },
591
594
  };
595
+ const baseMetadata = request.metadata;
592
596
  // Suppression state: one message per CHANGE of delivery state, so a long
593
597
  // outage produces one line rather than one per minute.
594
598
  let lastFailed = false;
595
599
  for (;;) {
600
+ request.metadata = {
601
+ ...baseMetadata,
602
+ ...(latestCorrectionQualification
603
+ ? { correction_intent: latestCorrectionQualification }
604
+ : {}),
605
+ ...(latestCorrectionQualification?.qualified === true
606
+ ? { ci_evidence: { version: 1, qualified: true } }
607
+ : {}),
608
+ };
596
609
  let outcome;
597
610
  try {
598
611
  outcome = await publish.call(httpClient, request);
@@ -781,6 +794,16 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
781
794
  try {
782
795
  for (;;) {
783
796
  const report = await collectPreflight(options, deps, preflightSeams);
797
+ latestCorrectionQualification = report.correctionIntentQualified === true &&
798
+ report.correctionQualificationEvidence?.hook_installed === true &&
799
+ report.correctionQualificationEvidence.nested_denial === true
800
+ ? {
801
+ version: 1, qualified: true, hook_installed: true,
802
+ nested_denial: true,
803
+ fixture_id: report.correctionQualificationEvidence.fixture_id,
804
+ runtime: report.correctionQualificationEvidence.runtime,
805
+ }
806
+ : null;
784
807
  // First heartbeat goes out immediately once preflight passes, so a freshly
785
808
  // started executor is visible to the server without waiting one interval.
786
809
  // Gated on `report.ok` because an executor that preflight refuses is not
@@ -25,10 +25,11 @@ export const CONDUCTOR_CRITIQUE_FILE = "critique.md";
25
25
  /**
26
26
  * The files a prior attempt may leave behind, and the ONLY ones removed here.
27
27
  *
28
- * `worker.lock` and `worker.log` are deliberately absent: the lock is live
29
- * coordination state, and the log is forensic evidence truncated by its own
30
- * owner (`worker-log.ts`). The `.conductor/` directory itself and any unrelated
31
- * file inside it are left untouched.
28
+ * `worker.lock` and the worker logs are deliberately absent: the lock is live
29
+ * coordination state, and each attempt's log is forensic evidence that its owner
30
+ * (`worker-log.ts`) creates exclusively and never truncates (BAPI-1330). The
31
+ * `.conductor/` directory itself and any unrelated file inside it are left
32
+ * untouched.
32
33
  */
33
34
  export const STALE_ATTEMPT_ARTIFACT_FILES = [
34
35
  CONDUCTOR_RESULT_FILE,
@@ -52,6 +52,7 @@ export const EXECUTOR_CLASSIFICATION_VALUES = [
52
52
  * evidence: `rebase` is not a covered job type.
53
53
  */
54
54
  export const FOCUSED_REBASE_CAPABILITY = "focused_rebase.v1";
55
+ export const SCOPE_WAIT_CAPABILITY = "scope_wait.v1";
55
56
  /** The focused context version; the same string as the capability on purpose. */
56
57
  export const FOCUSED_REBASE_CONTEXT_VERSION = FOCUSED_REBASE_CAPABILITY;
57
58
  /**
@@ -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));
@@ -18,7 +18,7 @@
18
18
  * agent's CLI from becoming an operator-visible policy break across all of them.
19
19
  */
20
20
  import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
21
- import { isRecoveryJobType } from "./job-types.js";
21
+ import { isRemainingWork, validateCorrectionIntent } from "./correction-dispatch.js";
22
22
  /**
23
23
  * BAPI-1296: `ci_fix` is the one recovery job with a FOCUSED prompt route. It
24
24
  * renders its server-authored `payload.prompt_spec` (the dispatched failing and
@@ -27,11 +27,9 @@ import { isRecoveryJobType } from "./job-types.js";
27
27
  export const CI_FIX_JOB_TYPE = "ci_fix";
28
28
  /**
29
29
  * Resolve the worker prompt for the explicit-prompt and legacy synthesis paths.
30
- * Uses an explicit non-empty `payload.prompt` verbatim; otherwise, for
31
- * `implement` and the `remediate`/`rebase` recovery jobs, builds the
32
- * conservative `/implement-ticket <ticket_key> --auto` prompt. `ci_fix` never
33
- * resolves here (BAPI-1296): it is refused if it carries an explicit prompt and
34
- * otherwise falls through to its focused prompt-spec route.
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.
35
33
  *
36
34
  * Structured `payload.prompt_spec` rendering is NOT done here: it needs the
37
35
  * prepared worktree/git state, so `job-runner.ts` renders it in the prepared-spawn
@@ -46,29 +44,48 @@ export const CI_FIX_JOB_TYPE = "ci_fix";
46
44
  * explicit prompt. It no longer depends on the prompt-spec fall-through.
47
45
  */
48
46
  /**
49
- * True for the job types that synthesize `/implement-ticket <KEY> --auto` when no
50
- * explicit `payload.prompt` is present: the first-pass `implement` plus the
51
- * `remediate`/`rebase` recovery jobs, which re-enter the SAME correction loop on
52
- * the ticket's existing branch/PR. Recovery classification is shared with
53
- * dispatch and worktree reuse via `isRecoveryJobType` (`job-types.ts`) so
54
- * eligibility cannot drift. Verdict jobs (`spec_review`/`smoke`) are NOT here —
55
- * they carry an explicit `payload.prompt` and write a fixed-path artifact (T6/T5b).
56
- *
57
- * BAPI-1296: `ci_fix` is EXCLUDED even though it stays a recovery job (branch
58
- * reuse, worktree lock, and finalization policy are unchanged). Re-entering the
59
- * whole recipe to repair a dispatched check repeated planning and implementation
60
- * the server had already finished; `ci_fix` now renders its focused prompt spec
61
- * or is refused by name, and never falls back to this synthesis.
47
+ * Only the first-pass `implement` job synthesizes the complete workflow.
62
48
  */
63
49
  export function synthesizesImplementPrompt(jobType) {
64
- if (jobType === CI_FIX_JOB_TYPE)
65
- return false;
66
- return jobType === "implement" || isRecoveryJobType(jobType);
50
+ return jobType === "implement";
67
51
  }
68
52
  export function resolveExecutorPrompt(job) {
69
53
  const payload = job.payload && typeof job.payload === "object"
70
54
  ? job.payload
71
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
+ }
72
89
  // BAPI-1296: `ci_fix` is decided FIRST, ahead of both explicit-prompt
73
90
  // precedence and synthesis. An explicit prompt on a ci_fix job — including an
74
91
  // in-flight legacy `/implement-ticket <KEY> --auto` — is refused by name rather
@@ -89,31 +106,14 @@ export function resolveExecutorPrompt(job) {
89
106
  };
90
107
  }
91
108
  // An explicit prompt wins and is returned VERBATIM (un-trimmed), but a
92
- // whitespace-only string is not a usable worker instruction — treat it as
93
- // absent so a recovery/implement job still falls back to the synthesized
94
- // `/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.
95
111
  const explicit = payload.prompt;
96
112
  if (typeof explicit === "string" && explicit.trim().length > 0) {
97
113
  return { ok: true, prompt: explicit };
98
114
  }
99
- // `/implement-ticket <KEY> --auto` is the full correction engine, not just a
100
- // first-pass implementer: its post-PR loop owns review-change addressing and
101
- // merge-conflict resolution (see .claude/commands/implement-ticket.md "Clean
102
- // session exit"). So the recovery jobs that operate on the ticket's existing
103
- // branch/PR — `remediate`/`rebase` — resume by RE-ENTERING THE COMPLETE RECIPE
104
- // from step 1, not a distinct resume path. (`ci_fix` left this path in
105
- // BAPI-1296 and returned above: CI failures are repaired by a focused worker.)
106
- // That re-entry is safe (BAPI-746), backed by re-entry handling already
107
- // present in three of the recipe's own steps, not a new assumption: plan
108
- // generation is idempotent because the server reuses a fresh existing plan
109
- // instead of regenerating it (BAPI-745, `reused: true`); commit-and-push.md
110
- // treats a clean tree with an already-pushed branch/PR as a legitimate
111
- // re-entry rather than an empty implementation; and create-pr.md treats an
112
- // already-open pull request on the head branch as satisfying its success
113
- // condition. Without synthesizing this command, a code_review
114
- // `changes_requested` (near-certain in real runs) enqueues a `remediate` job
115
- // that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap
116
- // #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.
117
117
  const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
118
118
  ? job.ticket_key.trim()
119
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,10 @@
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}$/;
61
74
  /** The `runCommand` options for one bounded read; `{ cwd }` alone when unbounded. */
62
75
  function callOptions(worktreePath, bound) {
63
76
  return bound && typeof bound.timeoutMs === "number"
@@ -268,7 +281,7 @@ function evaluatePrBase(job, pr, expectedBase) {
268
281
  * means the provider has not yet reflected the push, not that the push is in
269
282
  * doubt.
270
283
  */
271
- function evaluatePrContract(pr, workerHeadSha) {
284
+ function evaluatePrContract(pr, workerHeadSha, coveredAttempt = false) {
272
285
  if (!pr.found) {
273
286
  return {
274
287
  ok: false,
@@ -296,6 +309,17 @@ function evaluatePrContract(pr, workerHeadSha) {
296
309
  retryable: false,
297
310
  };
298
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
+ }
299
323
  if (workerHeadSha && pr.headRefOid !== null && pr.headRefOid !== workerHeadSha) {
300
324
  return {
301
325
  ok: false,
@@ -317,9 +341,16 @@ function evaluatePrContract(pr, workerHeadSha) {
317
341
  * so the executor failure and the reconciler's observation are searchable as
318
342
  * one state instead of two vocabularies for the same thing.
319
343
  * 2. It recommends attachment and says plainly that removing or re-writing the
320
- * branch destroys completed work. In the F4 incident the pushed branch WAS the
321
- * finished implementation, and both remedies the stale-worktree guard offered
322
- * 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.
323
354
  */
324
355
  function prNotAttachedFailure(job, branch, workerHeadSha, expectedBase, detail) {
325
356
  const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
@@ -328,14 +359,31 @@ function prNotAttachedFailure(job, branch, workerHeadSha, expectedBase, detail)
328
359
  return {
329
360
  error_kind: WorkerFinalizationPrNotAttached,
330
361
  error_message: `${label} exited cleanly and its HEAD${headLabel} is durable on origin branch '${branch}', ` +
331
- `but ${detail}. This is the pr_not_attached state: the implementation is COMPLETE and ` +
332
- `published, only its pull request is missing. Recovery: open or attach a pull request for ` +
333
- `'${branch}'${baseLabel} with a non-empty description. Removing or re-writing that branch ` +
334
- `would destroy finished work — the commits on it are the implementation.`,
335
- 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",
336
368
  last_commit_sha: workerHeadSha || undefined,
337
369
  };
338
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
+ }
339
387
  /**
340
388
  * `detail` describes a state with NO evidence any work reached origin (branch
341
389
  * unresolved, branch absent from origin, or the origin lookup itself failed).
@@ -459,6 +507,17 @@ export async function validateWorkerFinalization(input) {
459
507
  // runs unconditionally — including when `hasPrUrl` is true (BAPI-762, AC-5): a
460
508
  // PR being open is never accepted as proof this worker's own commit landed.
461
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
+ }
462
521
  const attempts = normalizeAttempts(input.originResolveAttempts);
463
522
  const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
464
523
  const sleep = input.sleep ?? sleepMs;
@@ -518,11 +577,17 @@ export async function validateWorkerFinalization(input) {
518
577
  if (refreshedBaseVerdict)
519
578
  return { ok: false, failure: refreshedBaseVerdict };
520
579
  }
521
- let contract = evaluatePrContract(pr, trimmedHeadSha);
580
+ let contract = evaluatePrContract(pr, trimmedHeadSha, covered);
522
581
  for (let attempt = 1; attempt < attempts && !contract.ok && contract.retryable; attempt++) {
523
582
  await sleep(retryDelayMs);
524
583
  pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch, bound());
525
- contract = evaluatePrContract(pr, trimmedHeadSha);
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
+ };
526
591
  }
527
592
  if (!contract.ok) {
528
593
  return {