@bridge_gpt/mcp-server 0.2.51 → 0.2.53

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 (79) hide show
  1. package/README.md +59 -13
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +7 -5
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +232 -5
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/deny-enforcement-preflight.js +107 -10
  10. package/build/conductor/doctor.js +428 -5
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/local-merge.js +170 -11
  13. package/build/conductor/readiness-cli.js +152 -0
  14. package/build/conductor/readiness-sections.js +666 -0
  15. package/build/conductor/readiness.js +710 -0
  16. package/build/conductor/tools.js +56 -3
  17. package/build/conductor-bin.js +21 -17
  18. package/build/connect-bitbucket-api.js +370 -0
  19. package/build/connect-bitbucket.js +437 -0
  20. package/build/docs.generated.js +1 -1
  21. package/build/doctor.js +40 -1
  22. package/build/drive-epic.js +423 -11
  23. package/build/env-file-link.js +164 -0
  24. package/build/epic-integration-pr.js +10 -0
  25. package/build/executor/cli.js +41 -6
  26. package/build/executor/deps.js +5 -1
  27. package/build/executor/env-file-guard.js +113 -0
  28. package/build/executor/env.js +78 -1
  29. package/build/executor/heartbeat.js +9 -0
  30. package/build/executor/http-client.js +90 -22
  31. package/build/executor/job-errors.js +43 -2
  32. package/build/executor/job-runner.js +130 -28
  33. package/build/executor/merge-job.js +67 -16
  34. package/build/executor/permissions.js +106 -0
  35. package/build/executor/preflight.js +38 -13
  36. package/build/executor/resume-pre-spawn.js +2 -1
  37. package/build/executor/runner.js +175 -4
  38. package/build/executor/service-unit.js +15 -0
  39. package/build/executor/terminal-mutation.js +22 -1
  40. package/build/executor/types.js +86 -0
  41. package/build/executor/worker-command.js +21 -5
  42. package/build/executor/worker-guard-hook.js +939 -0
  43. package/build/executor/worker-log.js +56 -0
  44. package/build/executor/worktree.js +11 -0
  45. package/build/git-reachability.js +147 -0
  46. package/build/index.js +4734 -4270
  47. package/build/install-bridge.js +95 -0
  48. package/build/install-doctor.js +154 -2
  49. package/build/pipelines.generated.js +6 -4
  50. package/build/plan-epic-conductor-eligibility.js +37 -7
  51. package/build/plane/alembic-head.js +40 -11
  52. package/build/plane/build-freshness.js +22 -11
  53. package/build/plane/cli.js +78 -15
  54. package/build/plane/defaults.js +165 -0
  55. package/build/plane/manifest.js +63 -8
  56. package/build/plane/member-logs.js +6 -0
  57. package/build/plane/member-roster.js +195 -11
  58. package/build/plane/preflight.js +402 -44
  59. package/build/plane/shutdown.js +25 -3
  60. package/build/plane/status.js +11 -0
  61. package/build/plane/supervisor.js +343 -14
  62. package/build/plane/test-fakes.js +43 -0
  63. package/build/plane/types.js +118 -11
  64. package/build/pr-base-contract.js +20 -0
  65. package/build/readiness-check.js +412 -0
  66. package/build/readme.generated.js +1 -1
  67. package/build/review-synthesis-config.js +60 -0
  68. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  69. package/build/setup-epic.js +560 -139
  70. package/build/sfcc/log-query.js +2 -1
  71. package/build/start-tickets-conductor.js +11 -2
  72. package/build/start-tickets.js +69 -2
  73. package/build/version.generated.js +3 -3
  74. package/build/worker-containment-diagnostic.js +97 -0
  75. package/build/worker-guard-hook-bin.js +6 -0
  76. package/docs/CONDUCTOR.md +27 -0
  77. package/docs/install/mcp-tool-integrations.md +3 -2
  78. package/package.json +4 -3
  79. package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
@@ -23,11 +23,9 @@
23
23
  */
24
24
  import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
25
25
  import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
26
- import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "../epic-integration-pr.js";
26
+ import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
27
27
  import { secretFreeErrorMessage } from "./job-errors.js";
28
28
  import { resolveExecutorJobBaseBranch } from "./base-branch.js";
29
- /** `epic/<KEY>` prefix a child PR's base branch carries under BAPI-949/BAPI-950. */
30
- const EPIC_BRANCH_PREFIX = "epic/";
31
29
  function positiveIntOrNull(value) {
32
30
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
33
31
  }
@@ -42,8 +40,21 @@ function asString(value) {
42
40
  * Extract and validate merge fields. Prefers the claimed-job `expected_head_sha`
43
41
  * over any payload copy, validates `pr_number` as a positive integer, and fails
44
42
  * (before any subprocess) when the PR number or expected head SHA is absent.
43
+ *
44
+ * BAPI-1021 (AC-11): also resolves the two branch-ref expectations the local
45
+ * merge executor's ref-verification guard needs, and fails closed — before any
46
+ * `makeLocalMergeExecutor` construction or `runCommand` seam is reached — when
47
+ * either cannot be resolved:
48
+ *
49
+ * - `expectedBaseBranch` via {@link resolveExecutorJobBaseBranch}, the SAME
50
+ * resolver every other PR-producing job type uses, with `repositoryBaseBranch`
51
+ * as its documented legacy fallback for a payload omitting `base_branch`.
52
+ * - `expectedHeadBranch` EXCLUSIVELY from a trimmed, nonblank
53
+ * `job.expected_branch` — never from payload data, which is untrusted and
54
+ * job-type-generic. There is no fallback: a merge job with no known expected
55
+ * head branch cannot be ref-verified.
45
56
  */
46
- export function readMergeJobPayloadFields(job) {
57
+ export function readMergeJobPayloadFields(job, repositoryBaseBranch) {
47
58
  const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
48
59
  const prNumber = positiveIntOrNull(payload.pr_number);
49
60
  if (prNumber === null) {
@@ -53,6 +64,15 @@ export function readMergeJobPayloadFields(job) {
53
64
  if (!expectedHeadSha) {
54
65
  return { ok: false, error: "merge job requires an expected_head_sha (top-level or payload)" };
55
66
  }
67
+ const baseResolution = resolveExecutorJobBaseBranch(job, repositoryBaseBranch);
68
+ if (!baseResolution.ok) {
69
+ return { ok: false, error: baseResolution.error };
70
+ }
71
+ const expectedBaseBranch = baseResolution.baseBranch;
72
+ const expectedHeadBranch = asString(job.expected_branch ?? undefined);
73
+ if (!expectedHeadBranch) {
74
+ return { ok: false, error: "merge job requires a nonblank expected_branch" };
75
+ }
56
76
  const method = resolveLocalMergeMethod(payload.method);
57
77
  const requiredChecks = Array.isArray(payload.required_checks)
58
78
  ? payload.required_checks.filter((c) => typeof c === "string")
@@ -62,7 +82,17 @@ export function readMergeJobPayloadFields(job) {
62
82
  const ciWaitPollIntervalMs = nonNegativeIntOrUndefined(payload.ci_wait_poll_interval_ms);
63
83
  return {
64
84
  ok: true,
65
- fields: { prNumber, expectedHeadSha, method, requiredChecks, actionKey, ciWaitTimeoutMs, ciWaitPollIntervalMs },
85
+ fields: {
86
+ prNumber,
87
+ expectedHeadSha,
88
+ method,
89
+ requiredChecks,
90
+ actionKey,
91
+ ciWaitTimeoutMs,
92
+ ciWaitPollIntervalMs,
93
+ expectedBaseBranch,
94
+ expectedHeadBranch,
95
+ },
66
96
  };
67
97
  }
68
98
  /** Construct the {@link ConductorMergeRequest} for the local merge executor. */
@@ -124,10 +154,18 @@ export function buildMergeJobResult(response, fields) {
124
154
  * safe to re-fire the same merge later.
125
155
  * - {@link MERGE_CONFLICT}: needs a rebase before it can merge.
126
156
  * - {@link MERGE_FAILED}: deterministic other failure (bad state, missing input).
157
+ * - {@link MERGE_REF_MISMATCH}: BAPI-1021 — a DELIBERATE, terminal safety
158
+ * refusal: the PR's current base or head branch did not match the caller's
159
+ * expectation, so no provider merge was attempted. This is neither
160
+ * retryable (the mismatch will not resolve itself) nor a rebase target (it
161
+ * is not a conflict) — see `_merge_entry_failure_is_retryable` and
162
+ * `_merge_entry_failure_requires_rebase` on the backend gate-retry path,
163
+ * which both explicitly exclude it.
127
164
  */
128
165
  export const MERGE_RETRYABLE = "MergeRetryable";
129
166
  export const MERGE_CONFLICT = "MergeConflict";
130
167
  export const MERGE_FAILED = "MergeFailed";
168
+ export const MERGE_REF_MISMATCH = "MergeRefMismatch";
131
169
  /** Reasons whose failure is transient/infrastructure and safe to retry. */
132
170
  const RETRYABLE_MERGE_REASONS = new Set([
133
171
  "gh_pr_view_timeout",
@@ -151,10 +189,18 @@ const RETRYABLE_MERGE_REASONS = new Set([
151
189
  * `ci_poll_`-prefixed reason is retryable — a prefix match keeps unbounded HTTP-status
152
190
  * variants covered so a distinct reason never accidentally downgrades to
153
191
  * {@link MERGE_FAILED}. Conflict classification is checked first so it is never masked.
192
+ *
193
+ * BAPI-1021: `ref_mismatch` is classified BEFORE conflict, retryable-prefix, and
194
+ * generic-failure handling, and is deliberately NEVER added to
195
+ * {@link RETRYABLE_MERGE_REASONS} — it is a terminal safety refusal, not a
196
+ * transient or conflict-shaped failure, and must not be re-firable by any
197
+ * future classification change.
154
198
  */
155
199
  export function classifyMergeFailureErrorKind(response) {
156
200
  const hasConflictEvent = response.ledger_events.some((ev) => ev.type === "merge.conflict");
157
201
  const reason = asString(response.reason ?? undefined);
202
+ if (reason === "ref_mismatch")
203
+ return MERGE_REF_MISMATCH;
158
204
  if (hasConflictEvent || reason === "gh_merge_conflict")
159
205
  return MERGE_CONFLICT;
160
206
  if (reason && (reason.startsWith("ci_poll_") || RETRYABLE_MERGE_REASONS.has(reason))) {
@@ -168,13 +214,19 @@ export function classifyMergeFailureErrorKind(response) {
168
214
  * `error_message` includes only the sanitized merge status and reason (never raw
169
215
  * `gh` output). `classification` stays within the executor's vocabulary; the
170
216
  * `error_kind` is the routing signal.
217
+ *
218
+ * BAPI-1021: a {@link MERGE_REF_MISMATCH} classifies as `classification:
219
+ * "clean_exit"` rather than `"crashed"` — it is a deliberate refusal the
220
+ * executor made correctly, not a crash. Every other merge outcome keeps its
221
+ * existing `"crashed"` classification.
171
222
  */
172
223
  export function buildMergeJobFailure(response) {
173
224
  const reason = asString(response.reason ?? undefined) ?? response.status;
225
+ const errorKind = classifyMergeFailureErrorKind(response);
174
226
  return {
175
- error_kind: classifyMergeFailureErrorKind(response),
227
+ error_kind: errorKind,
176
228
  error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
177
- classification: "crashed",
229
+ classification: errorKind === MERGE_REF_MISMATCH ? "clean_exit" : "crashed",
178
230
  };
179
231
  }
180
232
  /**
@@ -186,14 +238,8 @@ export function buildMergeJobFailure(response) {
186
238
  * merge outcome). NEVER changes the returned result, the `error_kind`, or the
187
239
  * `/complete` payload — only emits a sanitized advisory line.
188
240
  */
189
- async function tryEnsurePostMergeEpicIntegrationPr(job, seams) {
190
- if (seams.repositoryBaseBranch === undefined)
191
- return;
241
+ async function tryEnsurePostMergeEpicIntegrationPr(seams, childBaseBranch) {
192
242
  try {
193
- const baseResolution = resolveExecutorJobBaseBranch(job, seams.repositoryBaseBranch);
194
- if (!baseResolution.ok)
195
- return;
196
- const childBaseBranch = baseResolution.baseBranch;
197
243
  if (!childBaseBranch.startsWith(EPIC_BRANCH_PREFIX))
198
244
  return;
199
245
  const epicKey = childBaseBranch.slice(EPIC_BRANCH_PREFIX.length);
@@ -220,7 +266,7 @@ async function tryEnsurePostMergeEpicIntegrationPr(job, seams) {
220
266
  * bounded, secret-free executor failure.
221
267
  */
222
268
  export async function runExecutorMergeJob(job, seams) {
223
- const resolution = readMergeJobPayloadFields(job);
269
+ const resolution = readMergeJobPayloadFields(job, seams.repositoryBaseBranch);
224
270
  if (!resolution.ok) {
225
271
  return {
226
272
  ok: false,
@@ -238,6 +284,11 @@ export async function runExecutorMergeJob(job, seams) {
238
284
  method: fields.method,
239
285
  ciWaitTimeoutMs: fields.ciWaitTimeoutMs,
240
286
  ciWaitPollIntervalMs: fields.ciWaitPollIntervalMs,
287
+ // BAPI-1021 (AC-11): pass the already-resolved expectations through
288
+ // unchanged so verification and the post-merge enrichment below use one
289
+ // authoritative value — neither re-resolves it independently.
290
+ expectedBaseBranch: fields.expectedBaseBranch,
291
+ expectedHeadBranch: fields.expectedHeadBranch,
241
292
  }, seams.localMergeDeps);
242
293
  let response;
243
294
  try {
@@ -257,7 +308,7 @@ export async function runExecutorMergeJob(job, seams) {
257
308
  // Build the successful outcome COMPLETELY first — enrichment can only ever
258
309
  // add an advisory log line, never alter what is returned.
259
310
  const result = buildMergeJobResult(response, fields);
260
- await tryEnsurePostMergeEpicIntegrationPr(job, seams);
311
+ await tryEnsurePostMergeEpicIntegrationPr(seams, fields.expectedBaseBranch);
261
312
  return { ok: true, result };
262
313
  }
263
314
  return { ok: false, failure: buildMergeJobFailure(response) };
@@ -52,6 +52,40 @@ import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree,
52
52
  * carries the whole Bridge-credential guarantee again, exactly as it did before
53
53
  * BAPI-731: the shim resolves the operator's real store in its own process, and
54
54
  * the model cannot read it.
55
+ *
56
+ * BAPI-1020 appends fifteen DESTRUCTIVE / DEPLOYING rules after the original nine.
57
+ * Architecture Miss 28 happened with the original set in place, because it covered
58
+ * credentials, force-push, and `rm -rf` at the filesystem root — and nothing else.
59
+ * Every external or operational system a worker could reach (`gh`, `heroku`,
60
+ * `gcloud`, `aws`, `kubectl`, `terraform`, `docker push`, `npm publish`, a tag
61
+ * push, a push to `main`) was allowed.
62
+ *
63
+ * EVERY new rule is COMMAND-scoped, and that is the Architecture Miss 23
64
+ * constraint restated above, not a stylistic preference: a deny glob must target
65
+ * the ASSET, never a directory that also holds the worker's own working state. A
66
+ * `Bash(<cmd>:...)` rule cannot shadow a path the CLI persists tool results into.
67
+ * Do not widen any of them into a directory or worktree-state path glob.
68
+ *
69
+ * Two rules a reader might expect are deliberately ABSENT because they are already
70
+ * covered: `Bash(git push:*--force*)` above satisfies the force-push clause, and
71
+ * the two `rm:*-rf` rules cover the root and home cases. The hook's
72
+ * worktree-relative `rm -rf` check COMPLEMENTS those two rather than replacing
73
+ * them — a glob cannot decide whether a relative path resolves inside the
74
+ * worktree, and no glob can decide whether a push destination is the job's own
75
+ * branch. That argument-sensitive half lives in `worker-guard-hook.ts`.
76
+ *
77
+ * `Bash(gh:*)` denies `gh` TO WORKERS ONLY, and the distinction is load-bearing —
78
+ * do not "fix" this denial when a conductor merge appears to need `gh`. Conductor
79
+ * merges run in the EXECUTOR PROCESS: `merge-job.ts` spawns no worker, and
80
+ * `conductor/local-merge.ts` runs `gh pr view` / `gh pr merge` in the executor's
81
+ * own process, which reads no worktree `settings.local.json`. The merge path is
82
+ * therefore unaffected by this rule. The mandatory worker paths already avoid `gh`
83
+ * too (push via git's credential helper; PRs via the `create_pull_request` MCP
84
+ * tool — see the note in `env.ts`). `pr-base-contract.ts`'s
85
+ * `buildPrBaseContractLaunchInstruction()` does instruct `gh pr create`, but its
86
+ * only production caller is the INTERACTIVE `start-tickets` path, which is
87
+ * provisioned through `start-tickets-conductor.ts` and never receives these rules.
88
+ * If that ever changes, the instruction — not this rule — is what moves.
55
89
  */
56
90
  export function executorDenyRules(inputs) {
57
91
  return [
@@ -64,8 +98,46 @@ export function executorDenyRules(inputs) {
64
98
  "Bash(git push:*--force*)",
65
99
  "Bash(rm:*-rf /*)",
66
100
  "Bash(rm:*-rf ~*)",
101
+ // BAPI-1020 — destructive / deploying commands, in the ticket's stated order.
102
+ "Bash(gh:*)",
103
+ "Bash(git push:*--tags*)",
104
+ "Bash(heroku:*)",
105
+ "Bash(git push:*refs/tags/*)",
106
+ "Bash(gcloud:*)",
107
+ "Bash(git push:*--delete*)",
108
+ "Bash(aws:*)",
109
+ "Bash(git push:* :*)",
110
+ "Bash(kubectl:*)",
111
+ "Bash(git push:* main*)",
112
+ "Bash(terraform:*)",
113
+ "Bash(git push:* epic/*)",
114
+ "Bash(docker:push*)",
115
+ "Bash(git push:*mcp-server/v*)",
116
+ "Bash(npm:publish*)",
67
117
  ];
68
118
  }
119
+ /**
120
+ * Branch names that a BRANCH-SHAPED deny glob would match (BAPI-1020).
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.
134
+ */
135
+ export function workerBranchCollidesWithDenyGlob(branch) {
136
+ const trimmed = branch.trim();
137
+ if (trimmed.length === 0)
138
+ return false;
139
+ return trimmed === "main" || trimmed.startsWith("main") || trimmed.startsWith("epic/");
140
+ }
69
141
  function asObject(value) {
70
142
  return value !== null && typeof value === "object" && !Array.isArray(value)
71
143
  ? value
@@ -75,6 +147,12 @@ function asObject(value) {
75
147
  * Merge the executor deny set into a settings object under `permissions.deny`,
76
148
  * preserving all existing settings and appending only rules not already present
77
149
  * (idempotent).
150
+ *
151
+ * Append-if-absent is what keeps the merge safe for a worktree provisioned by an
152
+ * EARLIER executor build: the nine pre-BAPI-1020 rules already sitting in the file
153
+ * are matched and skipped, the fifteen new ones are appended, and any unrelated
154
+ * operator-authored deny entry is preserved in place rather than reordered or
155
+ * replaced.
78
156
  */
79
157
  export function mergeExecutorDenySettings(existing, inputs) {
80
158
  const permissions = { ...asObject(existing.permissions) };
@@ -272,6 +350,12 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
272
350
  * and shield that file from worker commits via `.git/info/exclude` (BAPI-740/F2).
273
351
  * Fails open: a malformed existing file is preserved (not clobbered) and I/O
274
352
  * errors return a warning — the caller still spawns the worker.
353
+ *
354
+ * BAPI-1020: this call now provisions TWO things into the same file — the deny
355
+ * rules and the always-on deterministic worker-guard PreToolUse hook. They are one
356
+ * write because they are one worktree-provisioning boundary, not because they are
357
+ * one mechanism: the rules deny command FAMILIES, the guard denies destructive
358
+ * ARGUMENTS, and neither can express the other's half.
275
359
  */
276
360
  export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
277
361
  const inputs = {
@@ -281,6 +365,12 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
281
365
  };
282
366
  const result = await provisionClaudeSettingsForWorktree(worktreePath, (existing) => {
283
367
  let merged = mergeExecutorDenySettings(existing, inputs);
368
+ // BAPI-1020 — the worker guard is registered UNCONDITIONALLY, before and
369
+ // independently of the conditional fallback below. PreToolUse hooks fire in
370
+ // EVERY permission mode, bypass included, which is what makes the guard hold
371
+ // even under the `skip_permissions` revert value. The merge is idempotent
372
+ // per command, so a re-provisioned worktree does not accumulate duplicates.
373
+ merged = mergeClaudeSettingsWithCommandHook(merged, options.workerGuardHookCommand, [], { enablePreToolUse: true, preToolUseMatcher: DEFAULT_PRE_TOOL_USE_MATCHER });
284
374
  if (options.preToolUseFallbackCommand) {
285
375
  merged = mergeClaudeSettingsWithCommandHook(merged, options.preToolUseFallbackCommand, [], { enablePreToolUse: true, preToolUseMatcher: DEFAULT_PRE_TOOL_USE_MATCHER });
286
376
  }
@@ -290,6 +380,14 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
290
380
  // may already exist from an earlier provisioning, so it runs even when the
291
381
  // merge-write was refused or failed.
292
382
  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.
389
+ const collidingBranch = typeof options.workerBranch === "string" &&
390
+ workerBranchCollidesWithDenyGlob(options.workerBranch);
293
391
  // `blocking` and `advisory` are kept apart on purpose. A blocking warning
294
392
  // flips `ok` (the established contract, which existing callers and tests
295
393
  // read); an advisory one rides along on `ok: true` so a degraded-but-
@@ -303,6 +401,14 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
303
401
  blocking.push(exclude.warning);
304
402
  else if (exclude.warning)
305
403
  advisory.push(exclude.warning);
404
+ if (collidingBranch) {
405
+ // 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");
411
+ }
306
412
  if (blocking.length > 0) {
307
413
  return { ok: false, warning: [...blocking, ...advisory].join("; ") };
308
414
  }
@@ -46,9 +46,25 @@ export function resolveDenyProbeTimeoutMs(env = {}) {
46
46
  }
47
47
  return parsed;
48
48
  }
49
- /** True only for a result safe to reuse: genuinely enforced by a real layer. */
49
+ /**
50
+ * True only for a result safe to reuse: genuinely enforced by a real layer, for
51
+ * EVERY posture that was probed.
52
+ *
53
+ * BAPI-1020 added the per-posture clause. The combined result already refuses to
54
+ * report `enforced: true` unless every required posture passed, but caching is the
55
+ * one place where being wrong is STICKY — a cached result is reused for the rest
56
+ * of the executor's life — so the check is made against the per-posture record
57
+ * directly rather than trusted to the conjunction upstream. A spawn failure, a
58
+ * hang, a control refusal, and a degraded guess all report `layer: "none"` for the
59
+ * posture they happened under, and none of them is cached.
60
+ */
50
61
  function isCacheableDenyResult(result) {
51
- return result.enforced === true && result.layer !== "none";
62
+ if (result.enforced !== true || result.layer === "none")
63
+ return false;
64
+ const postures = result.postures;
65
+ if (postures === undefined)
66
+ return true;
67
+ return postures.every((p) => p.enforced === true && p.layer !== "none");
52
68
  }
53
69
  /** Create a fresh, empty executor-scoped deny-probe cache. */
54
70
  export function createDenyProbeCache() {
@@ -343,18 +359,26 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
343
359
  };
344
360
  }
345
361
  /**
346
- * Build the HTTP claim manifest — the eight original T2-accepted keys, plus the
347
- * optional ninth `epic_run_ids` key (BAPI-794) when the executor was configured
348
- * with one or more `--epic-run-id` flags. `free_slots` is supplied by the runner
349
- * (dynamic). The internal-only `gh` auth capability is intentionally NOT included
350
- * (not accepted by the T2 request schema in this slice).
362
+ * Build the HTTP claim manifest — the eight original T2-accepted keys, plus at
363
+ * most ONE optional scope-intent key: `epic_run_ids` (BAPI-794) when the
364
+ * executor was configured with `--epic-run-id`, or `repo_wide: true`
365
+ * (BAPI-1026) when it was configured with `--repo-wide`. `free_slots` is
366
+ * supplied by the runner (dynamic). The internal-only `gh` auth capability is
367
+ * intentionally NOT included (not accepted by the T2 request schema).
368
+ *
369
+ * The two scope keys are mutually exclusive by construction: `parseExecutorArgs`
370
+ * refuses to start an executor that supplied both, and the server rejects the
371
+ * combination with a 422 anyway. Exactly one is always present, because since
372
+ * BAPI-1026 the CLI also refuses to start with neither.
351
373
  *
352
- * `epic_run_ids` is added via a conditional spread, not a bare property
353
- * assignment: `{ epic_run_ids: options.epicRunIds }` would still create the key
354
- * (with value `undefined`) when unscoped, which `Object.keys()` — unlike
355
- * `JSON.stringify` — does NOT drop. The eight-key compatibility contract is
356
- * pinned on `Object.keys()`, so the key must be truly absent, not merely
357
- * `undefined`-valued.
374
+ * Both are added via a conditional spread, not a bare property assignment:
375
+ * `{ epic_run_ids: options.epicRunIds }` would still create the key (with value
376
+ * `undefined`) when unscoped, which `Object.keys()` — unlike `JSON.stringify` —
377
+ * does NOT drop. The eight-key compatibility contract is pinned on
378
+ * `Object.keys()`, so an unused key must be truly absent, not merely
379
+ * `undefined`-valued. For the same reason `repo_wide` is never serialized as
380
+ * `false`: `false` is the server's default and means "not an opt-out", so
381
+ * sending it would add a key that carries no information.
358
382
  */
359
383
  export function buildClaimManifest(report, options, freeSlots) {
360
384
  return {
@@ -367,5 +391,6 @@ export function buildClaimManifest(report, options, freeSlots) {
367
391
  free_slots: freeSlots,
368
392
  disk_free_gb: report.diskFreeGb ?? 0,
369
393
  ...(options.epicRunIds !== undefined ? { epic_run_ids: options.epicRunIds } : {}),
394
+ ...(options.repoWide === true ? { repo_wide: true } : {}),
370
395
  };
371
396
  }
@@ -51,7 +51,8 @@ export function resolveResumeExpectedBranch(job, fields) {
51
51
  * Resolve the server-provided prompt template, failing loud with a contract
52
52
  * error when it is missing/empty. Validated BEFORE any worktree resolution or
53
53
  * WIP-checkpoint work so a malformed `resume` payload never spawns a real
54
- * `claude -p "" --dangerously-skip-permissions` worker that would burn the full
54
+ * `claude -p ""` worker under whatever posture the run policy resolved, which
55
+ * since BAPI-1020 defaults to `--permission-mode auto` — that would burn the full
55
56
  * job timeout before failing generically (mirrors the `implement`/`spec_review`
56
57
  * `ContractError.Prompt` fail-fast in `resolveExecutorPrompt`).
57
58
  */
@@ -9,8 +9,10 @@
9
9
  import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
10
10
  import { runClaimedJob } from "./job-runner.js";
11
11
  import { appendToActiveJobLogs } from "./job-log-registry.js";
12
- import { createLiveWorkerRegistry } from "./live-worker-registry.js";
12
+ import { createLiveWorkerRegistry, } from "./live-worker-registry.js";
13
13
  import { sweepExecutorWorktrees } from "./worktree-gc.js";
14
+ import { validateBranchName } from "../base-ref.js";
15
+ import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
14
16
  /**
15
17
  * Signals that request a graceful executor shutdown (BAPI-828).
16
18
  *
@@ -133,6 +135,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
133
135
  // Read-only diagnostics, exactly like `lastClaimedEpicRunId` above: it never
134
136
  // feeds claim eligibility, cadence, retries, dispatch, or exit.
135
137
  let lastDispatcherAvailability = null;
138
+ // BAPI-1010 — one-shot executor wind-down observer state. Declared here,
139
+ // OUTSIDE both the claim loop and the poll loop, so a readiness attempt
140
+ // fires at most once for the life of this `runExecutor` invocation rather
141
+ // than once per poll cycle.
142
+ let markReadyAttempted = false;
143
+ // Suppresses the missing-read-capability advisory to one line per
144
+ // invocation, mirroring the dispatcher-liveness suppression above.
145
+ let missingReadCapabilityWarned = false;
136
146
  /**
137
147
  * Announce a dispatcher availability CHANGE, and only a change.
138
148
  *
@@ -164,6 +174,109 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
164
174
  deps.errorLog(formatDispatcherRecoveredDiagnostic());
165
175
  }
166
176
  };
177
+ /**
178
+ * Resolve the single epic run this executor process is unambiguously scoped
179
+ * to observe for wind-down, or `null` when the scope is absent or ambiguous.
180
+ *
181
+ * BAPI-1010: Slice 1 (`ExecutorOptions.epicRunIds`, BAPI-794) does not
182
+ * guarantee a dedicated executor receives exactly one run id — an operator
183
+ * may repeat `--epic-run-id` for several runs. Observing an arbitrary entry
184
+ * from a multi-run scope would silently pick the wrong epic, so the
185
+ * mutation stays disabled for both an unscoped executor (`epicRunIds`
186
+ * absent) and an ambiguous one (more than one id).
187
+ *
188
+ * BAPI-1026 (R54) DELIBERATELY LEAVES THIS CONDITION UNCHANGED, and that is
189
+ * the whole decision, not an omission.
190
+ *
191
+ * Making run scoping the default raised a real hazard: if the server could
192
+ * hand an executor a single-run scope it had not asked for, this gate would
193
+ * start authorizing a GitHub write — the one-shot epic→base integration-PR
194
+ * mutation — for processes that never had it, invisibly, as a side effect of
195
+ * a claim-scoping change. The decision is to keep the wind-down gated on
196
+ * EXPLICIT OPERATOR SCOPE only.
197
+ *
198
+ * No provenance flag is needed to express that, because R54 removed the only
199
+ * way a non-operator scope could arise: there is no server-derived scope. The
200
+ * server owns no executor-to-run binding to derive one from, so the accepted
201
+ * design makes the scope client-supplied and the CLI refuse to start without
202
+ * an explicit `--epic-run-id` or `--repo-wide`. Every `epicRunIds` value that
203
+ * can reach this line therefore came from an operator's `--epic-run-id`
204
+ * flags, exactly as it did before R54, and `--repo-wide` leaves `epicRunIds`
205
+ * absent so it cannot enable the mutation either.
206
+ *
207
+ * If a future slice ever introduces a server-derived scope, this gate must
208
+ * grow an explicit provenance field at the same time — cardinality would stop
209
+ * being evidence of operator intent the moment that happens.
210
+ */
211
+ const observedEpicRunId = options.epicRunIds !== undefined && options.epicRunIds.length === 1
212
+ ? options.epicRunIds[0]
213
+ : null;
214
+ /**
215
+ * One-shot epic-run completion observer (BAPI-1010).
216
+ *
217
+ * Called after every successful claim response (`claimed` or `none`), next
218
+ * to `observeDispatcherLiveness`. The FIRST time the scoped run's state
219
+ * reads back `status: "done"` on a valid `epic/`-prefixed feature branch,
220
+ * this requests the epic's existing draft integration pull request be
221
+ * marked ready for review — it never creates a merge and never merges
222
+ * anything; only a human merges an epic integration branch (see
223
+ * `epic-integration-pr.ts`). Every failure here — a missing read
224
+ * capability, a sanitized transport/validation failure, an invalid branch,
225
+ * or an unexpected throw — is ADVISORY: it is logged (credential-free,
226
+ * bounded) and never affects claiming, running jobs, or the runner's exit.
227
+ */
228
+ const observeEpicRunCompletion = async () => {
229
+ if (markReadyAttempted || observedEpicRunId === null)
230
+ return;
231
+ const readState = httpClient.readEpicRunState;
232
+ if (typeof readState !== "function") {
233
+ if (!missingReadCapabilityWarned) {
234
+ missingReadCapabilityWarned = true;
235
+ deps.errorLog("executor: HTTP client lacks readEpicRunState; skipping epic wind-down observation.");
236
+ }
237
+ return;
238
+ }
239
+ try {
240
+ const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
241
+ if (!result.ok) {
242
+ deps.errorLog(`executor: epic run completion-state read failed (reason=${result.reason})`);
243
+ return;
244
+ }
245
+ if (result.state.status !== "done")
246
+ return;
247
+ const featureBranch = result.state.featureBranch;
248
+ if (typeof featureBranch !== "string" || !featureBranch.startsWith(EPIC_BRANCH_PREFIX)) {
249
+ return;
250
+ }
251
+ const epicKey = featureBranch.slice(EPIC_BRANCH_PREFIX.length);
252
+ if (epicKey.length === 0)
253
+ return;
254
+ if (validateBranchName(featureBranch) !== null)
255
+ return;
256
+ if (validateBranchName(options.baseBranch) !== null)
257
+ return;
258
+ // Marked BEFORE constructing/awaiting the PR operation — a slow or
259
+ // failing `gh` call must never let a later poll cycle start a second
260
+ // attempt while this one is still in flight.
261
+ markReadyAttempted = true;
262
+ const gh = seams.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
263
+ const outcome = await ensureEpicIntegrationPullRequest({
264
+ epicKey,
265
+ epicBranch: featureBranch,
266
+ baseBranch: options.baseBranch,
267
+ command: "executor wind-down",
268
+ requestReady: true,
269
+ gh,
270
+ cwd: deps.cwd,
271
+ });
272
+ deps.errorLog(`executor: epic wind-down integration PR: ${JSON.stringify(formatEpicIntegrationPullRequestOutcome(outcome))}`);
273
+ }
274
+ catch {
275
+ // Advisory-only: never allowed to affect claiming, running jobs, or the
276
+ // runner's exit code.
277
+ deps.errorLog("executor: epic wind-down observation failed unexpectedly (advisory only).");
278
+ }
279
+ };
167
280
  // BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
168
281
  // the claim loop below — that scope is the whole feature. A cache created inside
169
282
  // the loop would be discarded every cycle and re-probe exactly as before; a
@@ -182,7 +295,6 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
182
295
  // not module-global. Every dispatched job receives it, so a worker spawned by
183
296
  // any of them is reachable from the signal handlers installed below.
184
297
  const liveWorkers = createLiveWorkerRegistry();
185
- const control = { liveWorkers };
186
298
  let shutdownRequested = false;
187
299
  /**
188
300
  * Wake functions for whichever poll sleep is currently in flight. A list whose
@@ -210,6 +322,35 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
210
322
  for (const wake of shutdownWaiters.splice(0, shutdownWaiters.length))
211
323
  wake();
212
324
  };
325
+ /**
326
+ * Process-level auth-fatal handler (BAPI-1021, AC-5). A dispatched job
327
+ * (claim, heartbeat, complete, or fail) calls this via
328
+ * `control.reportAuthFatal` on its FIRST 401/403 — a credential that will
329
+ * never become valid again by waiting.
330
+ *
331
+ * Deliberately just `beginShutdown()` underneath: an auth halt is the same
332
+ * kind of stop as a `SIGTERM`/`SIGINT` — terminate live workers gracefully
333
+ * and stop claiming new work — reusing its idempotent flag and drain
334
+ * behavior rather than inventing a parallel shutdown path. That reuse is
335
+ * also what keeps this handler from ever running a worktree
336
+ * reset/recreation or a conservative GC sweep: neither the existing
337
+ * shutdown path nor this one performs either.
338
+ *
339
+ * The ONE bounded, secret-safe log line is guarded SEPARATELY from
340
+ * `beginShutdown`'s own idempotency: two jobs racing an auth failure nearly
341
+ * concurrently must produce exactly one `executor_auth_failure` line, even
342
+ * though `beginShutdown()` itself is safe to call from both.
343
+ */
344
+ let authFailureLogged = false;
345
+ const reportAuthFatal = (notice) => {
346
+ if (!authFailureLogged) {
347
+ authFailureLogged = true;
348
+ deps.errorLog(`executor_auth_failure: category=${notice.category} status=${notice.httpStatus} ` +
349
+ `job_id=${notice.jobId ?? "n/a"} repo=${notice.repoName}`);
350
+ }
351
+ beginShutdown();
352
+ };
353
+ const control = { liveWorkers, reportAuthFatal };
213
354
  /**
214
355
  * Sleep one poll interval, or return early once shutdown is requested.
215
356
  *
@@ -453,15 +594,26 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
453
594
  // not evidence about the dispatcher, so it is deliberately not observed.
454
595
  if (result.kind === "claimed" || result.kind === "none") {
455
596
  observeDispatcherLiveness(result.reconcilerLiveness);
597
+ await observeEpicRunCompletion();
456
598
  }
457
599
  if (result.kind === "claimed") {
458
600
  const currentEpicRunId = result.job.epic_run_id ?? null;
459
601
  if (lastClaimedEpicRunId !== null &&
460
602
  currentEpicRunId !== null &&
461
603
  currentEpicRunId !== lastClaimedEpicRunId) {
604
+ // BAPI-1026 (R54) — RETARGETED, not removed. `scoped=` used to
605
+ // report `epicRunIds !== undefined`, which this ticket could have
606
+ // made universally true and so turned the warning it guards into
607
+ // dead code. It reports the claim-scope MODE instead, which stays
608
+ // genuinely two-valued: a `--repo-wide` executor legitimately
609
+ // claims across runs (the warning is expected there), while a
610
+ // scoped executor seeing a cross-run claim is a real anomaly worth
611
+ // the line. Bounded context only — a mode label and the run IDs
612
+ // already present in this pre-existing message.
462
613
  deps.errorLog(`executor claimed a job for a different epic run than the previous claim ` +
463
614
  `(repo=${result.job.repo_name} previous_epic_run_id=${lastClaimedEpicRunId} ` +
464
- `current_epic_run_id=${currentEpicRunId} scoped=${options.epicRunIds !== undefined})`);
615
+ `current_epic_run_id=${currentEpicRunId} ` +
616
+ `claim_scope_mode=${options.epicRunIds !== undefined ? "scoped" : "repo_wide"})`);
465
617
  }
466
618
  if (currentEpicRunId !== null)
467
619
  lastClaimedEpicRunId = currentEpicRunId;
@@ -478,6 +630,18 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
478
630
  else {
479
631
  deps.errorLog(`executor claim ${result.kind}: ${result.error}`);
480
632
  claiming = false;
633
+ // BAPI-1021 (AC-5): a categorized 401/403 fatal claim halts the
634
+ // RUNNER through the same handler a heartbeat/complete/fail auth
635
+ // failure reaches — not merely this claim iteration. No job was
636
+ // ever claimed, so the notice names the manifest's repository and
637
+ // omits `jobId`.
638
+ if (result.kind === "fatal" && result.authCategory === "auth_fatal") {
639
+ control.reportAuthFatal?.({
640
+ repoName: manifest.repo_name,
641
+ category: "claim",
642
+ httpStatus: result.authHttpStatus ?? 401,
643
+ });
644
+ }
481
645
  }
482
646
  }
483
647
  // Conservative worktree GC runs once per cycle, AFTER the claim attempt and
@@ -485,7 +649,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
485
649
  // both the startup cycle and between-poll-cycle cadence without ever
486
650
  // sweeping while a job is running. Kept off the pre-claim path so it never
487
651
  // delays claiming a ready job.
488
- await maybeSweepWorktrees();
652
+ //
653
+ // BAPI-1021 (AC-5): also skipped once shutdown was requested DURING this
654
+ // cycle's claim loop (a claim-time auth-fatal is the case that matters —
655
+ // it can flip `shutdownRequested` while `active.size` is still 0). No
656
+ // worktree-mutating sweep may run once the auth-fatal flag is set.
657
+ if (!shutdownRequested) {
658
+ await maybeSweepWorktrees();
659
+ }
489
660
  }
490
661
  if (options.once || shutdownRequested)
491
662
  break;