@bridge_gpt/mcp-server 0.2.50 → 0.2.52

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 (76) hide show
  1. package/README.md +24 -8
  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 +1 -1
  6. package/build/conduct-epic/bridge-client.js +115 -1
  7. package/build/conduct-epic/cli.js +351 -33
  8. package/build/conduct-epic/cut-protocol.js +65 -0
  9. package/build/conductor/bridge-api-client.js +171 -5
  10. package/build/conductor/deny-enforcement-preflight.js +107 -10
  11. package/build/conductor/local-merge.js +170 -11
  12. package/build/conductor-bin.js +2 -2
  13. package/build/connect-bitbucket-api.js +370 -0
  14. package/build/connect-bitbucket.js +437 -0
  15. package/build/docs.generated.js +1 -1
  16. package/build/doctor.js +230 -1
  17. package/build/drive-epic.js +423 -11
  18. package/build/env-file-link.js +164 -0
  19. package/build/epic-integration-pr.js +290 -0
  20. package/build/executor/cli.js +41 -6
  21. package/build/executor/deps.js +5 -1
  22. package/build/executor/env-file-guard.js +113 -0
  23. package/build/executor/env.js +78 -1
  24. package/build/executor/heartbeat.js +9 -0
  25. package/build/executor/http-client.js +90 -22
  26. package/build/executor/job-errors.js +43 -2
  27. package/build/executor/job-runner.js +137 -29
  28. package/build/executor/merge-job.js +102 -6
  29. package/build/executor/permissions.js +106 -0
  30. package/build/executor/preflight.js +38 -13
  31. package/build/executor/resume-pre-spawn.js +2 -1
  32. package/build/executor/runner.js +175 -4
  33. package/build/executor/service-unit.js +15 -0
  34. package/build/executor/terminal-mutation.js +22 -1
  35. package/build/executor/types.js +86 -0
  36. package/build/executor/worker-command.js +21 -5
  37. package/build/executor/worker-guard-hook.js +939 -0
  38. package/build/executor/worker-log.js +56 -0
  39. package/build/executor/worktree.js +11 -0
  40. package/build/git-reachability.js +147 -0
  41. package/build/index.js +535 -95
  42. package/build/install-bridge.js +95 -0
  43. package/build/pipelines.generated.js +10 -2
  44. package/build/plan-epic-conductor-eligibility.js +213 -0
  45. package/build/plane/cli.js +78 -15
  46. package/build/plane/defaults.js +165 -0
  47. package/build/plane/manifest.js +63 -8
  48. package/build/plane/member-logs.js +6 -0
  49. package/build/plane/member-roster.js +195 -11
  50. package/build/plane/preflight.js +43 -0
  51. package/build/plane/shutdown.js +25 -3
  52. package/build/plane/status.js +11 -0
  53. package/build/plane/supervisor.js +343 -14
  54. package/build/plane/test-fakes.js +43 -0
  55. package/build/plane/types.js +82 -11
  56. package/build/pr-base-contract.js +20 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/review-synthesis-config.js +60 -0
  59. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  60. package/build/setup-epic.js +592 -139
  61. package/build/sfcc/log-query.js +2 -1
  62. package/build/sfcc/reads-custom-object-def.js +10 -13
  63. package/build/sfcc/reads-site-preference.js +5 -5
  64. package/build/sfcc/reads-system-object.js +4 -4
  65. package/build/sfcc/writes-custom-object-def.js +7 -7
  66. package/build/sfcc/writes-site-preference.js +4 -3
  67. package/build/sfcc/writes-system-object.js +7 -6
  68. package/build/start-tickets-conductor.js +11 -2
  69. package/build/start-tickets.js +69 -2
  70. package/build/version.generated.js +3 -3
  71. package/build/worker-containment-diagnostic.js +97 -0
  72. package/build/worker-guard-hook-bin.js +6 -0
  73. package/docs/CONDUCTOR.md +27 -0
  74. package/docs/install/mcp-tool-integrations.md +3 -2
  75. package/package.json +5 -3
  76. package/pipelines/plan-epic.json +5 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Interactive worktree environment-file LINKING (BAPI-1019, Slice A / A.2).
3
+ *
4
+ * Architecture Miss 28 was a conductor worker that truncated the operator's real
5
+ * operational database. It could reach that database because Worktrunk's
6
+ * `copy-ignored` step had placed a COPY of the operator's `.env` inside the
7
+ * worker's worktree. BAPI-1019 removes `.env*` from that copy step at source
8
+ * (`.config/wt.toml`), which fixes the executor case outright — an executor
9
+ * worktree must hold neither a copy nor a link — but it also takes `.env` away
10
+ * from the INTERACTIVE worktrees a human actually works in, which legitimately
11
+ * need it.
12
+ *
13
+ * This module gives that file back by REFERENCE rather than by value. For each
14
+ * supported name present in the main checkout it creates
15
+ * `<worktree>/<name>` as a SYMLINK to the main checkout's file, so an operator's
16
+ * secrets exist exactly once at rest no matter how many worktrees are open, and
17
+ * editing the operator's `.env` is immediately visible everywhere instead of
18
+ * drifting per worktree.
19
+ *
20
+ * CONTAINMENT RULES, all load-bearing:
21
+ *
22
+ * - This module is for the INTERACTIVE path only. `createWorktrees()` in
23
+ * `start-tickets.ts` is its sole production caller; `ensureExecutorWorktree()`
24
+ * must never call it. An executor worktree that held a link would resolve to
25
+ * the operator's real `.env` exactly as a copy did.
26
+ * - It NEVER reads or logs file CONTENTS. Its whole dependency surface is
27
+ * metadata (`stat`/`lstat`), link creation, unlinking, and — only on a
28
+ * platform that cannot make links — a copy. There is deliberately no
29
+ * `readFile` seam to misuse.
30
+ * - Its returned warnings are FIXED text plus a closed category. No absolute
31
+ * path, username, resolved link target, file content, or exception text
32
+ * reaches the caller, because these strings surface in `start-tickets`'
33
+ * operator-facing summary.
34
+ * - A destination that already exists as a REGULAR FILE (a copy left behind by
35
+ * an earlier run, before this ticket) is replaced with a link. That is the
36
+ * migration path off the old behavior: leaving the copy in place would leave
37
+ * the second at-rest copy this ticket exists to remove.
38
+ *
39
+ * ACCEPTED WINDOWS GAP. Creating a symlink on Windows requires Developer Mode or
40
+ * an elevated process, so `symlink` there commonly fails with `EPERM`. On that
41
+ * recognized failure this module falls back to a COPY and returns a warning
42
+ * rather than throwing: an interactive Windows worktree with a copied `.env` is
43
+ * the behavior operators already had, whereas a hard failure would break worktree
44
+ * creation outright. The residual second-copy-at-rest gap on Windows is recorded
45
+ * as accepted in the ticket's exploration ("Criteria Coverage"), not as a defect
46
+ * to solve here. It does NOT weaken the executor guarantee, which is enforced by
47
+ * `executor/env-file-guard.ts` on every platform.
48
+ */
49
+ import { pathApiForPlatform } from "./worktree-core.js";
50
+ /**
51
+ * The operator environment files an interactive worktree may receive by link.
52
+ *
53
+ * A CLOSED list, not a glob. `.env` and `.env.test` are the two files this
54
+ * repository's own tooling reads; matching `.env.*` here would start linking
55
+ * whatever an operator happens to have lying around (`.env.production`,
56
+ * `.env.backup`) into every worktree, which is a wider blast radius than the
57
+ * problem needs. The executor's strip, by contrast, is deliberately a WIDE
58
+ * pattern — removing too much from a worker is safe, linking too much is not.
59
+ */
60
+ export const LINKED_OPERATOR_ENV_FILES = [".env", ".env.test"];
61
+ /**
62
+ * The FIXED warning texts. Basenames are the only variable part, and a basename
63
+ * is a constant from {@link LINKED_OPERATOR_ENV_FILES} rather than anything
64
+ * discovered on disk — so no path, username, or secret can enter these strings.
65
+ */
66
+ function symlinkUnsupportedWarning(name) {
67
+ return {
68
+ category: "symlink_unsupported",
69
+ message: `worktree ${name}: this platform could not create a symbolic link, so the file was ` +
70
+ "copied instead. The worktree now holds a second copy of that environment file at " +
71
+ "rest. On Windows, enable Developer Mode (or run elevated) to get links instead.",
72
+ };
73
+ }
74
+ function linkFailedWarning(name) {
75
+ return {
76
+ category: "link_failed",
77
+ message: `worktree ${name}: could not be provided from the main checkout, so this worktree has ` +
78
+ "no copy of it. Commands that need it will fail until it is linked by hand.",
79
+ };
80
+ }
81
+ /**
82
+ * Error codes that mean "this platform/filesystem cannot make a symlink here",
83
+ * as opposed to an ordinary failure. `EPERM` is the Windows non-Developer-Mode
84
+ * signature; the rest cover filesystems and runtimes without link support.
85
+ */
86
+ const SYMLINK_UNSUPPORTED_CODES = new Set([
87
+ "EPERM",
88
+ "ENOSYS",
89
+ "EOPNOTSUPP",
90
+ "ENOTSUP",
91
+ "UNKNOWN",
92
+ ]);
93
+ function errorCode(err) {
94
+ if (!err || typeof err !== "object")
95
+ return undefined;
96
+ const code = err.code;
97
+ return typeof code === "string" ? code : undefined;
98
+ }
99
+ /** True when two metadata records describe the same underlying file. */
100
+ function sameFile(a, b) {
101
+ if (!a || !b)
102
+ return false;
103
+ return a.dev === b.dev && a.ino === b.ino;
104
+ }
105
+ /**
106
+ * Provide the main checkout's operator environment files to an interactive
107
+ * worktree as symlinks.
108
+ *
109
+ * Never throws: every per-name failure becomes a fixed warning, because a
110
+ * worktree that was created successfully must not be reported as failed just
111
+ * because a convenience link could not be made. A name whose SOURCE is absent in
112
+ * the main checkout is an ordinary no-op with no warning at all — most checkouts
113
+ * have `.env` and no `.env.test`, and warning about that would train operators to
114
+ * ignore this channel.
115
+ */
116
+ export async function linkOperatorEnvFiles(mainCheckout, worktreePath, deps) {
117
+ const pathApi = pathApiForPlatform(deps.platform ?? process.platform);
118
+ const result = { linked: [], copied: [], unchanged: [], warnings: [] };
119
+ for (const name of LINKED_OPERATOR_ENV_FILES) {
120
+ const source = pathApi.join(mainCheckout, name);
121
+ const destination = pathApi.join(worktreePath, name);
122
+ try {
123
+ // The main checkout is the authority on whether this name exists at all.
124
+ // Checked FIRST so a missing source touches the destination in no way.
125
+ const sourceStats = await deps.stat(source);
126
+ if (!sourceStats || !sourceStats.isFile())
127
+ continue;
128
+ const existing = await deps.lstat(destination);
129
+ if (existing) {
130
+ if (existing.isSymbolicLink() && sameFile(await deps.stat(destination), sourceStats)) {
131
+ // Already pointing at this exact file. Re-creating it would be churn,
132
+ // and every unlink of a correct entry is a window in which the worktree
133
+ // has no `.env` at all.
134
+ result.unchanged.push(name);
135
+ continue;
136
+ }
137
+ // A stale link, or a regular-file copy from before this ticket. Remove
138
+ // the ENTRY — `unlink` never follows, so the main checkout's real file is
139
+ // untouched even when the entry is a link pointing straight at it.
140
+ await deps.unlink(destination);
141
+ }
142
+ try {
143
+ await deps.symlink(source, destination);
144
+ result.linked.push(name);
145
+ }
146
+ catch (err) {
147
+ if (!SYMLINK_UNSUPPORTED_CODES.has(errorCode(err) ?? ""))
148
+ throw err;
149
+ // The accepted platform gap. Fall back to the old behavior rather than
150
+ // leaving the worktree without a file it needs.
151
+ await deps.copyFile(source, destination);
152
+ result.copied.push(name);
153
+ result.warnings.push(symlinkUnsupportedWarning(name));
154
+ }
155
+ }
156
+ catch {
157
+ // Bounded on purpose: the caught error is never inspected for text, only
158
+ // discarded. A filesystem error message can carry an absolute path (which
159
+ // contains a username) and this string is printed to an operator summary.
160
+ result.warnings.push(linkFailedWarning(name));
161
+ }
162
+ }
163
+ return result;
164
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Shared, non-throwing helper that ensures a draft epic-integration PR exists
3
+ * (BAPI-951).
4
+ *
5
+ * Local `gh` is used deliberately here: the Bridge/server pull-request seam has
6
+ * no `draft` parameter and cannot distinguish GitHub's "no commits between
7
+ * branches" 422 from a credential or provider failure — both of which this
8
+ * helper must classify without throwing.
9
+ *
10
+ * A draft PR still receives the `conductor-ci / gate` required check (BAPI-949's
11
+ * trigger has no draft exclusion), while `claude-review.yml` excludes drafts
12
+ * until the PR is marked ready. So opening the integration PR as a draft earns
13
+ * the epic branch its required CI immediately, without paying for a paid review
14
+ * nobody can act on until a human decides the epic is ready.
15
+ */
16
+ import { execFile as nodeExecFile } from "node:child_process";
17
+ /** Bounded read timeout — matches the existing 5s GitHub probe limit (pr-discovery.ts). */
18
+ export const EPIC_INTEGRATION_PR_READ_TIMEOUT_MS = 5_000;
19
+ /** Separate, larger bounded timeout for the two write operations (`pr create`, `pr ready`). */
20
+ export const EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS = 20_000;
21
+ /** Bounded result count for the existence probe. */
22
+ const LIST_LIMIT = 20;
23
+ const PR_LIST_JSON_FIELDS = "number,headRefName,baseRefName,isDraft";
24
+ /**
25
+ * `epic/<KEY>` prefix a child PR's base branch carries under BAPI-949/BAPI-950.
26
+ * Exported (BAPI-1010) so every call site that derives an epic key from a base
27
+ * branch shares one definition instead of re-declaring a private literal.
28
+ */
29
+ export const EPIC_BRANCH_PREFIX = "epic/";
30
+ /** The production call sites this helper is invoked from. */
31
+ export const EPIC_INTEGRATION_PR_COMMANDS = [
32
+ "setup-epic",
33
+ "conduct-epic init",
34
+ "conduct-epic catch-up",
35
+ "conduct-epic finish",
36
+ "executor merge",
37
+ // BAPI-1010: the executor's one-shot wind-down observer, distinct from
38
+ // "executor merge" so the generated PR body and diagnostics never mislabel
39
+ // a done-state readiness request as a ticket merge enrichment.
40
+ "executor wind-down",
41
+ ];
42
+ /** Only the sanctioned safe fields. Never raw command output. */
43
+ export function formatEpicIntegrationPullRequestOutcome(outcome) {
44
+ if (outcome.kind === "already_open" || outcome.kind === "created") {
45
+ return {
46
+ kind: outcome.kind,
47
+ reason: outcome.reason,
48
+ pr_number: outcome.prNumber,
49
+ readiness: outcome.readiness,
50
+ };
51
+ }
52
+ return { kind: outcome.kind, reason: outcome.reason };
53
+ }
54
+ /** A concise, epic-specific PR title. Does not depend on `gh`. */
55
+ export function buildEpicIntegrationPullRequestTitle(epicKey) {
56
+ return `${epicKey}: epic integration branch`;
57
+ }
58
+ /** A non-empty, epic-specific PR body. Does not depend on `gh`. */
59
+ export function buildEpicIntegrationPullRequestBody(epicKey, command) {
60
+ return [
61
+ `\`${command}\` opened this pull request automatically.`,
62
+ "",
63
+ `It exists so the \`conductor-ci / gate\` required check runs for the ${epicKey} ` +
64
+ "epic integration branch (BAPI-949). Draft status postpones the paid Claude " +
65
+ "review until a human marks this pull request ready for review.",
66
+ "",
67
+ "Only a human merges an epic integration branch — the conductor never merges it.",
68
+ ].join("\n");
69
+ }
70
+ function isRecord(value) {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+ /** Bounded diagnostics to distinguish "no local gh", "not authenticated", and "inconclusive". */
74
+ async function classifyGhUnavailable(gh, cwd) {
75
+ let version;
76
+ try {
77
+ version = await gh(["--version"], { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
78
+ }
79
+ catch {
80
+ return "gh_unavailable";
81
+ }
82
+ if (version.exitCode !== 0)
83
+ return "gh_unavailable";
84
+ let auth;
85
+ try {
86
+ auth = await gh(["auth", "status"], { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
87
+ }
88
+ catch {
89
+ return "probe_inconclusive";
90
+ }
91
+ if (auth.exitCode !== 0)
92
+ return "gh_unauthenticated";
93
+ return "probe_inconclusive";
94
+ }
95
+ /**
96
+ * Existence probe: `gh pr list` constrained by BOTH head and base. Only a
97
+ * successful, valid, empty JSON array is confirmed absence — anything else
98
+ * (non-zero exit, unparseable output, a non-array shape, an item with an
99
+ * invalid number, or a record whose head/base do not match) is `unavailable`,
100
+ * never treated as absence.
101
+ */
102
+ async function probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd) {
103
+ const args = [
104
+ "pr",
105
+ "list",
106
+ "--state",
107
+ "open",
108
+ "--head",
109
+ epicBranch,
110
+ "--base",
111
+ baseBranch,
112
+ "--json",
113
+ PR_LIST_JSON_FIELDS,
114
+ "--limit",
115
+ String(LIST_LIMIT),
116
+ ];
117
+ let result;
118
+ try {
119
+ result = await gh(args, { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
120
+ }
121
+ catch {
122
+ return { kind: "unavailable", reason: await classifyGhUnavailable(gh, cwd) };
123
+ }
124
+ if (result.exitCode !== 0) {
125
+ return { kind: "unavailable", reason: await classifyGhUnavailable(gh, cwd) };
126
+ }
127
+ let parsed;
128
+ try {
129
+ parsed = JSON.parse(result.stdout);
130
+ }
131
+ catch {
132
+ return { kind: "unavailable", reason: "probe_malformed" };
133
+ }
134
+ if (!Array.isArray(parsed)) {
135
+ return { kind: "unavailable", reason: "probe_malformed" };
136
+ }
137
+ if (parsed.length === 0) {
138
+ return { kind: "absent" };
139
+ }
140
+ for (const item of parsed) {
141
+ if (!isRecord(item))
142
+ continue;
143
+ const number = item.number;
144
+ const head = item.headRefName;
145
+ const base = item.baseRefName;
146
+ const isDraft = item.isDraft;
147
+ if (typeof number === "number" &&
148
+ Number.isInteger(number) &&
149
+ number > 0 &&
150
+ head === epicBranch &&
151
+ base === baseBranch &&
152
+ typeof isDraft === "boolean") {
153
+ return { kind: "found", number, isDraft };
154
+ }
155
+ }
156
+ // Records exist but none matches both head and base exactly, or the shape is
157
+ // unexpected — never treated as absence.
158
+ return { kind: "unavailable", reason: "probe_malformed" };
159
+ }
160
+ const NO_COMMITS_BETWEEN_PATTERN = /no commits between/i;
161
+ /** Strict positive numeric suffix of a normal `https://…/pull/<n>` URL, else `null`. */
162
+ function extractPrNumberFromCreateOutput(stdout) {
163
+ const match = stdout.trim().match(/\/pull\/(\d+)\s*$/);
164
+ if (!match)
165
+ return null;
166
+ const n = Number(match[1]);
167
+ return Number.isInteger(n) && n > 0 ? n : null;
168
+ }
169
+ async function createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd) {
170
+ const title = buildEpicIntegrationPullRequestTitle(epicKey);
171
+ const body = buildEpicIntegrationPullRequestBody(epicKey, command);
172
+ const args = [
173
+ "pr",
174
+ "create",
175
+ "--draft",
176
+ "--base",
177
+ baseBranch,
178
+ "--head",
179
+ epicBranch,
180
+ "--title",
181
+ title,
182
+ "--body",
183
+ body,
184
+ ];
185
+ let result;
186
+ try {
187
+ result = await gh(args, { cwd, timeoutMs: EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS });
188
+ }
189
+ catch {
190
+ return { kind: "create_failed" };
191
+ }
192
+ if (result.exitCode === 0) {
193
+ return { kind: "created", numberHint: extractPrNumberFromCreateOutput(result.stdout) };
194
+ }
195
+ const combined = `${result.stdout}\n${result.stderr}`;
196
+ if (NO_COMMITS_BETWEEN_PATTERN.test(combined)) {
197
+ return { kind: "no_commits" };
198
+ }
199
+ return { kind: "create_failed" };
200
+ }
201
+ async function maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd) {
202
+ if (!requestReady)
203
+ return "not_requested";
204
+ if (prNumber === null)
205
+ return "ready_failed";
206
+ if (!isDraft)
207
+ return "already_ready";
208
+ try {
209
+ const result = await gh(["pr", "ready", String(prNumber)], {
210
+ cwd,
211
+ timeoutMs: EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS,
212
+ });
213
+ return result.exitCode === 0 ? "made_ready" : "ready_failed";
214
+ }
215
+ catch {
216
+ return "ready_failed";
217
+ }
218
+ }
219
+ async function ensureEpicIntegrationPullRequestInner(options) {
220
+ const { epicKey, epicBranch, baseBranch, command, gh, cwd, requestReady } = options;
221
+ const probe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
222
+ if (probe.kind === "unavailable") {
223
+ return { kind: "unavailable", reason: probe.reason };
224
+ }
225
+ if (probe.kind === "found") {
226
+ const readiness = await maybeMakeReady(gh, probe.number, probe.isDraft, requestReady, cwd);
227
+ return { kind: "already_open", reason: "already_open", prNumber: probe.number, readiness };
228
+ }
229
+ // Confirmed absent: create.
230
+ const created = await createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd);
231
+ if (created.kind === "no_commits") {
232
+ return { kind: "deferred", reason: "no_commits_between_branches" };
233
+ }
234
+ if (created.kind === "created") {
235
+ // Resolve the number through a fresh matching probe first; fall back to the
236
+ // strict URL-derived hint only when the probe cannot confirm it.
237
+ const reprobe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
238
+ const prNumber = reprobe.kind === "found" ? reprobe.number : created.numberHint;
239
+ const isDraft = reprobe.kind === "found" ? reprobe.isDraft : true;
240
+ const readiness = await maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd);
241
+ return { kind: "created", reason: "created", prNumber, readiness };
242
+ }
243
+ // Generic create failure: resolve a possible concurrent creator once, never more.
244
+ const race = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
245
+ if (race.kind === "found") {
246
+ const readiness = await maybeMakeReady(gh, race.number, race.isDraft, requestReady, cwd);
247
+ return { kind: "already_open", reason: "already_open", prNumber: race.number, readiness };
248
+ }
249
+ return { kind: "unavailable", reason: "create_unavailable" };
250
+ }
251
+ /**
252
+ * Ensure a matching draft epic-integration pull request exists. Idempotent and
253
+ * never throws or rejects — every internal failure, including an injected
254
+ * runner throwing, resolves to `{ kind: "unavailable", ... }`.
255
+ */
256
+ export async function ensureEpicIntegrationPullRequest(options) {
257
+ try {
258
+ return await ensureEpicIntegrationPullRequestInner(options);
259
+ }
260
+ catch {
261
+ return { kind: "unavailable", reason: "probe_inconclusive" };
262
+ }
263
+ }
264
+ /**
265
+ * Build the production `gh` runner. Uses `execFile` (never a shell, never
266
+ * interpolated argv), ignores stdin, captures stdout/stderr, and bounds both
267
+ * the timeout and the output buffer. Every process-level failure (missing
268
+ * binary, timeout, spawn error) resolves a non-zero/timed-out result rather
269
+ * than rejecting.
270
+ */
271
+ export function createProductionEpicIntegrationGhRunner(execFileImpl = nodeExecFile) {
272
+ return (args, options) => new Promise((resolve) => {
273
+ execFileImpl("gh", args, {
274
+ cwd: options.cwd,
275
+ timeout: options.timeoutMs,
276
+ maxBuffer: 4 * 1024 * 1024,
277
+ encoding: "utf-8",
278
+ shell: false,
279
+ stdio: ["ignore", "pipe", "pipe"],
280
+ }, (error, stdout, stderr) => {
281
+ if (error) {
282
+ const timedOut = error.killed === true;
283
+ const code = typeof error.code === "number" ? error.code : null;
284
+ resolve({ exitCode: code, stdout: stdout ?? "", stderr: stderr ?? "", timedOut });
285
+ return;
286
+ }
287
+ resolve({ exitCode: 0, stdout: stdout ?? "", stderr: stderr ?? "", timedOut: false });
288
+ });
289
+ });
290
+ }
@@ -40,7 +40,11 @@ export function getExecutorUsage() {
40
40
  " --repo <name> Repo to serve (repeatable).",
41
41
  " --repos=<a,b> Comma-separated repos.",
42
42
  " --epic-run-id <id> Dedicate this executor to an epic run (repeatable).",
43
- " Omit for the default repository-wide behavior.",
43
+ " --repo-wide Explicit compatibility opt-out: claim from every",
44
+ " authorized repo, oldest-first, unscoped by run.",
45
+ " Exactly one of --epic-run-id or --repo-wide is",
46
+ " REQUIRED; omitting both is a startup error, not",
47
+ " repository-wide claiming (BAPI-1026 / R54).",
44
48
  " --base-url <url> Bridge API endpoint (overrides BAPI_BASE_URL).",
45
49
  " --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
46
50
  " --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
@@ -83,6 +87,7 @@ function isValidEpicRunId(value) {
83
87
  export function parseExecutorArgs(argv, context) {
84
88
  const repos = [];
85
89
  const epicRunIds = [];
90
+ let repoWide = false;
86
91
  let executorId;
87
92
  let maxConcurrent = DEFAULT_MAX_CONCURRENT;
88
93
  let once = false;
@@ -122,6 +127,9 @@ export function parseExecutorArgs(argv, context) {
122
127
  if (!epicRunIds.includes(trimmed))
123
128
  epicRunIds.push(trimmed);
124
129
  }
130
+ else if (arg === "--repo-wide") {
131
+ repoWide = true;
132
+ }
125
133
  else if (arg === "--executor-id") {
126
134
  executorId = argv[++i];
127
135
  if (!executorId)
@@ -182,6 +190,29 @@ export function parseExecutorArgs(argv, context) {
182
190
  const executorIdFinal = executorId && executorId.trim().length > 0
183
191
  ? executorId.trim()
184
192
  : `${context.hostname}-${context.pid}`;
193
+ // BAPI-1026 (R54) — the claim scope is now MANDATORY and explicit.
194
+ //
195
+ // Before this ticket, omitting `--epic-run-id` silently meant "claim the
196
+ // oldest queued row of ANY run in every authorized repo", which is how an
197
+ // executor could take a job belonging to an abandoned run (BAPI-993 B2). The
198
+ // server no longer treats an omitted scope as repository-wide once its
199
+ // deprecation window closes, so an executor started with no scope would poll
200
+ // forever and claim nothing. Refusing to START is the loud failure that
201
+ // replaces that silent idle: a misconfiguration is a startup error, visible
202
+ // immediately, rather than an executor that looks healthy and does no work.
203
+ if (repoWide && epicRunIds.length > 0) {
204
+ return {
205
+ kind: "error",
206
+ message: "--repo-wide cannot be combined with --epic-run-id: pass one claim scope, not both",
207
+ };
208
+ }
209
+ if (!repoWide && epicRunIds.length === 0) {
210
+ return {
211
+ kind: "error",
212
+ message: "a claim scope is required: pass --epic-run-id <id> (repeatable) to serve " +
213
+ "specific epic runs, or --repo-wide to deliberately claim repository-wide",
214
+ };
215
+ }
185
216
  const options = {
186
217
  executorId: executorIdFinal,
187
218
  repos,
@@ -208,6 +239,9 @@ export function parseExecutorArgs(argv, context) {
208
239
  // was supplied, so downstream `epicRunIds !== undefined` checks (the claim
209
240
  // manifest builder, startup diagnostics) see an unambiguous "unscoped".
210
241
  ...(epicRunIds.length > 0 ? { epicRunIds } : {}),
242
+ // BAPI-1026 (R54) — same "truly absent, never a falsy placeholder"
243
+ // discipline: `repoWide` is either `true` or the key is missing.
244
+ ...(repoWide ? { repoWide: true } : {}),
211
245
  };
212
246
  return { kind: "ok", options };
213
247
  }
@@ -267,14 +301,15 @@ export async function runExecutorCli(argv, overrides = {}) {
267
301
  return 1;
268
302
  }
269
303
  const options = parsed.options;
270
- // BAPI-794 — startup diagnostics: only non-secret run identifiers, never
271
- // credentials, job payloads, or command arguments beyond the run IDs
272
- // themselves (which are not secrets see EPIC_RUN_ID_PATTERN's docstring).
304
+ // BAPI-794/BAPI-1026 — startup diagnostics: a bounded MODE label and, when
305
+ // scoped, only the COUNT of runs. Never credentials, job payloads, command
306
+ // arguments and, since BAPI-1026, never the run IDs either, matching the
307
+ // per-claim logging policy the server side already enforces.
273
308
  if (options.epicRunIds !== undefined) {
274
- errorLog(`executor scoped to epic run(s): ${options.epicRunIds.join(", ")}`);
309
+ errorLog(`executor claim scope: scoped (epic_run_count=${options.epicRunIds.length})`);
275
310
  }
276
311
  else {
277
- errorLog("executor running repository-wide (no --epic-run-id configured)");
312
+ errorLog("executor claim scope: repo_wide (explicit --repo-wide)");
278
313
  }
279
314
  // The mutating executor requires an EXPLICIT base URL (BAPI-676): `--base-url`
280
315
  // then `BAPI_BASE_URL`, never an implicit production default. Fail here —
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { execFile, spawn } from "node:child_process";
11
11
  import { existsSync } from "node:fs";
12
- import { open, readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, readdir, lstat, stat, statfs, } from "node:fs/promises";
12
+ import { open, readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, rename, readdir, lstat, stat, statfs, } from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import { promisify } from "node:util";
15
15
  import { resolveMcpShimInvocationForRuntime } from "../mcp-server-invocation.js";
@@ -92,6 +92,10 @@ export function createDefaultExecutorDeps() {
92
92
  }
93
93
  },
94
94
  removeFile: (filePath) => rm(filePath, { force: true }),
95
+ // BAPI-1021 (AC-6): worker-log archival's rename seam. Real `fs.promises.rename`
96
+ // rejects with `code: "EXDEV"` across filesystems/devices, which worker-log.ts
97
+ // treats as an ordinary best-effort archive failure like any other.
98
+ rename: (oldPath, newPath) => rename(oldPath, newPath),
95
99
  stat: (filePath) => stat(filePath).then((s) => ({ mode: s.mode })),
96
100
  statMtimeMs: (filePath) => stat(filePath)
97
101
  .then((s) => s.mtimeMs)