@bridge_gpt/mcp-server 0.2.65 → 0.2.67

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 (132) hide show
  1. package/README.md +269 -82
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +55 -34
  4. package/build/agent-launchers/executor-adapter.js +34 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/bridge-config.js +104 -0
  8. package/build/codex-bridge-profile.js +10 -1
  9. package/build/commands.generated.js +12 -12
  10. package/build/conductor/bridge-api-client.js +372 -10
  11. package/build/conductor/bring-up-facts.js +1 -1
  12. package/build/conductor/doctor.js +3 -3
  13. package/build/conductor/epic-child-continuation.js +61 -34
  14. package/build/conductor/epic-implementer-cli.js +34 -6
  15. package/build/conductor/errors.js +4 -3
  16. package/build/conductor/guided-repair.js +327 -0
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/merge-execution-contract.js +27 -8
  19. package/build/conductor/merge-execution-eligibility.js +3 -2
  20. package/build/conductor/pr-ci-producer.js +1 -1
  21. package/build/conductor/readiness-cli.js +46 -9
  22. package/build/conductor/readiness-sections.js +41 -6
  23. package/build/conductor/readiness.js +204 -30
  24. package/build/conductor/recovery-cli.js +703 -20
  25. package/build/conductor/recovery-operations.js +392 -3
  26. package/build/conductor/repair-contract.js +112 -3
  27. package/build/conductor/repository-workflow-remedy.js +125 -0
  28. package/build/conductor/tools.js +2 -2
  29. package/build/conductor-bundle-artifacts.js +8 -0
  30. package/build/credential-store.js +5 -4
  31. package/build/diagnostic-text.js +36 -0
  32. package/build/docs.generated.js +8 -3
  33. package/build/doctor.js +70 -10
  34. package/build/drive-epic-key.js +6 -5
  35. package/build/drive-epic.js +479 -131
  36. package/build/epic-coherence-input.js +2 -1
  37. package/build/epic-implementer-bin.js +30 -26
  38. package/build/epic-implementer-bundle-cli.js +2 -2
  39. package/build/epic-launch-diagnostic.js +31 -0
  40. package/build/epic-launch-profile.js +44 -14
  41. package/build/executor/cli.js +3 -2
  42. package/build/executor/completion-contract.js +48 -5
  43. package/build/executor/completion-supervision.js +9 -0
  44. package/build/executor/correction-dispatch.js +60 -0
  45. package/build/executor/env.js +25 -0
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/http-client.js +35 -0
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +38 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +485 -74
  52. package/build/executor/job-types.js +8 -13
  53. package/build/executor/observation.js +6 -1
  54. package/build/executor/preflight.js +136 -10
  55. package/build/executor/prompt-spec.js +113 -9
  56. package/build/executor/required-work-ledger.js +6 -1
  57. package/build/executor/resume-pre-spawn.js +2 -0
  58. package/build/executor/runner.js +31 -2
  59. package/build/executor/service-unit.js +7 -1
  60. package/build/executor/stale-artifacts.js +5 -4
  61. package/build/executor/types.js +1 -0
  62. package/build/executor/watch-cli.js +35 -8
  63. package/build/executor/worker-command.js +43 -43
  64. package/build/executor/worker-finalization.js +161 -43
  65. package/build/executor/worker-guard-hook.js +152 -12
  66. package/build/executor/worker-log.js +137 -70
  67. package/build/executor/worktree-gc.js +30 -6
  68. package/build/executor/worktree.js +82 -1
  69. package/build/index.js +482 -660
  70. package/build/init.js +107 -4
  71. package/build/install-bridge-conductor.js +8 -6
  72. package/build/install-bridge.js +287 -23
  73. package/build/install-doctor.js +172 -30
  74. package/build/install-reexec.js +69 -7
  75. package/build/macos-terminal-permission-guidance.js +16 -0
  76. package/build/model-thresholds-tool.js +185 -0
  77. package/build/phase-context-git-probe.js +68 -0
  78. package/build/phase-result-artifacts.js +173 -5
  79. package/build/pipelines.generated.js +19 -25
  80. package/build/plane/alembic-head.js +12 -3
  81. package/build/plane/base-worktree-guard.js +147 -0
  82. package/build/plane/cli.js +217 -17
  83. package/build/plane/code-snapshot.js +468 -0
  84. package/build/plane/defaults.js +48 -3
  85. package/build/plane/endpoint.js +41 -0
  86. package/build/plane/manifest.js +120 -1
  87. package/build/plane/member-roster.js +94 -19
  88. package/build/plane/preflight.js +30 -2
  89. package/build/plane/process-cwd.js +67 -0
  90. package/build/plane/restart.js +542 -0
  91. package/build/plane/shutdown.js +31 -0
  92. package/build/plane/status.js +145 -6
  93. package/build/plane/supervisor.js +588 -177
  94. package/build/plane/types.js +54 -2
  95. package/build/plane/worker-exit-code.js +2 -0
  96. package/build/printed-remedy-commands.js +37 -0
  97. package/build/project-root.js +99 -0
  98. package/build/readiness-check.js +1 -1
  99. package/build/readme.generated.js +1 -1
  100. package/build/review-generation.js +22 -0
  101. package/build/review-tickets.js +156 -13
  102. package/build/setup-epic.js +224 -127
  103. package/build/sfcc/config.js +9 -0
  104. package/build/sfcc/credentials.js +100 -38
  105. package/build/sfcc/hostname.js +67 -0
  106. package/build/sfcc/permissions.js +17 -13
  107. package/build/sfcc/register.js +1 -1
  108. package/build/sfcc/setup-status.js +2 -0
  109. package/build/sfcc/tool-wrapper.js +41 -43
  110. package/build/sfcc/tools-configured.js +96 -0
  111. package/build/sfcc/write-grants.js +17 -11
  112. package/build/sfcc/write-result.js +4 -3
  113. package/build/sfcc/writes-site-preference.js +20 -8
  114. package/build/start-tickets.js +332 -79
  115. package/build/tool-error-envelope.js +61 -0
  116. package/build/upgrade-cli.js +109 -24
  117. package/build/version.generated.js +3 -3
  118. package/build/visual-diff.js +22 -1
  119. package/build/windows-npm-entry.js +103 -0
  120. package/build/worker-guard-hook-bin.js +3 -5
  121. package/build/worker-guard-hook-main.js +47 -0
  122. package/build/worktree-core.js +136 -9
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  124. package/docs/EPIC-IMPLEMENTER.md +662 -0
  125. package/docs/install/mcp-tool-integrations.md +25 -15
  126. package/docs/install/sfcc-integration.md +21 -8
  127. package/package.json +1 -1
  128. package/pipelines/implement-ticket.json +1 -1
  129. package/pipelines/plan-epic.json +0 -5
  130. package/pipelines/review-ticket.json +1 -1
  131. package/docs/CONDUCTOR.md +0 -605
  132. package/pipelines/idea-to-pr.json +0 -49
@@ -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
@@ -34,9 +34,14 @@ export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
34
34
  /* degraded: omit last_commit_sha */
35
35
  }
36
36
  try {
37
+ // `--no-optional-locks`: a plain `git status` refreshes the index and takes
38
+ // `.git/index.lock`. This runs on every heartbeat against the LIVE worker's
39
+ // worktree, so without it a worker's own `git add`/`commit` can fail with
40
+ // "index.lock: File exists".
37
41
  const status = await deps.runCommand("git", [
38
42
  "-C",
39
43
  worktreePath,
44
+ "--no-optional-locks",
40
45
  "status",
41
46
  "--porcelain",
42
47
  ]);
@@ -293,7 +298,7 @@ const RATE_LIMIT_CONSEQUENCE_TEXT = "This warning is advisory only and triggers
293
298
  "rejection and the server advertised capacity recovery for this job; otherwise " +
294
299
  "the failure follows ordinary failure handling.";
295
300
  /** Fixed runbook reference appended to every rate-limit diagnostic (BAPI-898). */
296
- const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/claude/epic-implementer-v2-operator-runbook.md §8.";
301
+ const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/epic-implementer/operator-runbook.md §8.";
297
302
  /**
298
303
  * Render the operator-facing rate-limit line (BAPI-828, BAPI-898).
299
304
  *
@@ -2,10 +2,17 @@ import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
2
2
  import { inspectExecutorAgentAdapter, inspectionForFailedResolution, } from "../agent-launchers/executor-adapter-inspection.js";
3
3
  import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
4
4
  import { resolveCompletionEvidenceCapability } from "../agent-launchers/executor-adapter.js";
5
- import { COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY, runtimeMatchesSupported, } from "./completion-contract.js";
5
+ import { COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY, cliVersionAtLeast, normalizeCliVersion, parseCliVersion, runtimeMatchesSupported, } from "./completion-contract.js";
6
+ import { buildHeadlessAgentEnv } from "./env.js";
6
7
  import { resolveExecutorAgentId } from "./agent-identity.js";
8
+ import { mkdtemp, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+ import { defaultResolveBinPath } from "../start-tickets-conductor.js";
13
+ import { CORRECTION_INTENT_CAPABILITY } from "./correction-dispatch.js";
7
14
  import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
8
- import { FOCUSED_REBASE_CAPABILITY } from "./types.js";
15
+ import { FOCUSED_REBASE_CAPABILITY, SCOPE_WAIT_CAPABILITY } from "./types.js";
9
16
  /**
10
17
  * Environment override for the deny probe's headless timeout (BAPI-722).
11
18
  *
@@ -117,20 +124,34 @@ export async function evaluateCompletionEvidenceQualification(adapter, deps) {
117
124
  if (probed === null) {
118
125
  return { qualified: false, refusal: "runtime identity probe did not run", runtime: null };
119
126
  }
120
- const declared = capability.value.runtime;
127
+ const declared = capability.value.predicate;
121
128
  const mismatches = [];
122
- for (const key of ["adapter", "cli_version", "platform", "event_schema"]) {
123
- if (probed[key] !== declared[key]) {
124
- mismatches.push(`${key} ${String(probed[key]).slice(0, 40)} != ${declared[key]}`);
125
- }
129
+ if (probed.adapter !== declared.adapter) {
130
+ mismatches.push(`adapter ${String(probed.adapter).slice(0, 40)} != ${declared.adapter}`);
131
+ }
132
+ if (probed.event_schema !== declared.event_schema) {
133
+ mismatches.push(`event_schema ${String(probed.event_schema).slice(0, 40)} != ${declared.event_schema}`);
126
134
  }
127
135
  if (mismatches.length > 0) {
128
136
  return {
129
137
  qualified: false,
130
- refusal: bounded(`probed runtime differs from the approved identity: ${mismatches.join("; ")}`),
138
+ refusal: bounded(`probed runtime differs from the declared predicate: ${mismatches.join("; ")}`),
131
139
  runtime: probed,
132
140
  };
133
141
  }
142
+ // Fixed reasons below: the probed version and platform are never echoed, because
143
+ // an unparseable value is arbitrary probe output.
144
+ const version = parseCliVersion(probed.cli_version);
145
+ const minimum = parseCliVersion(declared.min_cli_version);
146
+ if (version === null || minimum === null) {
147
+ return { qualified: false, refusal: "probed CLI version is not a recognized version", runtime: probed };
148
+ }
149
+ if (!cliVersionAtLeast(version, minimum)) {
150
+ return { qualified: false, refusal: "probed CLI version is older than the minimum", runtime: probed };
151
+ }
152
+ if (!declared.platforms.includes(probed.platform)) {
153
+ return { qualified: false, refusal: "probed platform is not a supported platform", runtime: probed };
154
+ }
134
155
  if (!runtimeMatchesSupported(probed)) {
135
156
  return {
136
157
  qualified: false,
@@ -138,7 +159,12 @@ export async function evaluateCompletionEvidenceQualification(adapter, deps) {
138
159
  runtime: probed,
139
160
  };
140
161
  }
141
- return { qualified: true, refusal: null, runtime: probed };
162
+ // Normalized (suffix stripped) so the retained identity and the envelope carry one form.
163
+ const normalized = normalizeCliVersion(probed.cli_version);
164
+ if (normalized === null) {
165
+ return { qualified: false, refusal: "probed CLI version is not a recognized version", runtime: probed };
166
+ }
167
+ return { qualified: true, refusal: null, runtime: { ...probed, cli_version: normalized } };
142
168
  }
143
169
  /** Fail-fast report shape for a preflight that refused before probing. */
144
170
  function refusedPreflight(fatalFindings, warnings, adapter) {
@@ -154,6 +180,68 @@ function refusedPreflight(fatalFindings, warnings, adapter) {
154
180
  adapter,
155
181
  };
156
182
  }
183
+ export const CORRECTION_HOOK_FIXTURE_ID = "correction-entry-nested-v2";
184
+ const spawnCorrectionHook = (args, input, env) => {
185
+ const result = spawnSync(process.execPath, args, { input, encoding: "utf8", env, timeout: 5000 });
186
+ return { status: result.status, ...(result.error ? { error: result.error } : {}) };
187
+ };
188
+ /** Exercise the packaged hook process, including a nested-agent entry attempt. */
189
+ export function probeInstalledCorrectionHook(worktreePath, binPath = defaultResolveBinPath("worker-guard-hook-bin.js"), run = spawnCorrectionHook) {
190
+ const args = [binPath, "--worktree-root", worktreePath,
191
+ "--correction-intent-version", "1"];
192
+ const env = { ...process.env, BAPI_CORRECTION_INTENT_VERSION: "1" };
193
+ const fixtures = [
194
+ { payload: { tool_name: "Skill", tool_input: { skill: "bridge-implement-ticket" } }, status: 2 },
195
+ { payload: { tool_name: "Task", tool_input: { prompt: "/implement-ticket BAPI-1 --auto" } }, status: 2 },
196
+ { payload: { tool_name: "Read", tool_input: { file_path: "commands/src/implement-ticket.md" } }, status: 0 },
197
+ {
198
+ payload: { tool_name: "Bash", tool_input: { command: 'claude -p "/implement-ticket BAPI-1"' } },
199
+ status: 2,
200
+ },
201
+ { payload: { tool_name: "mcp__bridge_api__request_plan_generation", tool_input: {} }, status: 2 },
202
+ { payload: { tool_name: "mcp__bridge_api__get_plan", tool_input: { ticket_number: "BAPI-1" } }, status: 0 },
203
+ ];
204
+ return fixtures.every(({ payload, status }) => {
205
+ const result = run(args, JSON.stringify(payload), env);
206
+ return result.error === undefined && result.status === status;
207
+ });
208
+ }
209
+ export async function qualifyCorrectionIntentRuntime(adapter, deps, probeHook = probeInstalledCorrectionHook) {
210
+ if (adapter.identity.strategyId !== "claude-strict-mcp-v1" ||
211
+ adapter.denyEnforcement?.supported !== true || !deps.writeFile || !deps.mkdir) {
212
+ return { qualified: false, evidence: null };
213
+ }
214
+ const worktree = await (deps.mkdtemp ?? mkdtemp)(join((deps.tmpdir ?? tmpdir)(), "bridge-correction-hook-"));
215
+ try {
216
+ const bin = defaultResolveBinPath("worker-guard-hook-bin.js");
217
+ const installed = await adapter.denyEnforcement.value.provisionWorktreeDenyLayer({
218
+ worktreePath: worktree, baseBranch: "main", homedir: deps.homedir(),
219
+ workerBranch: "feature/probe", workerGuardHookBinPath: bin,
220
+ correctionIntentVersion: 1,
221
+ });
222
+ const settings = await deps.readFile(join(worktree, ".claude", "settings.local.json"));
223
+ const hookInstalled = installed.ok && settings.includes(bin) &&
224
+ settings.includes("--correction-intent-version 1") && settings.includes("PreToolUse");
225
+ const nestedDenial = hookInstalled && probeHook(worktree, bin);
226
+ return {
227
+ qualified: hookInstalled && nestedDenial,
228
+ evidence: {
229
+ version: 1, hook_installed: hookInstalled, nested_denial: nestedDenial,
230
+ fixture_id: CORRECTION_HOOK_FIXTURE_ID,
231
+ runtime: `${adapter.identity.adapterId}:${process.version}`,
232
+ },
233
+ };
234
+ }
235
+ catch {
236
+ return { qualified: false, evidence: null };
237
+ }
238
+ finally {
239
+ if (deps.rmRecursive)
240
+ await deps.rmRecursive(worktree);
241
+ else
242
+ await rm(worktree, { recursive: true, force: true });
243
+ }
244
+ }
157
245
  /**
158
246
  * Collect the executor preflight report. Populates the manifest fields and
159
247
  * accumulates fatal findings — the runner refuses to claim when any exist.
@@ -176,7 +264,15 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
176
264
  // preflight's own injected `readFile` keeps the inspection hermetic in
177
265
  // tests and read-only in production — the adapter never opens a file
178
266
  // handle this module did not hand it.
179
- claude: { readFile: deps.readFile },
267
+ claude: {
268
+ readFile: deps.readFile,
269
+ ...(deps.writeFile && deps.mkdir ? {
270
+ denyProvisioningDeps: {
271
+ readFile: deps.readFile, writeFile: deps.writeFile,
272
+ mkdir: deps.mkdir, homedir: deps.homedir,
273
+ },
274
+ } : {}),
275
+ },
180
276
  }));
181
277
  const resolution = resolveAdapter(agentId);
182
278
  if (!resolution.supported) {
@@ -341,8 +437,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
341
437
  if (denyCapability?.supported !== true) {
342
438
  throw new Error("adapter declares no deny-enforcement capability");
343
439
  }
440
+ // The probe gets a worker-shaped environment so a nested-session variable in
441
+ // the executor's own process cannot change its verdict (BAPI-1386).
344
442
  const verdict = await denyCapability.value.probeEnforcement({
345
443
  timeoutMs: resolveDenyProbeTimeoutMs(deps.env),
444
+ env: buildHeadlessAgentEnv(deps.env),
346
445
  });
347
446
  deny = {
348
447
  enforced: verdict.enforced,
@@ -416,10 +515,32 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
416
515
  if (!completion.qualified && completion.refusal !== null) {
417
516
  warnings.push(`completion evidence not advertised: ${completion.refusal}`);
418
517
  }
518
+ // One notice per qualifying collection, built only from validated fields: the
519
+ // adapter id the predicate matched, the normalized version, and a closed platform id.
520
+ const notices = [];
521
+ if (completion.qualified && completion.runtime !== null) {
522
+ notices.push(`completion evidence advertised: ${completion.runtime.adapter} ` +
523
+ `${completion.runtime.cli_version} ${completion.runtime.platform}`);
524
+ }
525
+ let correction = {
526
+ qualified: false, evidence: null,
527
+ };
528
+ if (fatalFindings.length === 0) {
529
+ try {
530
+ correction = await qualifyCorrectionIntentRuntime(adapter, deps);
531
+ }
532
+ catch {
533
+ correction = { qualified: false, evidence: null };
534
+ }
535
+ }
536
+ if (!correction.qualified) {
537
+ warnings.push("correction intent not advertised: installed nested hook unverified");
538
+ }
419
539
  return {
420
540
  ok: fatalFindings.length === 0,
421
541
  fatalFindings,
422
542
  warnings,
543
+ notices,
423
544
  agentVersion,
424
545
  wtVersion,
425
546
  diskFreeGb,
@@ -429,6 +550,8 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
429
550
  completionEvidenceQualified: completion.qualified,
430
551
  completionEvidenceRefusal: completion.refusal,
431
552
  completionRuntime: completion.runtime,
553
+ correctionIntentQualified: correction.qualified,
554
+ correctionQualificationEvidence: correction.evidence,
432
555
  };
433
556
  }
434
557
  /**
@@ -490,6 +613,9 @@ export function buildClaimManifest(report, options, freeSlots) {
490
613
  ? [COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY]
491
614
  : []),
492
615
  FOCUSED_REBASE_CAPABILITY,
616
+ SCOPE_WAIT_CAPABILITY,
617
+ ...(report.correctionIntentQualified === true ? [CORRECTION_INTENT_CAPABILITY] : []),
618
+ ...(report.correctionIntentQualified === true ? ["ci_evidence.v1"] : []),
493
619
  ],
494
620
  };
495
621
  }
@@ -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": {
@@ -877,6 +877,10 @@ export function createRequiredWorkLedger(init) {
877
877
  const readyToClose = resultsSeen > 0 && latestIsResult && !turnOpen && liveRequired.length === 0 && snap.required.length === 0;
878
878
  return { readyToClose, liveRequired, liveOptional, activitySeq, latestIsResult };
879
879
  };
880
+ // One predicate for "a required task is still pending", shared by the envelope
881
+ // and the observe-only query so the two cannot drift apart.
882
+ const hasPendingRequiredWork = () => effectiveTasks().some(({ o, state }) => o.required && state === "pending");
883
+ const observeLiveRequiredWork = () => hasPendingRequiredWork() ? "live_required_work" : null;
880
884
  const buildEnvelope = (facts) => {
881
885
  const reasons = new Set(insufficient);
882
886
  if (!lifecycleSupported || !runtimeMatchesSupported(facts.runtime))
@@ -897,7 +901,7 @@ export function createRequiredWorkLedger(init) {
897
901
  reasons.add("forced_kill_no_cli_teardown");
898
902
  const effective = effectiveTasks();
899
903
  // R6: a required task with no terminal after the stream ended is missing evidence.
900
- if (effective.some(({ o, state }) => o.required && state === "pending"))
904
+ if (hasPendingRequiredWork())
901
905
  reasons.add("live_required_work");
902
906
  // R6b: a stale snapshot, or a snapshot-only task nothing ever explained.
903
907
  const snap = liveSnapshotIds();
@@ -993,6 +997,7 @@ export function createRequiredWorkLedger(init) {
993
997
  recordDeclarationRefused: () => {
994
998
  insufficient.add("declaration_refused");
995
999
  },
1000
+ observeLiveRequiredWork,
996
1001
  buildEnvelope,
997
1002
  };
998
1003
  }
@@ -149,6 +149,8 @@ export function fillResumePromptPlaceholders(template, gitLog, gitDiff) {
149
149
  // ---------------------------------------------------------------------------
150
150
  /** The deterministic WIP checkpoint message for a continuation. */
151
151
  export const CONTINUATION_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before continuation";
152
+ /** BAPI-1367: the WIP checkpoint message when a retry resumes its prior attempt's work. */
153
+ export const PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before retry resume";
152
154
  const FULL_SHA = /^[0-9a-f]{40}$/;
153
155
  /** Read the continuation binding from a claimed job's payload (pure). */
154
156
  export function readContinuationBinding(job) {
@@ -70,7 +70,7 @@ export function formatDispatcherUnavailableDiagnostic(liveness) {
70
70
  return (`executor: no fresh reconciler/dispatcher heartbeat is available ` +
71
71
  `(reconciler_liveness=${liveness}); the dispatcher may not be running. ` +
72
72
  "Check DISABLE_SCHEDULER and GET /automation/health; see " +
73
- "docs/claude/epic-implementer-v2-operator-runbook.md. Continuing to poll.");
73
+ "docs/epic-implementer/operator-runbook.md. Continuing to poll.");
74
74
  }
75
75
  /** The recovery diagnostic, emitted once when an absent dispatcher comes back. */
76
76
  export function formatDispatcherRecoveredDiagnostic() {
@@ -94,7 +94,8 @@ export const PROCESS_HEARTBEAT_INTERVAL_MS = 60_000;
94
94
  /** Delivery-failure diagnostic. Bounded, credential-free, stderr-only. */
95
95
  export function formatProcessHeartbeatFailureDiagnostic() {
96
96
  return ("executor: process-heartbeat delivery is failing; the server may report this " +
97
- "executor as stale. Claiming and running jobs are unaffected.");
97
+ "executor as stale. If the server is unreachable, job heartbeats and claims " +
98
+ "can fail too, and a running job can lose its lease.");
98
99
  }
99
100
  /** Emitted once when delivery starts working again. */
100
101
  export function formatProcessHeartbeatRecoveredDiagnostic() {
@@ -151,6 +152,8 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
151
152
  // optional mtime boundary (keeps GC conservative → retains).
152
153
  statMtimeMs: deps.statMtimeMs ?? (async () => null),
153
154
  platform: deps.platform,
155
+ // BAPI-1330: finds the per-attempt worker logs for the activity probe.
156
+ ...(deps.readdir ? { listDir: deps.readdir } : {}),
154
157
  }));
155
158
  // BAPI-1301 — before the first claim, inspect ownership records left by a
156
159
  // previous executor process on this host. Each record holds the real per-job
@@ -557,6 +560,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
557
560
  * a failure is reported to stderr and the loop keeps going. It cannot fail a
558
561
  * claim, fail a job, or end the run.
559
562
  */
563
+ let latestCorrectionQualification = null;
560
564
  const runProcessHeartbeatLoop = async () => {
561
565
  const publish = httpClient.processHeartbeat;
562
566
  // A client without the capability (every object-literal test fake) simply
@@ -589,10 +593,20 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
589
593
  ? { claim_mode: "scoped", epic_run_id: observedEpicRunId }
590
594
  : { claim_mode: "scoped" },
591
595
  };
596
+ const baseMetadata = request.metadata;
592
597
  // Suppression state: one message per CHANGE of delivery state, so a long
593
598
  // outage produces one line rather than one per minute.
594
599
  let lastFailed = false;
595
600
  for (;;) {
601
+ request.metadata = {
602
+ ...baseMetadata,
603
+ ...(latestCorrectionQualification
604
+ ? { correction_intent: latestCorrectionQualification }
605
+ : {}),
606
+ ...(latestCorrectionQualification?.qualified === true
607
+ ? { ci_evidence: { version: 1, qualified: true } }
608
+ : {}),
609
+ };
596
610
  let outcome;
597
611
  try {
598
612
  outcome = await publish.call(httpClient, request);
@@ -781,6 +795,16 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
781
795
  try {
782
796
  for (;;) {
783
797
  const report = await collectPreflight(options, deps, preflightSeams);
798
+ latestCorrectionQualification = report.correctionIntentQualified === true &&
799
+ report.correctionQualificationEvidence?.hook_installed === true &&
800
+ report.correctionQualificationEvidence.nested_denial === true
801
+ ? {
802
+ version: 1, qualified: true, hook_installed: true,
803
+ nested_denial: true,
804
+ fixture_id: report.correctionQualificationEvidence.fixture_id,
805
+ runtime: report.correctionQualificationEvidence.runtime,
806
+ }
807
+ : null;
784
808
  // First heartbeat goes out immediately once preflight passes, so a freshly
785
809
  // started executor is visible to the server without waiting one interval.
786
810
  // Gated on `report.ok` because an executor that preflight refuses is not
@@ -796,6 +820,11 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
796
820
  for (const warning of report.warnings) {
797
821
  deps.errorLog(`executor preflight warning: ${warning}`);
798
822
  }
823
+ // BAPI-1386: informational lines (the completion-evidence advertisement) go to
824
+ // stderr like every other diagnostic here, never stdout.
825
+ for (const notice of report.notices ?? []) {
826
+ deps.errorLog(`executor preflight: ${notice}`);
827
+ }
799
828
  if (!report.ok) {
800
829
  deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
801
830
  }
@@ -929,7 +929,8 @@ export function resolvePackagedExecutorInvocation(deps) {
929
929
  /** User-facing usage for `executor install-service`. */
930
930
  export function getExecutorInstallServiceUsage() {
931
931
  return [
932
- "Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...] [options]",
932
+ "Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...]",
933
+ " (--epic-run-id <id> | --repo-wide) [options]",
933
934
  "",
934
935
  "The FILE-GENERATION LEG of durable-host mode. It writes one unit and stops there.",
935
936
  "For the supported composed bring-up — planning, consented write, consented start,",
@@ -947,6 +948,11 @@ export function getExecutorInstallServiceUsage() {
947
948
  " needs a durable identity, so the foreground",
948
949
  " <hostname>-<pid> default is never used here.",
949
950
  " --repo <name> Repo to serve (repeatable; --repos=a,b also works).",
951
+ " --epic-run-id <id> Claim scope: dedicate the unit to an epic run",
952
+ " (repeatable). Or:",
953
+ " --repo-wide Claim scope: claim from every authorized repo,",
954
+ " unscoped by run. Exactly one of the two is required;",
955
+ " the executor refuses to start without one.",
950
956
  " --base-url <url> Bridge API endpoint, or set BAPI_BASE_URL.",
951
957
  " There is NO production default.",
952
958
  "",
@@ -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
  /**