@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
@@ -42,7 +42,7 @@ import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
42
42
  import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
43
43
  import { stripWorkerEnvFiles } from "./env-file-guard.js";
44
44
  import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
45
- import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
45
+ import { createWorkerLogClaimId, createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
46
46
  import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
47
47
  import { resolveExecutorStateDir, activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
48
48
  import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
@@ -56,8 +56,10 @@ import { FOCUSED_REBASE_JOB_TYPE, focusedRebaseContextError, hasFocusedRebaseCon
56
56
  import { ensureExecutorWorktree, finalizeRetainedExecutorWorktree, } from "./worktree.js";
57
57
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
58
58
  import { CI_FIX_JOB_TYPE, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
59
+ import { CORRECTION_INTENT_CAPABILITY, isRemainingWork, validateCorrectionIntent, } from "./correction-dispatch.js";
59
60
  import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
60
61
  import { TERMINAL_CAPACITY_PROTOCOL_VERSIONS, TERMINAL_CAPACITY_RUNTIME_CONTRACTS, TERMINAL_CAPACITY_RUNTIMES, } from "./types.js";
62
+ import { probeInstalledCorrectionHook } from "./preflight.js";
61
63
  import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
62
64
  /**
63
65
  * Bound on the pre-spawn git tracked-file lookup (BAPI-1104, R81).
@@ -174,6 +176,9 @@ function buildJobLogRegistryDeps(deps) {
174
176
  env: deps.env,
175
177
  tmpdir: () => os.tmpdir(),
176
178
  platform: deps.platform,
179
+ // BAPI-1330: atomic temp-file + rename replacement when the host has it.
180
+ ...(deps.rename ? { rename: deps.rename } : {}),
181
+ errorLog: deps.errorLog,
177
182
  };
178
183
  }
179
184
  /**
@@ -651,6 +656,23 @@ async function sendTerminalMutationWithAuthReport(params, job, control) {
651
656
  * `/executor/jobs`. Integration actions are NOT ordinary executor jobs.
652
657
  */
653
658
  export async function runIntegrationCorrectionSpawn(job, httpClient, options, deps, driver, seams = {}, control) {
659
+ const validated = validateCorrectionIntent(job.payload?.correction_intent, {
660
+ repo_name: job.repo_name,
661
+ epic_run_id: job.epic_run_id ?? null,
662
+ ticket_key: job.ticket_key ?? null,
663
+ expected_branch: job.expected_branch,
664
+ expected_head_sha: job.expected_head_sha,
665
+ });
666
+ if (!validated.ok || validated.intent.kind !== "integration_correction" ||
667
+ (validated.ok && validated.intent.context_ref.action_id !==
668
+ job.payload?.integration_action_id)) {
669
+ const reason = validated.ok ? "correction_contract_invalid" : validated.code;
670
+ await httpClient.fail(job, {
671
+ error_kind: "ContractError.CorrectionLegacy",
672
+ error_message: reason, classification: "crashed",
673
+ });
674
+ return { status: "failed", reason };
675
+ }
654
676
  const { ownership, observation } = createJobRunState(options, deps);
655
677
  return runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver);
656
678
  }
@@ -1175,7 +1197,20 @@ async function superviseDriverPhase(params) {
1175
1197
  collectTelemetry: params.collectTelemetry,
1176
1198
  control: params.control,
1177
1199
  });
1178
- await Promise.race([flow.settled, params.deps.sleep(DRIVER_PHASE_SETTLE_MAX_MS)]);
1200
+ let settleTimer;
1201
+ try {
1202
+ await Promise.race([
1203
+ flow.settled,
1204
+ new Promise((resolve) => {
1205
+ settleTimer = params.deps.setTimer(resolve, DRIVER_PHASE_SETTLE_MAX_MS);
1206
+ }),
1207
+ ]);
1208
+ }
1209
+ finally {
1210
+ // A normal phase must not leave its four-minute bound alive after it settles.
1211
+ if (settleTimer !== undefined)
1212
+ params.deps.clearTimer(settleTimer);
1213
+ }
1179
1214
  if (procResult.serverStopRequested)
1180
1215
  return { kind: "server_stop" };
1181
1216
  if (params.ownership.abandoned)
@@ -1323,10 +1358,56 @@ async function ensureJobWorktree(job, options, deps, seams, policy) {
1323
1358
  }
1324
1359
  }
1325
1360
  async function prepareSpawn(job, httpClient, options, deps, seams) {
1361
+ // A continuation passes no local head here (`head === undefined`): its retained
1362
+ // worktree was already proven by `prepareRetainedContinuationWorktree` →
1363
+ // `verifyContinuationHeadLineage`, which refuses before spawn unless HEAD
1364
+ // descends from `continuation.bound_head_sha` — the same head as
1365
+ // `correction_intent.head_sha`, including one the server derived for a first
1366
+ // attempt with no bound ticket head (BAPI-1344). The spawn source is always
1367
+ // that retained worktree, never an origin checkout.
1368
+ const refuseCorrectionDrift = async (branch, head) => {
1369
+ if (!isRemainingWork(job.job_type, job.payload))
1370
+ return null;
1371
+ const intent = job.payload?.correction_intent;
1372
+ if (branch === intent.branch && (head === undefined || head === intent.head_sha))
1373
+ return null;
1374
+ const reason = "correction_context_missing";
1375
+ await httpClient.fail(job, {
1376
+ error_kind: "ContractError.CorrectionLegacy",
1377
+ error_message: reason,
1378
+ classification: "crashed",
1379
+ });
1380
+ return { ok: false, result: { status: "failed", reason } };
1381
+ };
1382
+ if (isRemainingWork(job.job_type, job.payload)) {
1383
+ const intent = validateCorrectionIntent(job.payload?.correction_intent, {
1384
+ repo_name: job.repo_name,
1385
+ epic_run_id: job.epic_run_id ?? null,
1386
+ ticket_key: job.ticket_key ?? null,
1387
+ expected_branch: job.expected_branch,
1388
+ expected_head_sha: job.expected_head_sha,
1389
+ });
1390
+ const reason = !intent.ok
1391
+ ? intent.code
1392
+ : !job.required_capabilities?.includes(CORRECTION_INTENT_CAPABILITY)
1393
+ ? "correction_contract_invalid"
1394
+ : null;
1395
+ if (reason !== null) {
1396
+ await httpClient.fail(job, {
1397
+ error_kind: "ContractError.CorrectionLegacy",
1398
+ error_message: reason,
1399
+ classification: "crashed",
1400
+ });
1401
+ return { ok: false, result: { status: "failed", reason } };
1402
+ }
1403
+ }
1326
1404
  if (job.job_type === "resume") {
1327
1405
  const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
1328
1406
  try {
1329
1407
  const prepared = await prepareResume(job, options, buildResumeSeams(deps, options));
1408
+ const drift = await refuseCorrectionDrift(prepared.branch);
1409
+ if (drift)
1410
+ return drift;
1330
1411
  return {
1331
1412
  ok: true,
1332
1413
  worktreePath: prepared.worktreePath,
@@ -1386,6 +1467,9 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1386
1467
  await httpClient.fail(job, jobWorktreeFailure(wt));
1387
1468
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1388
1469
  }
1470
+ const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
1471
+ if (drift)
1472
+ return drift;
1389
1473
  return {
1390
1474
  ok: true,
1391
1475
  worktreePath: wt.worktreePath,
@@ -1466,6 +1550,9 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1466
1550
  await httpClient.fail(job, jobWorktreeFailure(wt));
1467
1551
  return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
1468
1552
  }
1553
+ const drift = await refuseCorrectionDrift(wt.branch, readContinuationBinding(job) ? undefined : wt.expectedHeadSha);
1554
+ if (drift)
1555
+ return drift;
1469
1556
  // Explicit/synthesized prompt wins verbatim, preserving existing behavior.
1470
1557
  // BAPI-1311: for a focused rebase that explicit prompt IS the validated
1471
1558
  // server-rendered prompt (`validateFocusedRebaseContext` required it).
@@ -1724,6 +1811,34 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1724
1811
  * adds no second worktree entry and no second worker launcher.
1725
1812
  */
1726
1813
  driver) {
1814
+ // A server-owned read precedes every worktree reset/cut and worker launch.
1815
+ // Only this boundary can authorize a no-charge pre-work wait; a later plan
1816
+ // retrieval failure is ordinary bounded worker failure.
1817
+ if (job.epic_run_id && (httpClient.scopeReadiness ||
1818
+ job.required_capabilities?.includes("scope_wait.v1"))) {
1819
+ if (!httpClient.scopeReadiness) {
1820
+ await httpClient.fail(job, {
1821
+ error_kind: "ContractError.ScopeReadiness",
1822
+ error_message: "Required pre-work scope protocol is unavailable.",
1823
+ classification: "crashed",
1824
+ });
1825
+ return { status: "failed", reason: "scope_readiness_unsupported" };
1826
+ }
1827
+ try {
1828
+ const readiness = await httpClient.scopeReadiness(job);
1829
+ if (readiness.decision !== "ready") {
1830
+ return { status: "failed", reason: `scope_${readiness.decision}` };
1831
+ }
1832
+ }
1833
+ catch {
1834
+ await httpClient.fail(job, {
1835
+ error_kind: "ScopeReadinessUnavailable",
1836
+ error_message: "Server scope readiness could not be confirmed.",
1837
+ classification: "crashed",
1838
+ });
1839
+ return { status: "failed", reason: "scope_readiness_unavailable" };
1840
+ }
1841
+ }
1727
1842
  // --- Per-job base branch (BAPI-586) ----------------------------------
1728
1843
  // Resolve the effective logical base BEFORE any side effect: the persisted
1729
1844
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -1824,11 +1939,12 @@ driver) {
1824
1939
  // --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
1825
1940
  // The FIRST thing that happens once a canonical worktree path exists, and
1826
1941
  // ahead of every executor-owned write to it: the deny layer, MCP
1827
- // provisioning, the `worker.log` truncation, the viewer tab, and the spawn
1828
- // itself all live inside the lock. `worker.log` in particular is created with
1829
- // an empty write, so a refused contender that got as far as the tee would
1830
- // erase the running worker's forensic log without ever spawning — which is
1831
- // why refusal returns from HERE rather than anywhere downstream.
1942
+ // provisioning, the attempt log allocation, the viewer tab, and the spawn
1943
+ // itself all live inside the lock. Before BAPI-1330 the shared `worker.log`
1944
+ // was created with a truncating write, so a refused contender that got as far
1945
+ // as the tee could erase the running worker's forensic log; per-attempt logs
1946
+ // are now created exclusively, and refusal still returns from HERE rather
1947
+ // than anywhere downstream.
1832
1948
  //
1833
1949
  // Known residual window, stated rather than papered over: `prepareSpawn`
1834
1950
  // above already ran `ensureWorktree`, which hard-resets and cleans the tree.
@@ -2114,6 +2230,12 @@ async function runPreparedSpawn(params) {
2114
2230
  // safety margin but must not kill a job. Refusing to CLAIM on unenforced deny
2115
2231
  // remains a separate, fatal preflight concern.
2116
2232
  const denyCapability = adapter.denyEnforcement;
2233
+ const correctionWork = isRemainingWork(job.job_type, job.payload);
2234
+ let correctionDenyInstalled = false;
2235
+ const correctionHookBinPath = denyCapability?.supported === true
2236
+ ? (seams.resolveWorkerGuardBinPath ??
2237
+ (() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))()
2238
+ : "";
2117
2239
  if (denyCapability?.supported === true) {
2118
2240
  const deny = await denyCapability.value.provisionWorktreeDenyLayer({
2119
2241
  worktreePath,
@@ -2125,8 +2247,8 @@ async function runPreparedSpawn(params) {
2125
2247
  // "is this push destination mine?" by comparing against it, so a value the
2126
2248
  // payload could influence would let a job nominate its own safe branch.
2127
2249
  workerBranch: branch,
2128
- workerGuardHookBinPath: (seams.resolveWorkerGuardBinPath ??
2129
- (() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))(),
2250
+ ...(correctionWork ? { correctionIntentVersion: 1 } : {}),
2251
+ workerGuardHookBinPath: correctionHookBinPath,
2130
2252
  });
2131
2253
  // Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
2132
2254
  // hygiene step can degrade without failing the deny layer — present-but-
@@ -2138,6 +2260,40 @@ async function runPreparedSpawn(params) {
2138
2260
  // fail-OPEN and the job continues to the spawn boundary.
2139
2261
  if (deny.warning)
2140
2262
  deps.errorLog(deny.warning);
2263
+ correctionDenyInstalled = deny.ok;
2264
+ }
2265
+ if (correctionWork && !correctionDenyInstalled) {
2266
+ await httpClient.fail(job, {
2267
+ error_kind: "ContractError.CorrectionLegacy",
2268
+ error_message: "correction_contract_invalid",
2269
+ classification: "crashed",
2270
+ });
2271
+ return { status: "failed", reason: "correction_hook_unavailable" };
2272
+ }
2273
+ if (correctionWork) {
2274
+ let hookQualified = false;
2275
+ try {
2276
+ if (seams.verifyInstalledCorrectionHook) {
2277
+ hookQualified = await seams.verifyInstalledCorrectionHook(worktreePath, correctionHookBinPath);
2278
+ }
2279
+ else {
2280
+ const settings = await deps.readFile(`${worktreePath}/.claude/settings.local.json`);
2281
+ hookQualified = settings.includes(correctionHookBinPath) &&
2282
+ settings.includes("--correction-intent-version 1") &&
2283
+ probeInstalledCorrectionHook(worktreePath, correctionHookBinPath);
2284
+ }
2285
+ }
2286
+ catch {
2287
+ hookQualified = false;
2288
+ }
2289
+ if (!hookQualified) {
2290
+ await httpClient.fail(job, {
2291
+ error_kind: "ContractError.CorrectionLegacy",
2292
+ error_message: "correction_contract_invalid",
2293
+ classification: "crashed",
2294
+ });
2295
+ return { status: "failed", reason: "correction_hook_unverified" };
2296
+ }
2141
2297
  }
2142
2298
  // --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
2143
2299
  // Unconditional for every job type that reaches this shared spawn path
@@ -2187,28 +2343,45 @@ async function runPreparedSpawn(params) {
2187
2343
  }
2188
2344
  const mcpContract = registration.contract;
2189
2345
  // --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
2346
+ // BAPI-1330: ONE attempt identity — the server's job id and attempt number
2347
+ // plus a claim id minted here — names this attempt's log file, its registry
2348
+ // entry, and its active-log key. It is allocated BEFORE the tee exists, the
2349
+ // file is created exclusively, and every terminal branch below finishes and
2350
+ // deactivates exactly this identity, so no later claim of the same job (a
2351
+ // retry, a reclaim, a reused worktree) can overwrite or be confused with it.
2190
2352
  const registryDeps = buildJobLogRegistryDeps(deps);
2191
2353
  const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
2192
2354
  const createTee = seams.createWorkerLogTee ?? createWorkerLogTee;
2193
- let tee = null;
2355
+ let logClaimId = null;
2194
2356
  try {
2195
- tee = await createTee(worktreePath, {
2196
- mkdir: deps.mkdir,
2197
- writeFile: deps.writeFile,
2198
- appendFile: deps.appendFile,
2199
- platform: deps.platform,
2200
- // BAPI-1021 (AC-6): the archive seams. All optional on `ExecutorDeps`
2201
- // already, so this is source-compatible with every existing deps
2202
- // builder — archival simply does not run when either is absent.
2203
- rename: deps.rename,
2204
- stat: deps.stat,
2205
- now: deps.now,
2206
- errorLog: deps.errorLog,
2207
- });
2357
+ logClaimId = (seams.createWorkerLogClaimId ?? createWorkerLogClaimId)();
2208
2358
  }
2209
2359
  catch {
2210
- tee = null;
2360
+ logClaimId = null;
2361
+ }
2362
+ let tee = null;
2363
+ if (logClaimId !== null) {
2364
+ try {
2365
+ tee = await createTee(worktreePath, {
2366
+ mkdir: deps.mkdir,
2367
+ writeFile: deps.writeFile,
2368
+ appendFile: deps.appendFile,
2369
+ writeFileExclusive: deps.writeFileExclusive,
2370
+ stat: deps.stat,
2371
+ platform: deps.platform,
2372
+ errorLog: deps.errorLog,
2373
+ }, { jobId: job.id, attempt: job.attempt_count, claimId: logClaimId });
2374
+ }
2375
+ catch {
2376
+ tee = null;
2377
+ }
2378
+ }
2379
+ if (tee === null) {
2380
+ // A logging failure never changes the worker's disposition; it only means
2381
+ // this attempt has no transcript, which is said once, without detail.
2382
+ deps.errorLog(`worker log unavailable for job ${job.id} (allocation_failed); the attempt continues.`);
2211
2383
  }
2384
+ const teeClaimId = tee?.identity.claimId;
2212
2385
  // BAPI-828 — idempotent worker-log closure, defined the moment a tee could
2213
2386
  // exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
2214
2387
  // directly, and `runSpawnJob`'s `finally` calls it once more as a backstop for
@@ -2221,8 +2394,10 @@ async function runPreparedSpawn(params) {
2221
2394
  return;
2222
2395
  workerLogClosed = true;
2223
2396
  // Deactivate FIRST: once the tee is closing, an append from the suspend
2224
- // diagnostic would be writing to a log nobody is reading any more.
2225
- deactivateJobLog(job.id);
2397
+ // diagnostic would be writing to a log nobody is reading any more. Only
2398
+ // THIS attempt's entry is removed (BAPI-1330).
2399
+ if (teeClaimId !== undefined)
2400
+ deactivateJobLog(job.id, teeClaimId);
2226
2401
  await closeWorkerLogTee(tee);
2227
2402
  };
2228
2403
  lifecycle.closeWorkerLog = closeWorkerLog;
@@ -2233,7 +2408,9 @@ async function runPreparedSpawn(params) {
2233
2408
  // visibility nicety for a separate `executor watch` process), and chaining
2234
2409
  // the active marker to it would mean a failed registry write silently
2235
2410
  // disables suspend annotation for a worker whose tee is perfectly healthy.
2236
- activateJobLog(job.id, tee.logPath);
2411
+ // BAPI-1330: an attempt whose file could not be allocated is never active.
2412
+ if (tee.available)
2413
+ activateJobLog(job.id, tee.logPath, tee.identity.claimId);
2237
2414
  const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
2238
2415
  try {
2239
2416
  await registerLog({
@@ -2244,12 +2421,17 @@ async function runPreparedSpawn(params) {
2244
2421
  worktree_path: worktreePath,
2245
2422
  log_path: tee.logPath,
2246
2423
  started_at: nowIso(deps),
2424
+ attempt: tee.identity.attempt,
2425
+ claim_id: tee.identity.claimId,
2426
+ availability: tee.available ? "available" : "unavailable",
2427
+ ...(tee.unavailableReason ? { unavailable_reason: tee.unavailableReason } : {}),
2247
2428
  }, registryDeps);
2248
2429
  }
2249
2430
  catch {
2250
2431
  /* registry is a visibility nicety — never fail the job */
2432
+ deps.errorLog(`executor job-log registry: could not record job ${job.id}'s attempt log (non-fatal)`);
2251
2433
  }
2252
- if (executorViewerTabsEnabled(deps.platform, deps.env)) {
2434
+ if (tee.available && executorViewerTabsEnabled(deps.platform, deps.env)) {
2253
2435
  const openViewer = seams.openExecutorViewerTab ?? openExecutorViewerTab;
2254
2436
  try {
2255
2437
  const viewer = await openViewer({
@@ -2273,11 +2455,21 @@ async function runPreparedSpawn(params) {
2273
2455
  const finalizeRegistry = async () => {
2274
2456
  if (!tee)
2275
2457
  return;
2458
+ const finishing = tee;
2276
2459
  try {
2277
- await markFinished(job.id, nowIso(deps), registryDeps);
2460
+ // BAPI-1330: stamps only THIS attempt's entry, and records a transcript
2461
+ // that became incomplete mid-run as unavailable rather than leaving a
2462
+ // reader to trust it.
2463
+ await markFinished(job.id, finishing.identity.claimId, nowIso(deps), registryDeps, finishing.available
2464
+ ? {}
2465
+ : {
2466
+ availability: "unavailable",
2467
+ ...(finishing.unavailableReason ? { unavailable_reason: finishing.unavailableReason } : {}),
2468
+ });
2278
2469
  }
2279
2470
  catch {
2280
2471
  /* non-fatal */
2472
+ deps.errorLog(`executor job-log registry: could not finish job ${job.id}'s attempt log (non-fatal)`);
2281
2473
  }
2282
2474
  };
2283
2475
  // --- Spawn -----------------------------------------------------------
@@ -2337,6 +2529,7 @@ async function runPreparedSpawn(params) {
2337
2529
  // denies every `git push` the worker attempts — the worker has no remote
2338
2530
  // write authority at all.
2339
2531
  ...(driver?.omitWorkerBranch ? {} : { workerBranch: branch }),
2532
+ ...(correctionWork ? { correctionIntentVersion: 1 } : {}),
2340
2533
  ...(driverStep ? { declaredTouchedFiles: driverStep.declaredTouchedFiles } : {}),
2341
2534
  parentEnv: deps.env,
2342
2535
  });
@@ -2902,6 +3095,9 @@ async function runPreparedSpawn(params) {
2902
3095
  kind: "fail",
2903
3096
  send: () => httpClient.fail(job, {
2904
3097
  ...finalization.failure,
3098
+ // BAPI-1330: the process's observed exit rides beside the
3099
+ // finalization failure; it is never rewritten to look like a crash.
3100
+ ...observedExitCode(procResult.exitCode),
2905
3101
  ...commitResidue(git),
2906
3102
  telemetry: observation.snapshot(),
2907
3103
  }),
@@ -3016,6 +3212,7 @@ async function runPreparedSpawn(params) {
3016
3212
  ? CLAUDE_NOT_AUTHENTICATED_MESSAGE
3017
3213
  : `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
3018
3214
  classification: toWireClassification(procResult.classification),
3215
+ ...observedExitCode(procResult.exitCode),
3019
3216
  ...commitResidue(git),
3020
3217
  telemetry: observation.snapshot(),
3021
3218
  ...(capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}),
@@ -3037,6 +3234,16 @@ const INITIAL_PROMPT_WRITE_BOUND_MS = 30_000;
3037
3234
  /** Bound on closing the held stdin once the closure rule fires. */
3038
3235
  const STDIN_CLOSE_BOUND_MS = 5_000;
3039
3236
  const SUPPORTED_COMPLETION_MARKER = COMPLETION_CONTRACT_MARKER_VALUES[0];
3237
+ /**
3238
+ * BAPI-1330 — the worker's observed exit code as a `/fail` field. Omitted (never
3239
+ * null, never invented) when the process ended without a usable code, e.g. on a
3240
+ * signal. Only ever read from the process result, never from worker output.
3241
+ */
3242
+ function observedExitCode(exitCode) {
3243
+ return typeof exitCode === "number" && Number.isInteger(exitCode) && exitCode >= 0
3244
+ ? { exit_code: exitCode }
3245
+ : {};
3246
+ }
3040
3247
  /**
3041
3248
  * Whether this claim runs under the covered completion contract.
3042
3249
  *
@@ -3604,6 +3811,7 @@ async function runCoveredAttempt(params) {
3604
3811
  ? CLAUDE_NOT_AUTHENTICATED_MESSAGE
3605
3812
  : `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
3606
3813
  classification: toWireClassification(procResult.classification),
3814
+ ...observedExitCode(procResult.exitCode),
3607
3815
  }, envelope, capacityEvidence !== undefined ? { capacity_evidence: capacityEvidence } : {}));
3608
3816
  }
3609
3817
  // --- Phase 3: independent declared checks (BAPI-1302, D4c) ---------------------
@@ -3734,6 +3942,9 @@ async function runCoveredAttempt(params) {
3734
3942
  runCommand: deps.runCommand,
3735
3943
  headSha: git.last_commit_sha,
3736
3944
  expectedBaseBranch: effectiveBaseBranch,
3945
+ // BAPI-1330: this host only runs claims the SERVER stamped as covered, so
3946
+ // the existence-only legacy fallback is refused here.
3947
+ coveredAttempt: true,
3737
3948
  });
3738
3949
  const facts = finalizationFacts(finalization.ok ? { ok: true } : { ok: false, errorKind: finalization.failure.error_kind });
3739
3950
  const envelope = envelopeWith({
@@ -3775,19 +3986,35 @@ async function runCoveredAttempt(params) {
3775
3986
  // Not a success. A completion cause (required work, cleanup, verification,
3776
3987
  // permission) outranks the finalization kind; with none, finalization keeps
3777
3988
  // its existing error kind and recovery lane.
3989
+ //
3990
+ // BAPI-1330: every branch below is reached only after the worker exited
3991
+ // cleanly. A completion refusal reports that observed process fact —
3992
+ // `clean_exit` and the real exit code — beside the refusal (the server decides
3993
+ // it from the envelope's completion cause, never the classification). A
3994
+ // finalization failure adds the exit code to its own kind and classification.
3995
+ const observedExit = {
3996
+ classification: toWireClassification(procResult.classification),
3997
+ ...observedExitCode(procResult.exitCode),
3998
+ };
3778
3999
  const cause = envelope.cause;
3779
4000
  if (cause) {
3780
4001
  return finish(await failWith({
3781
4002
  error_kind: COMPLETION_CAUSE_ERROR_KINDS[cause],
3782
4003
  error_message: `covered attempt did not prove completion (${successRefusalReason(envelope) ?? cause})`,
3783
- classification: "crashed",
4004
+ ...observedExit,
3784
4005
  }, envelope));
3785
4006
  }
3786
- if (!finalization.ok)
3787
- return finish(await failWith(finalization.failure, envelope));
4007
+ if (!finalization.ok) {
4008
+ // The finalization failure keeps its OWN classification, exactly as the
4009
+ // legacy path does: the kinds that mean "the process ended cleanly"
4010
+ // (`PrNotAttached`, `PublicationUnverified`) already say `clean_exit`, and
4011
+ // the rest keep the classification their existing recovery routing reads.
4012
+ // Only the observed exit code is added.
4013
+ return finish(await failWith({ ...finalization.failure, ...observedExitCode(procResult.exitCode) }, envelope));
4014
+ }
3788
4015
  return finish(await failWith({
3789
4016
  error_kind: COMPLETION_CAUSE_ERROR_KINDS.required_work_incomplete,
3790
4017
  error_message: `covered attempt did not prove completion (${successRefusalReason(envelope) ?? "unknown"})`,
3791
- classification: "crashed",
4018
+ ...observedExit,
3792
4019
  }, envelope));
3793
4020
  }
@@ -4,10 +4,8 @@
4
4
  * Single source of truth for which claimed job types the executor spawns and
5
5
  * which of those are RECOVERY jobs. Recovery jobs (`remediate`/`ci_fix`/`rebase`/
6
6
  * `reconcile_overlap`) resume the ticket's existing branch/PR rather than cutting
7
- * a fresh branch off base — `remediate`/`rebase` through the `/implement-ticket
8
- * <KEY> --auto` correction loop, `ci_fix` through its focused prompt spec
9
- * (BAPI-1296: never the full recipe), `reconcile_overlap` through its own job
10
- * body. Dispatch
7
+ * a fresh branch off base. Each recovery worker requires a versioned focused
8
+ * correction intent; `ci_fix` also uses its focused prompt spec. Dispatch
11
9
  * (`job-runner.ts` `runClaimedJob`), prompt synthesis (`worker-command.ts`
12
10
  * `resolveExecutorPrompt`), and worktree reuse (`job-runner.ts` `prepareSpawn` →
13
11
  * `worktree.ts` `ensureExecutorWorktree`) all read from here so the recovery /
@@ -24,16 +22,13 @@
24
22
  * `reconcile_overlap`). They work on the ticket's EXISTING branch/PR, so the
25
23
  * executor reuses that branch instead of cutting a fresh one off base.
26
24
  *
27
- * BAPI-1296: `ci_fix` stays a recovery job — and an implementation-style job
28
- * below — for branch reuse, the worktree lock, and finalization, but prompt
29
- * selection no longer follows recovery membership for it: `worker-command.ts`
30
- * refuses to synthesize `/implement-ticket` for `ci_fix`, and `job-runner.ts`
31
- * renders its server-authored focused prompt spec instead.
25
+ * `ci_fix` stays a recovery job for branch reuse, the worktree lock, and
26
+ * finalization. `job-runner.ts` renders its server-authored focused prompt spec
27
+ * after the correction intent is validated.
32
28
  *
33
- * BAPI-1152: `reconcile_overlap` is a recovery job for worktree reuse and prompt
34
- * resolution, but it does NOT re-enter `/implement-ticket`: it always carries an
35
- * explicit server-rendered `payload.prompt`, which wins over the synthesis in
36
- * `worker-command.ts`, and `job-runner.ts` dispatches it to its own body.
29
+ * `reconcile_overlap` is a recovery job for worktree reuse and prompt
30
+ * resolution. Its server-rendered focused prompt is available only after
31
+ * validating the correction intent, and `job-runner.ts` dispatches its body.
37
32
  *
38
33
  * BAPI-1160: `ci_rerun` is DELIBERATELY ABSENT from this set and from
39
34
  * `SPAWN_JOB_TYPES` below. It is a deterministic actuator — one bounded
@@ -4,8 +4,14 @@ import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter
4
4
  import { resolveCompletionEvidenceCapability } from "../agent-launchers/executor-adapter.js";
5
5
  import { COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY, runtimeMatchesSupported, } from "./completion-contract.js";
6
6
  import { resolveExecutorAgentId } from "./agent-identity.js";
7
+ import { mkdtemp, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { defaultResolveBinPath } from "../start-tickets-conductor.js";
12
+ import { CORRECTION_INTENT_CAPABILITY } from "./correction-dispatch.js";
7
13
  import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
8
- import { FOCUSED_REBASE_CAPABILITY } from "./types.js";
14
+ import { FOCUSED_REBASE_CAPABILITY, SCOPE_WAIT_CAPABILITY } from "./types.js";
9
15
  /**
10
16
  * Environment override for the deny probe's headless timeout (BAPI-722).
11
17
  *
@@ -154,6 +160,62 @@ function refusedPreflight(fatalFindings, warnings, adapter) {
154
160
  adapter,
155
161
  };
156
162
  }
163
+ const CORRECTION_HOOK_FIXTURE_ID = "correction-entry-nested-v1";
164
+ /** Exercise the packaged hook process, including a nested-agent entry attempt. */
165
+ export function probeInstalledCorrectionHook(worktreePath, binPath = defaultResolveBinPath("worker-guard-hook-bin.js")) {
166
+ const args = [binPath, "--worktree-root", worktreePath,
167
+ "--correction-intent-version", "1"];
168
+ const env = { ...process.env, BAPI_CORRECTION_INTENT_VERSION: "1" };
169
+ const fixtures = [
170
+ { payload: { tool_name: "Skill", tool_input: { skill: "bridge-implement-ticket" } }, status: 2 },
171
+ { payload: { tool_name: "Task", tool_input: { prompt: "/implement-ticket BAPI-1 --auto" } }, status: 2 },
172
+ { payload: { tool_name: "Read", tool_input: { file_path: "commands/src/implement-ticket.md" } }, status: 2 },
173
+ { payload: { tool_name: "mcp__bridge_api__request_plan_generation", tool_input: {} }, status: 2 },
174
+ { payload: { tool_name: "mcp__bridge_api__get_plan", tool_input: { ticket_number: "BAPI-1" } }, status: 0 },
175
+ ];
176
+ return fixtures.every(({ payload, status }) => {
177
+ const result = spawnSync(process.execPath, args, {
178
+ input: JSON.stringify(payload), encoding: "utf8", env, timeout: 5000,
179
+ });
180
+ return result.error === undefined && result.status === status;
181
+ });
182
+ }
183
+ export async function qualifyCorrectionIntentRuntime(adapter, deps, probeHook = probeInstalledCorrectionHook) {
184
+ if (adapter.identity.strategyId !== "claude-strict-mcp-v1" ||
185
+ adapter.denyEnforcement?.supported !== true || !deps.writeFile || !deps.mkdir) {
186
+ return { qualified: false, evidence: null };
187
+ }
188
+ const worktree = await (deps.mkdtemp ?? mkdtemp)(join((deps.tmpdir ?? tmpdir)(), "bridge-correction-hook-"));
189
+ try {
190
+ const bin = defaultResolveBinPath("worker-guard-hook-bin.js");
191
+ const installed = await adapter.denyEnforcement.value.provisionWorktreeDenyLayer({
192
+ worktreePath: worktree, baseBranch: "main", homedir: deps.homedir(),
193
+ workerBranch: "feature/probe", workerGuardHookBinPath: bin,
194
+ correctionIntentVersion: 1,
195
+ });
196
+ const settings = await deps.readFile(join(worktree, ".claude", "settings.local.json"));
197
+ const hookInstalled = installed.ok && settings.includes(bin) &&
198
+ settings.includes("--correction-intent-version 1") && settings.includes("PreToolUse");
199
+ const nestedDenial = hookInstalled && probeHook(worktree, bin);
200
+ return {
201
+ qualified: hookInstalled && nestedDenial,
202
+ evidence: {
203
+ version: 1, hook_installed: hookInstalled, nested_denial: nestedDenial,
204
+ fixture_id: CORRECTION_HOOK_FIXTURE_ID,
205
+ runtime: `${adapter.identity.adapterId}:${process.version}`,
206
+ },
207
+ };
208
+ }
209
+ catch {
210
+ return { qualified: false, evidence: null };
211
+ }
212
+ finally {
213
+ if (deps.rmRecursive)
214
+ await deps.rmRecursive(worktree);
215
+ else
216
+ await rm(worktree, { recursive: true, force: true });
217
+ }
218
+ }
157
219
  /**
158
220
  * Collect the executor preflight report. Populates the manifest fields and
159
221
  * accumulates fatal findings — the runner refuses to claim when any exist.
@@ -176,7 +238,15 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
176
238
  // preflight's own injected `readFile` keeps the inspection hermetic in
177
239
  // tests and read-only in production — the adapter never opens a file
178
240
  // handle this module did not hand it.
179
- claude: { readFile: deps.readFile },
241
+ claude: {
242
+ readFile: deps.readFile,
243
+ ...(deps.writeFile && deps.mkdir ? {
244
+ denyProvisioningDeps: {
245
+ readFile: deps.readFile, writeFile: deps.writeFile,
246
+ mkdir: deps.mkdir, homedir: deps.homedir,
247
+ },
248
+ } : {}),
249
+ },
180
250
  }));
181
251
  const resolution = resolveAdapter(agentId);
182
252
  if (!resolution.supported) {
@@ -416,6 +486,20 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
416
486
  if (!completion.qualified && completion.refusal !== null) {
417
487
  warnings.push(`completion evidence not advertised: ${completion.refusal}`);
418
488
  }
489
+ let correction = {
490
+ qualified: false, evidence: null,
491
+ };
492
+ if (fatalFindings.length === 0) {
493
+ try {
494
+ correction = await qualifyCorrectionIntentRuntime(adapter, deps);
495
+ }
496
+ catch {
497
+ correction = { qualified: false, evidence: null };
498
+ }
499
+ }
500
+ if (!correction.qualified) {
501
+ warnings.push("correction intent not advertised: installed nested hook unverified");
502
+ }
419
503
  return {
420
504
  ok: fatalFindings.length === 0,
421
505
  fatalFindings,
@@ -429,6 +513,8 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
429
513
  completionEvidenceQualified: completion.qualified,
430
514
  completionEvidenceRefusal: completion.refusal,
431
515
  completionRuntime: completion.runtime,
516
+ correctionIntentQualified: correction.qualified,
517
+ correctionQualificationEvidence: correction.evidence,
432
518
  };
433
519
  }
434
520
  /**
@@ -490,6 +576,9 @@ export function buildClaimManifest(report, options, freeSlots) {
490
576
  ? [COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY]
491
577
  : []),
492
578
  FOCUSED_REBASE_CAPABILITY,
579
+ SCOPE_WAIT_CAPABILITY,
580
+ ...(report.correctionIntentQualified === true ? [CORRECTION_INTENT_CAPABILITY] : []),
581
+ ...(report.correctionIntentQualified === true ? ["ci_evidence.v1"] : []),
493
582
  ],
494
583
  };
495
584
  }