@bridge_gpt/mcp-server 0.2.66 → 0.2.68

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 (171) hide show
  1. package/README.md +421 -83
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +70 -16
  4. package/build/agent-launchers/executor-adapter.js +7 -4
  5. package/build/agents.generated.js +22 -2
  6. package/build/bitbucket-workspace-remote.js +136 -0
  7. package/build/bootstrap-project-identity.js +24 -0
  8. package/build/bounded-wait.js +67 -10
  9. package/build/bridge-permissions.js +829 -0
  10. package/build/claude-settings.js +18 -0
  11. package/build/cli-help.js +21 -0
  12. package/build/command-assets-doctor.js +24 -70
  13. package/build/command-provisioning.js +29 -5
  14. package/build/commands.generated.js +12 -12
  15. package/build/conductor/bridge-api-client.js +857 -1
  16. package/build/conductor/bring-up-facts.js +1 -1
  17. package/build/conductor/command-drift-production.js +92 -0
  18. package/build/conductor/command-drift.js +89 -0
  19. package/build/conductor/doctor.js +72 -29
  20. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  21. package/build/conductor/epic-implementer-cli.js +98 -28
  22. package/build/conductor/errors.js +4 -3
  23. package/build/conductor/gate-discovery.js +890 -0
  24. package/build/conductor/guided-repair.js +6 -2
  25. package/build/conductor/hook-runtime-qualification.js +1 -1
  26. package/build/conductor/install-doctor.js +15 -3
  27. package/build/conductor/json-output.js +63 -0
  28. package/build/conductor/merge-execution-contract.js +27 -8
  29. package/build/conductor/merge-execution-eligibility.js +3 -2
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +113 -12
  32. package/build/conductor/readiness-sections.js +410 -9
  33. package/build/conductor/readiness.js +388 -33
  34. package/build/conductor/recovery-cli.js +310 -12
  35. package/build/conductor/recovery-operations.js +167 -3
  36. package/build/conductor/repair-contract.js +54 -3
  37. package/build/conductor/repository-workflow-remedy.js +125 -0
  38. package/build/conductor/tools.js +2 -2
  39. package/build/conductor-bundle-artifacts.js +8 -0
  40. package/build/connect-bitbucket-api.js +41 -6
  41. package/build/connect-bitbucket.js +106 -33
  42. package/build/credential-store.js +207 -47
  43. package/build/docs.generated.js +62 -3
  44. package/build/doctor.js +92 -1
  45. package/build/drive-epic-key.js +6 -5
  46. package/build/drive-epic.js +129 -61
  47. package/build/epic-coherence-input.js +2 -1
  48. package/build/epic-coherence-input.test-fixtures.js +4 -3
  49. package/build/epic-implementer-bin.js +1379 -28
  50. package/build/epic-implementer-bundle-cli.js +2 -2
  51. package/build/epic-workarounds-path.js +39 -0
  52. package/build/executor/cli.js +33 -2
  53. package/build/executor/completion-contract.js +66 -10
  54. package/build/executor/completion-supervision.js +4 -0
  55. package/build/executor/deps.js +4 -1
  56. package/build/executor/env.js +19 -0
  57. package/build/executor/gate-qualification-job.js +499 -0
  58. package/build/executor/guarded-fast-forward-push.js +129 -0
  59. package/build/executor/heartbeat.js +1 -1
  60. package/build/executor/http-client.js +20 -0
  61. package/build/executor/integration-job.js +122 -8
  62. package/build/executor/job-errors.js +33 -1
  63. package/build/executor/job-runner.js +813 -87
  64. package/build/executor/likely-tests.js +290 -0
  65. package/build/executor/live-worker-registry.js +1 -1
  66. package/build/executor/observation.js +37 -3
  67. package/build/executor/packaged-recipe.js +106 -0
  68. package/build/executor/permissions.js +115 -28
  69. package/build/executor/platform.js +1 -1
  70. package/build/executor/preflight.js +112 -26
  71. package/build/executor/process-containment.js +36 -12
  72. package/build/executor/process.js +15 -7
  73. package/build/executor/prompt-spec.js +33 -1
  74. package/build/executor/recently-failed-tests-file.js +71 -0
  75. package/build/executor/reconcile-overlap-job.js +41 -23
  76. package/build/executor/required-verification.js +1 -1
  77. package/build/executor/required-work-ledger.js +111 -18
  78. package/build/executor/resume-pre-spawn.js +2 -0
  79. package/build/executor/retirement.js +69 -0
  80. package/build/executor/runner.js +122 -13
  81. package/build/executor/service-unit.js +7 -1
  82. package/build/executor/test-clock.js +15 -0
  83. package/build/executor/types.js +32 -0
  84. package/build/executor/viewer-tabs.js +58 -8
  85. package/build/executor/worker-command.js +23 -7
  86. package/build/executor/worker-diagnostics.js +37 -0
  87. package/build/executor/worker-finalization.js +113 -34
  88. package/build/executor/worker-guard-hook.js +316 -58
  89. package/build/executor/worker-log.js +4 -3
  90. package/build/executor/worker-verification-guidance.js +37 -0
  91. package/build/executor/worktree.js +166 -8
  92. package/build/first-build-advisory.js +97 -0
  93. package/build/generated-assets-advisory.js +14 -0
  94. package/build/generated-assets-catalog.js +66 -0
  95. package/build/generated-assets-context.js +46 -0
  96. package/build/generated-assets-dependencies.js +26 -0
  97. package/build/generated-assets-doctor.js +50 -0
  98. package/build/generated-assets-health.js +65 -0
  99. package/build/generated-assets-host.js +17 -0
  100. package/build/generated-assets-inventory.js +17 -0
  101. package/build/generated-assets-paths.js +60 -0
  102. package/build/generated-assets-publisher.js +133 -0
  103. package/build/generated-assets-remedy.js +25 -0
  104. package/build/generated-assets-repair.js +72 -0
  105. package/build/generated-assets-session.js +71 -0
  106. package/build/generated-assets-test-support.js +35 -0
  107. package/build/index.js +1224 -1054
  108. package/build/init.js +54 -177
  109. package/build/install-bridge-conductor-gate.js +1372 -0
  110. package/build/install-bridge-conductor.js +296 -41
  111. package/build/install-bridge.js +547 -40
  112. package/build/mcp-install-state.js +50 -4
  113. package/build/merge-workflow-install.js +3 -4
  114. package/build/phase-context-git-probe.js +68 -0
  115. package/build/phase-result-artifacts.js +173 -5
  116. package/build/pipeline-utils.js +6 -2
  117. package/build/pipelines.generated.js +34 -160
  118. package/build/plan-execution-ledger.js +2 -32
  119. package/build/plane/alembic-head.js +12 -3
  120. package/build/plane/base-worktree-guard.js +147 -0
  121. package/build/plane/cli.js +217 -17
  122. package/build/plane/code-snapshot.js +468 -0
  123. package/build/plane/defaults.js +48 -3
  124. package/build/plane/endpoint.js +41 -0
  125. package/build/plane/manifest.js +136 -2
  126. package/build/plane/member-roster.js +94 -19
  127. package/build/plane/preflight.js +83 -2
  128. package/build/plane/process-cwd.js +67 -0
  129. package/build/plane/restart.js +542 -0
  130. package/build/plane/shutdown.js +31 -0
  131. package/build/plane/status.js +143 -8
  132. package/build/plane/supervisor.js +586 -179
  133. package/build/plane/types.js +54 -2
  134. package/build/printed-remedy-commands.js +52 -0
  135. package/build/readiness-check.js +1 -1
  136. package/build/readme.generated.js +1 -1
  137. package/build/repair-assets.js +93 -0
  138. package/build/review-generation.js +22 -0
  139. package/build/run-unit-tests-launcher.js +130 -49
  140. package/build/secret-redaction.js +33 -0
  141. package/build/setup-epic.js +152 -22
  142. package/build/sfcc/config.js +9 -0
  143. package/build/sfcc/credentials.js +100 -38
  144. package/build/sfcc/hostname.js +67 -0
  145. package/build/sfcc/permissions.js +17 -13
  146. package/build/sfcc/register.js +1 -1
  147. package/build/sfcc/tool-wrapper.js +39 -43
  148. package/build/sfcc/write-grants.js +17 -11
  149. package/build/sfcc/write-result.js +4 -3
  150. package/build/sfcc/writes-site-preference.js +20 -8
  151. package/build/start-tickets-prereqs.js +70 -16
  152. package/build/start-tickets.js +208 -36
  153. package/build/tool-error-envelope.js +61 -0
  154. package/build/tool-surface-gating.js +36 -0
  155. package/build/upgrade-cli.js +55 -1
  156. package/build/version.generated.js +3 -3
  157. package/build/visual-diff.js +22 -1
  158. package/build/worker-guard-hook-bin.js +4 -6
  159. package/build/worker-guard-hook-main.js +47 -0
  160. package/build/worktree-core.js +145 -6
  161. package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
  162. package/docs/EPIC-IMPLEMENTER.md +728 -0
  163. package/docs/install/mcp-tool-integrations.md +24 -15
  164. package/docs/install/sfcc-integration.md +12 -4
  165. package/package.json +3 -3
  166. package/pipelines/greenfield-setup.json +4 -128
  167. package/pipelines/implement-ticket.json +3 -2
  168. package/pipelines/plan-epic.json +0 -5
  169. package/pipelines/review-ticket.json +1 -1
  170. package/docs/CONDUCTOR.md +0 -605
  171. package/pipelines/idea-to-pr.json +0 -49
@@ -36,7 +36,7 @@
36
36
  * fallback is available for CLIs where settings-deny is not enforced.
37
37
  */
38
38
  import path from "node:path";
39
- import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
39
+ import { mergeClaudeSettingsWithAutoMemoryDisabled, mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
40
40
  /**
41
41
  * The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
42
42
  * `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
@@ -117,26 +117,96 @@ export function executorDenyRules(inputs) {
117
117
  ];
118
118
  }
119
119
  /**
120
- * Branch names that a BRANCH-SHAPED deny glob would match (BAPI-1020).
120
+ * The push commands a worker uses for its OWN branch (BAPI-1397, F7).
121
121
  *
122
- * Two of the fifteen new rules — `Bash(git push:* main*)` and
123
- * `Bash(git push:* epic/*)` — deny by branch shape rather than by command. If a
124
- * worker's OWN branch ever matched one, the static deny would block the push the
125
- * guard is required to allow, and AC-10 ("it still pushes its own branch") would
126
- * break at push time with an error naming a rule nobody connected to the branch
127
- * name. The hook cannot rescue it: a PreToolUse allow does not override a
128
- * settings-level deny.
129
- *
130
- * Today the collision is unreachable — worker branches come from `wt.branch`, the
131
- * Worktrunk `feature/<KEY>` branch, and `epic/<EPIC>` appears only as a merge base
132
- * — but nothing wrote that invariant down. This does, and it fails LOUDLY at
133
- * provisioning time rather than mysteriously at push time.
122
+ * These are the forms the worker instructions direct (`checkpoint-work.md`:
123
+ * `git push origin <branch>`; `commit-and-push.md`: `git push -u origin
124
+ * <branch>`) plus the bare upstream push, and every one is a form the PreToolUse
125
+ * worker guard allows for the job's own branch (`worker-guard-hook.ts`,
126
+ * `evaluateGitPush`).
134
127
  */
135
- export function workerBranchCollidesWithDenyGlob(branch) {
128
+ export function workerOwnBranchPushCommands(branch) {
136
129
  const trimmed = branch.trim();
137
130
  if (trimmed.length === 0)
131
+ return [];
132
+ return [`git push origin ${trimmed}`, `git push -u origin ${trimmed}`, "git push"];
133
+ }
134
+ /**
135
+ * Does a Claude Code `Bash(...)` permission rule match `command`? (BAPI-1397, F7)
136
+ *
137
+ * Mirrors the rule grammar of the installed Claude Code (checked against 2.1.287):
138
+ *
139
+ * - content ending in `:*` is a PREFIX rule — `Bash(git push:*)` matches
140
+ * `git push` and anything after it;
141
+ * - otherwise content with an unescaped `*` is a WILDCARD rule — the whole
142
+ * command must match, with each `*` matching any text (a single trailing ` *`
143
+ * also matches nothing at all). A `:*` in the MIDDLE is not special here: in
144
+ * `Bash(git push:* epic/*)` the `:` is a literal character, so that rule
145
+ * never matches `git push origin epic/X`;
146
+ * - anything else is an EXACT rule.
147
+ *
148
+ * Non-`Bash` rules never match a command.
149
+ */
150
+ export function claudeBashRuleMatchesCommand(rule, command) {
151
+ const ruleMatch = /^Bash\((.*)\)$/s.exec(rule.trim());
152
+ if (!ruleMatch)
138
153
  return false;
139
- return trimmed === "main" || trimmed.startsWith("main") || trimmed.startsWith("epic/");
154
+ const content = ruleMatch[1].trim();
155
+ const cmd = command.trim();
156
+ const prefix = /^(.+):\*$/s.exec(content);
157
+ if (prefix) {
158
+ const head = prefix[1];
159
+ return cmd === head || cmd.startsWith(`${head} `);
160
+ }
161
+ let pattern = "";
162
+ let literal = "";
163
+ let stars = 0;
164
+ for (let i = 0; i < content.length; i += 1) {
165
+ const ch = content[i];
166
+ // `\*` is a literal star and `\\` a literal backslash, as in Claude Code.
167
+ if (ch === "\\" && (content[i + 1] === "*" || content[i + 1] === "\\")) {
168
+ pattern += `\\${content[i + 1]}`;
169
+ literal += content[i + 1];
170
+ i += 1;
171
+ continue;
172
+ }
173
+ if (ch === "*") {
174
+ pattern += ".*";
175
+ stars += 1;
176
+ continue;
177
+ }
178
+ pattern += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
179
+ literal += ch;
180
+ }
181
+ if (stars === 0)
182
+ return cmd === literal;
183
+ if (stars === 1 && pattern.endsWith(" .*"))
184
+ pattern = `${pattern.slice(0, -3)}( .*)?`;
185
+ return new RegExp(`^${pattern}$`, "s").test(cmd);
186
+ }
187
+ /**
188
+ * Would one of `denyRules` block the worker's OWN push? (BAPI-1397, F7)
189
+ *
190
+ * Replaces the BAPI-1020 branch-NAME check, which warned for any `main*` or
191
+ * `epic/` branch. That was wrong in practice: the correction worker's push to
192
+ * `epic/` succeeded despite the warning, because the provisioned
193
+ * `Bash(git push:* epic/*)` rule does not match the push command the worker
194
+ * actually runs. This builds those real push commands and tests them against the
195
+ * deny rules with Claude Code's own matching grammar, so it warns only when a
196
+ * rule really matches.
197
+ */
198
+ export function workerOwnPushDeniedByRules(branch, denyRules) {
199
+ const commands = workerOwnBranchPushCommands(branch);
200
+ return denyRules.some((rule) => typeof rule === "string" &&
201
+ commands.some((command) => claudeBashRuleMatchesCommand(rule, command)));
202
+ }
203
+ /**
204
+ * Would the executor's OWN deny rules block this branch's push? Kept for callers
205
+ * of the BAPI-1020 name; it now asks the command-form question of
206
+ * {@link workerOwnPushDeniedByRules} against {@link executorDenyRules}.
207
+ */
208
+ export function workerBranchCollidesWithDenyGlob(branch) {
209
+ return workerOwnPushDeniedByRules(branch, executorDenyRules({ baseBranch: "", worktreePath: "", homedir: "" }));
140
210
  }
141
211
  function asObject(value) {
142
212
  return value !== null && typeof value === "object" && !Array.isArray(value)
@@ -363,8 +433,22 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
363
433
  worktreePath,
364
434
  homedir: deps.homedir(),
365
435
  };
436
+ // BAPI-1397 (F7): the deny list the worktree will actually carry — the executor
437
+ // rules plus any operator-authored entries already in the file. Captured from
438
+ // the merge; when the merge never runs (a malformed file is left untouched) the
439
+ // executor's own rules stand in.
440
+ let effectiveDeny = executorDenyRules(inputs);
366
441
  const result = await provisionClaudeSettingsForWorktree(worktreePath, (existing) => {
367
442
  let merged = mergeExecutorDenySettings(existing, inputs);
443
+ const mergedDeny = merged.permissions?.deny;
444
+ if (Array.isArray(mergedDeny))
445
+ effectiveDeny = mergedDeny;
446
+ // BAPI-1397 (F6) — a worker session must never write the OWNER's Claude
447
+ // auto-memory. Merged through the same malformed-safe write as the deny
448
+ // rules, so a malformed settings file is still never clobbered and a
449
+ // re-provisioned worktree is unchanged. The job runner also sets the
450
+ // environment form at spawn, so a failed write here does not re-enable it.
451
+ merged = mergeClaudeSettingsWithAutoMemoryDisabled(merged);
368
452
  // BAPI-1020 — the worker guard is registered UNCONDITIONALLY, before and
369
453
  // independently of the conditional fallback below. PreToolUse hooks fire in
370
454
  // EVERY permission mode, bypass included, which is what makes the guard hold
@@ -380,14 +464,16 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
380
464
  // may already exist from an earlier provisioning, so it runs even when the
381
465
  // merge-write was refused or failed.
382
466
  const exclude = await provisionWorktreeSettingsExclude(worktreePath, deps);
383
- // BAPI-1020 — the branch-shaped-glob collision check. Advisory rather than
384
- // blocking, matching this function's established fail-open posture: the worker
385
- // can still do most of its job, and killing the spawn would trade a push failure
386
- // for a total one. It is loud where it matters — the warning names the condition
387
- // at PROVISIONING time, so an operator reads it before the push fails, instead of
388
- // decoding a deny rule from a push error twenty minutes later.
467
+ // BAPI-1020 / BAPI-1397 (F7) — would a deny rule block the worker's own push?
468
+ // Advisory rather than blocking, matching this function's established
469
+ // fail-open posture: the worker can still do most of its job, and killing the
470
+ // spawn would trade a push failure for a total one. It is loud where it
471
+ // matters — at PROVISIONING time, before the push fails. Since BAPI-1397 it is
472
+ // decided by matching the worker's REAL push commands against the deny rules
473
+ // with Claude Code's grammar, not by the branch name's shape: an `epic/` branch
474
+ // alone no longer warns, because no provisioned rule matches its push.
389
475
  const collidingBranch = typeof options.workerBranch === "string" &&
390
- workerBranchCollidesWithDenyGlob(options.workerBranch);
476
+ workerOwnPushDeniedByRules(options.workerBranch, effectiveDeny);
391
477
  // `blocking` and `advisory` are kept apart on purpose. A blocking warning
392
478
  // flips `ok` (the established contract, which existing callers and tests
393
479
  // read); an advisory one rides along on `ok: true` so a degraded-but-
@@ -403,11 +489,12 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
403
489
  advisory.push(exclude.warning);
404
490
  if (collidingBranch) {
405
491
  // The branch NAME is not echoed: this text travels into worker logs, and the
406
- // condition ("matches a branch-shaped deny glob") is the whole diagnostic.
407
- advisory.push("the job branch matches a branch-shaped executor deny glob (git push to main/ or " +
408
- "epic/); the worker's own push would be denied by settings permissions.deny, " +
409
- "which the PreToolUse guard cannot override — re-cut the branch before relying " +
410
- "on this worktree");
492
+ // condition ("a deny rule matches the worker's own push") is the whole
493
+ // diagnostic.
494
+ advisory.push("a settings permissions.deny rule matches the worker's own git push command for " +
495
+ "the job branch; the worker's own push would be denied by settings " +
496
+ "permissions.deny, which the PreToolUse guard cannot override — remove the rule " +
497
+ "or re-cut the branch before relying on this worktree");
411
498
  }
412
499
  if (blocking.length > 0) {
413
500
  return { ok: false, warning: [...blocking, ...advisory].join("; ") };
@@ -188,7 +188,7 @@ export function startExecutorSleepAssertion(deps) {
188
188
  if (releasing)
189
189
  return;
190
190
  releasing = true;
191
- controller.requestTermination();
191
+ controller.requestTermination("executor_abort");
192
192
  // Await the child so a caller that releases and then exits does not race the
193
193
  // teardown, and so `pmset -g assertions` is observably clean afterwards.
194
194
  await settled;
@@ -2,7 +2,8 @@ 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";
7
8
  import { mkdtemp, rm } from "node:fs/promises";
8
9
  import { tmpdir } from "node:os";
@@ -11,7 +12,7 @@ import { spawnSync } from "node:child_process";
11
12
  import { defaultResolveBinPath } from "../start-tickets-conductor.js";
12
13
  import { CORRECTION_INTENT_CAPABILITY } from "./correction-dispatch.js";
13
14
  import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
14
- import { FOCUSED_REBASE_CAPABILITY, SCOPE_WAIT_CAPABILITY } from "./types.js";
15
+ import { FOCUSED_REBASE_CAPABILITY, GATE_QUALIFICATION_CAPABILITY, SCOPE_WAIT_CAPABILITY, } from "./types.js";
15
16
  /**
16
17
  * Environment override for the deny probe's headless timeout (BAPI-722).
17
18
  *
@@ -67,7 +68,7 @@ export function resolveDenyProbeTimeoutMs(env = {}) {
67
68
  * hang, a control refusal, and a degraded guess all report `layer: "none"` for the
68
69
  * posture they happened under, and none of them is cached.
69
70
  */
70
- function isCacheableDenyResult(result) {
71
+ function isVerifiedDenyResult(result) {
71
72
  if (result.enforced !== true || result.layer === "none")
72
73
  return false;
73
74
  const postures = result.postures;
@@ -75,17 +76,38 @@ function isCacheableDenyResult(result) {
75
76
  return true;
76
77
  return postures.every((p) => p.enforced === true && p.layer !== "none");
77
78
  }
79
+ function isCacheableDenyResult(result) {
80
+ return result.degraded !== true && isVerifiedDenyResult(result);
81
+ }
78
82
  /** Create a fresh, empty executor-scoped deny-probe cache. */
79
83
  export function createDenyProbeCache() {
80
84
  let cached;
85
+ let failedProbe = false;
86
+ let retryAt = 0;
87
+ let failures = 0;
88
+ const schedule = (nowMs) => {
89
+ failures += 1;
90
+ retryAt = nowMs + Math.min(30, 2 ** Math.min(failures - 1, 5)) * 60_000;
91
+ };
81
92
  return {
82
- get: () => cached,
83
- set: (result) => {
84
- if (isCacheableDenyResult(result))
85
- cached = result;
93
+ get: (nowMs = Date.now()) => cached && (isCacheableDenyResult(cached) || nowMs < retryAt) ? cached : undefined,
94
+ failedProbeDue: (nowMs) => failedProbe && nowMs < retryAt,
95
+ recordFailedProbe: (nowMs) => { failedProbe = true; schedule(nowMs); },
96
+ set: (result, nowMs = Date.now()) => {
97
+ failedProbe = false;
98
+ cached = result;
99
+ if (isCacheableDenyResult(result)) {
100
+ failures = 0;
101
+ retryAt = 0;
102
+ }
103
+ else
104
+ schedule(nowMs);
86
105
  },
87
106
  reset: () => {
88
107
  cached = undefined;
108
+ failedProbe = false;
109
+ failures = 0;
110
+ retryAt = 0;
89
111
  },
90
112
  };
91
113
  }
@@ -123,20 +145,34 @@ export async function evaluateCompletionEvidenceQualification(adapter, deps) {
123
145
  if (probed === null) {
124
146
  return { qualified: false, refusal: "runtime identity probe did not run", runtime: null };
125
147
  }
126
- const declared = capability.value.runtime;
148
+ const declared = capability.value.predicate;
127
149
  const mismatches = [];
128
- for (const key of ["adapter", "cli_version", "platform", "event_schema"]) {
129
- if (probed[key] !== declared[key]) {
130
- mismatches.push(`${key} ${String(probed[key]).slice(0, 40)} != ${declared[key]}`);
131
- }
150
+ if (probed.adapter !== declared.adapter) {
151
+ mismatches.push(`adapter ${String(probed.adapter).slice(0, 40)} != ${declared.adapter}`);
152
+ }
153
+ if (probed.event_schema !== declared.event_schema) {
154
+ mismatches.push(`event_schema ${String(probed.event_schema).slice(0, 40)} != ${declared.event_schema}`);
132
155
  }
133
156
  if (mismatches.length > 0) {
134
157
  return {
135
158
  qualified: false,
136
- refusal: bounded(`probed runtime differs from the approved identity: ${mismatches.join("; ")}`),
159
+ refusal: bounded(`probed runtime differs from the declared predicate: ${mismatches.join("; ")}`),
137
160
  runtime: probed,
138
161
  };
139
162
  }
163
+ // Fixed reasons below: the probed version and platform are never echoed, because
164
+ // an unparseable value is arbitrary probe output.
165
+ const version = parseCliVersion(probed.cli_version);
166
+ const minimum = parseCliVersion(declared.min_cli_version);
167
+ if (version === null || minimum === null) {
168
+ return { qualified: false, refusal: "probed CLI version is not a recognized version", runtime: probed };
169
+ }
170
+ if (!cliVersionAtLeast(version, minimum)) {
171
+ return { qualified: false, refusal: "probed CLI version is older than the minimum", runtime: probed };
172
+ }
173
+ if (!declared.platforms.includes(probed.platform)) {
174
+ return { qualified: false, refusal: "probed platform is not a supported platform", runtime: probed };
175
+ }
140
176
  if (!runtimeMatchesSupported(probed)) {
141
177
  return {
142
178
  qualified: false,
@@ -144,7 +180,12 @@ export async function evaluateCompletionEvidenceQualification(adapter, deps) {
144
180
  runtime: probed,
145
181
  };
146
182
  }
147
- return { qualified: true, refusal: null, runtime: probed };
183
+ // Normalized (suffix stripped) so the retained identity and the envelope carry one form.
184
+ const normalized = normalizeCliVersion(probed.cli_version);
185
+ if (normalized === null) {
186
+ return { qualified: false, refusal: "probed CLI version is not a recognized version", runtime: probed };
187
+ }
188
+ return { qualified: true, refusal: null, runtime: { ...probed, cli_version: normalized } };
148
189
  }
149
190
  /** Fail-fast report shape for a preflight that refused before probing. */
150
191
  function refusedPreflight(fatalFindings, warnings, adapter) {
@@ -160,23 +201,29 @@ function refusedPreflight(fatalFindings, warnings, adapter) {
160
201
  adapter,
161
202
  };
162
203
  }
163
- const CORRECTION_HOOK_FIXTURE_ID = "correction-entry-nested-v1";
204
+ export const CORRECTION_HOOK_FIXTURE_ID = "correction-entry-nested-v2";
205
+ const spawnCorrectionHook = (args, input, env) => {
206
+ const result = spawnSync(process.execPath, args, { input, encoding: "utf8", env, timeout: 5000 });
207
+ return { status: result.status, ...(result.error ? { error: result.error } : {}) };
208
+ };
164
209
  /** Exercise the packaged hook process, including a nested-agent entry attempt. */
165
- export function probeInstalledCorrectionHook(worktreePath, binPath = defaultResolveBinPath("worker-guard-hook-bin.js")) {
210
+ export function probeInstalledCorrectionHook(worktreePath, binPath = defaultResolveBinPath("worker-guard-hook-bin.js"), run = spawnCorrectionHook) {
166
211
  const args = [binPath, "--worktree-root", worktreePath,
167
212
  "--correction-intent-version", "1"];
168
213
  const env = { ...process.env, BAPI_CORRECTION_INTENT_VERSION: "1" };
169
214
  const fixtures = [
170
215
  { payload: { tool_name: "Skill", tool_input: { skill: "bridge-implement-ticket" } }, status: 2 },
171
216
  { 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 },
217
+ { payload: { tool_name: "Read", tool_input: { file_path: "commands/src/implement-ticket.md" } }, status: 0 },
218
+ {
219
+ payload: { tool_name: "Bash", tool_input: { command: 'claude -p "/implement-ticket BAPI-1"' } },
220
+ status: 2,
221
+ },
173
222
  { payload: { tool_name: "mcp__bridge_api__request_plan_generation", tool_input: {} }, status: 2 },
174
223
  { payload: { tool_name: "mcp__bridge_api__get_plan", tool_input: { ticket_number: "BAPI-1" } }, status: 0 },
175
224
  ];
176
225
  return fixtures.every(({ payload, status }) => {
177
- const result = spawnSync(process.execPath, args, {
178
- input: JSON.stringify(payload), encoding: "utf8", env, timeout: 5000,
179
- });
226
+ const result = run(args, JSON.stringify(payload), env);
180
227
  return result.error === undefined && result.status === status;
181
228
  });
182
229
  }
@@ -386,7 +433,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
386
433
  let denyEnforced = false;
387
434
  try {
388
435
  const cache = seams.bypassDenyProbeCache === true ? undefined : seams.denyProbeCache;
389
- const cachedDeny = cache?.get();
436
+ if (cache?.failedProbeDue?.(deps.now?.() ?? Date.now())) {
437
+ fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
438
+ throw new Error("cached deny probe failure");
439
+ }
440
+ const cachedDeny = cache?.get(deps.now?.() ?? Date.now());
390
441
  let deny;
391
442
  if (cachedDeny !== undefined) {
392
443
  deny = cachedDeny;
@@ -395,7 +446,7 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
395
446
  // An explicitly injected probe still wins, so every existing test seam
396
447
  // keeps working unchanged across the extraction.
397
448
  deny = await seams.runDenyPreflight({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
398
- seams.denyProbeCache?.set(deny);
449
+ seams.denyProbeCache?.set(deny, deps.now?.() ?? Date.now());
399
450
  }
400
451
  else {
401
452
  // Otherwise the ADAPTER's declared deny hook probes. The verdict is mapped
@@ -411,8 +462,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
411
462
  if (denyCapability?.supported !== true) {
412
463
  throw new Error("adapter declares no deny-enforcement capability");
413
464
  }
465
+ // The probe gets a worker-shaped environment so a nested-session variable in
466
+ // the executor's own process cannot change its verdict (BAPI-1386).
414
467
  const verdict = await denyCapability.value.probeEnforcement({
415
468
  timeoutMs: resolveDenyProbeTimeoutMs(deps.env),
469
+ env: buildHeadlessAgentEnv(deps.env),
416
470
  });
417
471
  deny = {
418
472
  enforced: verdict.enforced,
@@ -423,10 +477,10 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
423
477
  };
424
478
  // `set` itself refuses anything that is not a genuine success, so a failure
425
479
  // cannot become sticky even if this call site were later moved.
426
- seams.denyProbeCache?.set(deny);
480
+ seams.denyProbeCache?.set(deny, deps.now?.() ?? Date.now());
427
481
  }
428
- denyEnforced = deny.enforced === true;
429
- if (!deny.enforced) {
482
+ denyEnforced = isVerifiedDenyResult(deny);
483
+ if (!denyEnforced) {
430
484
  fatalFindings.push(`deny-layer enforcement not verified (enforced=false, layer=${deny.layer}); refusing to claim`);
431
485
  }
432
486
  else if (deny.layer === "none") {
@@ -436,7 +490,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
436
490
  }
437
491
  }
438
492
  catch {
439
- fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
493
+ if (!fatalFindings.includes("deny-layer enforcement probe failed; refusing to claim")) {
494
+ fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
495
+ if (!seams.bypassDenyProbeCache)
496
+ seams.denyProbeCache?.recordFailedProbe?.(deps.now?.() ?? Date.now());
497
+ }
440
498
  }
441
499
  // --- Advisory MCP-shadowing inspection (BAPI-727 → BAPI-790 → BAPI-781) --
442
500
  // This was a refusal under BAPI-727, because a `bridge-api` entry in the
@@ -486,6 +544,13 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
486
544
  if (!completion.qualified && completion.refusal !== null) {
487
545
  warnings.push(`completion evidence not advertised: ${completion.refusal}`);
488
546
  }
547
+ // One notice per qualifying collection, built only from validated fields: the
548
+ // adapter id the predicate matched, the normalized version, and a closed platform id.
549
+ const notices = [];
550
+ if (completion.qualified && completion.runtime !== null) {
551
+ notices.push(`completion evidence advertised: ${completion.runtime.adapter} ` +
552
+ `${completion.runtime.cli_version} ${completion.runtime.platform}`);
553
+ }
489
554
  let correction = {
490
555
  qualified: false, evidence: null,
491
556
  };
@@ -504,6 +569,7 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
504
569
  ok: fatalFindings.length === 0,
505
570
  fatalFindings,
506
571
  warnings,
572
+ notices,
507
573
  agentVersion,
508
574
  wtVersion,
509
575
  diskFreeGb,
@@ -547,6 +613,13 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
547
613
  * once ordinary preflight permits claiming — independently of completion-evidence
548
614
  * qualification. The completion tokens keep their exact conditional behavior and
549
615
  * order; the focused token is appended after them.
616
+ *
617
+ * BAPI-1391 adds two things. `gate_qualification.v1` is appended to
618
+ * `capabilities` ONLY on a repository-wide executor (see the comment at the
619
+ * token). And `job_types` is emitted, by conditional spread under the same
620
+ * "truly absent" rule, ONLY when `options.jobTypes` is set and non-empty; with
621
+ * it unset every manifest keeps its exact pre-BAPI-1391 shape apart from that
622
+ * one capability token on repository-wide executors.
550
623
  */
551
624
  export function buildClaimManifest(report, options, freeSlots) {
552
625
  return {
@@ -579,6 +652,19 @@ export function buildClaimManifest(report, options, freeSlots) {
579
652
  SCOPE_WAIT_CAPABILITY,
580
653
  ...(report.correctionIntentQualified === true ? [CORRECTION_INTENT_CAPABILITY] : []),
581
654
  ...(report.correctionIntentQualified === true ? ["ci_evidence.v1"] : []),
655
+ // BAPI-1391: repository-wide executors only. A `gate_qualification` row is
656
+ // run-less (no `epic_run_id`), and run-less rows pass the server's
657
+ // run-scope filter, so a run-scoped executor advertising this token could
658
+ // pick up repository-level qualification work it was never dedicated to.
659
+ // Gating the token on `repoWide` keeps that work on the executor whose
660
+ // operator chose the whole repository.
661
+ ...(options.repoWide === true ? [GATE_QUALIFICATION_CAPABILITY] : []),
582
662
  ],
663
+ // BAPI-1391: an optional job-type filter, present only when configured. An
664
+ // older server rejects the key with a 422, so it must never appear by
665
+ // default.
666
+ ...(options.jobTypes !== undefined && options.jobTypes.length > 0
667
+ ? { job_types: [...options.jobTypes] }
668
+ : {}),
583
669
  };
584
670
  }
@@ -266,6 +266,15 @@ export function buildCleanupVerdict(verdict, survivors, reasons) {
266
266
  export function unconfirmedCleanup(reason) {
267
267
  return buildCleanupVerdict("unconfirmed", 0, [reason]);
268
268
  }
269
+ /**
270
+ * The verdict when no sweep was attempted because ownership could not be
271
+ * proven (invalid token, malformed or unreadable ownership record). The extra
272
+ * `not_run` reason keeps this shape distinct from a completed sweep that merely
273
+ * could not verify ownership, so it is never treated as continuable.
274
+ */
275
+ function noSweepCleanup() {
276
+ return buildCleanupVerdict("unconfirmed", 0, ["ownership_unverifiable", "not_run"]);
277
+ }
269
278
  /** Hard cap on distinct processes tracked by one sweep or one descendant tracker. */
270
279
  const MAX_TRACKED_PROCESSES = 1024;
271
280
  /** Confirmation enumerations after SIGKILL (a killed process can take a moment to leave). */
@@ -302,8 +311,14 @@ export function createDescendantTracker(options) {
302
311
  else
303
312
  childrenOf.set(proc.ppid, [proc]);
304
313
  }
305
- // Seeds: the root (when its identity matches) and every tracked member
306
- // still live with the SAME start time. A reused pid is not a seed.
314
+ // Prune first: a member survives only while its exact (pid, startedAt)
315
+ // is still in this successful listing. Exited descendants must not
316
+ // exhaust the cap, and a reused pid loses its old identity.
317
+ for (const [key, member] of members) {
318
+ const live = byPid.get(member.pid);
319
+ if (!live || live.startedAt !== member.startedAt)
320
+ members.delete(key);
321
+ }
307
322
  const frontier = [];
308
323
  const rootNow = byPid.get(rootPid);
309
324
  if (rootNow) {
@@ -312,12 +327,11 @@ export function createDescendantTracker(options) {
312
327
  if (rootNow.startedAt === rootStartedAt)
313
328
  frontier.push(rootPid);
314
329
  }
315
- for (const member of members.values()) {
316
- const live = byPid.get(member.pid);
317
- if (live && live.startedAt === member.startedAt)
318
- frontier.push(member.pid);
319
- }
330
+ for (const member of members.values())
331
+ frontier.push(member.pid);
320
332
  const visited = new Set(frontier);
333
+ let liveCount = members.size;
334
+ let capHit = false;
321
335
  while (frontier.length > 0) {
322
336
  const parent = frontier.pop();
323
337
  for (const child of childrenOf.get(parent) ?? []) {
@@ -326,8 +340,9 @@ export function createDescendantTracker(options) {
326
340
  visited.add(child.pid);
327
341
  const key = keyOf(child);
328
342
  if (!members.has(key)) {
343
+ liveCount += 1;
329
344
  if (members.size >= MAX_TRACKED_PROCESSES) {
330
- overflow = true;
345
+ capHit = true;
331
346
  continue;
332
347
  }
333
348
  members.set(key, { pid: child.pid, startedAt: child.startedAt });
@@ -335,6 +350,15 @@ export function createDescendantTracker(options) {
335
350
  frontier.push(child.pid);
336
351
  }
337
352
  }
353
+ if (capHit && !overflow) {
354
+ overflow = true;
355
+ try {
356
+ options.onOverflow?.(liveCount);
357
+ }
358
+ catch {
359
+ /* a diagnostic failure never changes tracking or the verdict */
360
+ }
361
+ }
338
362
  }
339
363
  catch {
340
364
  /* sampling is best-effort; the sweep's own verdict stays authoritative */
@@ -399,7 +423,7 @@ export async function runOwnedProcessSweep(options) {
399
423
  const maxRounds = Math.max(quietRounds, options.maxRounds ?? 200);
400
424
  const roundDelayMs = Math.max(0, options.roundDelayMs ?? 15);
401
425
  if (!isOwnershipToken(token))
402
- return unconfirmedCleanup("ownership_unverifiable");
426
+ return noSweepCleanup();
403
427
  const reasons = new Set();
404
428
  if (options.trackerOverflowed)
405
429
  reasons.add("ownership_unverifiable");
@@ -700,8 +724,8 @@ async function ownerMayBeAlive(inspector, pid) {
700
724
  * interrupted before its own sweep finished. The record holds the exact token,
701
725
  * so ownership is verified and the full sweep runs. Only the job id and the
702
726
  * verdict are logged — never the token. A malformed record cannot prove
703
- * ownership: it is reported `unconfirmed / ownership_unverifiable` and removed
704
- * without signalling anything. Never throws.
727
+ * ownership: it is reported `unconfirmed` with `ownership_unverifiable` and
728
+ * `not_run` (no sweep ran) and removed without signalling anything. Never throws.
705
729
  */
706
730
  export async function recoverInterruptedOwnership(options) {
707
731
  const { stateDir, inspector, deps, fsDeps, deadlineMs } = options;
@@ -712,7 +736,7 @@ export async function recoverInterruptedOwnership(options) {
712
736
  let verdict;
713
737
  try {
714
738
  if (record === null) {
715
- verdict = unconfirmedCleanup("ownership_unverifiable");
739
+ verdict = noSweepCleanup();
716
740
  }
717
741
  else if (options.hostDigest !== undefined && record.hostDigest !== options.hostDigest) {
718
742
  continue;