@bridge_gpt/mcp-server 0.2.53 → 0.2.54

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 (40) hide show
  1. package/README.md +86 -10
  2. package/build/agent-launchers/claude.js +3 -3
  3. package/build/agent-launchers/prompt.js +8 -11
  4. package/build/base-ref.js +33 -9
  5. package/build/bounded-wait.js +174 -0
  6. package/build/commands.generated.js +1 -1
  7. package/build/conductor/bridge-api-client.js +36 -8
  8. package/build/conductor/epic-runtime.js +133 -97
  9. package/build/conductor/readiness.js +85 -0
  10. package/build/conductor/run-branch.js +137 -0
  11. package/build/conductor/test-run-branch-vectors.js +165 -0
  12. package/build/conductor-bin.js +5 -5
  13. package/build/doctor.js +68 -1
  14. package/build/drive-epic.js +287 -51
  15. package/build/executor/claim-scope.js +104 -0
  16. package/build/executor/cli.js +14 -25
  17. package/build/executor/env-file-guard.js +82 -3
  18. package/build/executor/job-runner.js +60 -0
  19. package/build/index.js +128 -400
  20. package/build/local-artifact-storage.js +130 -0
  21. package/build/pipelines.generated.js +16 -9
  22. package/build/plane/cli.js +285 -36
  23. package/build/plane/manifest.js +209 -1
  24. package/build/plane/member-roster.js +70 -0
  25. package/build/plane/shutdown.js +14 -1
  26. package/build/plane/status.js +35 -1
  27. package/build/plane/supervisor.js +546 -164
  28. package/build/plane/types.js +25 -2
  29. package/build/polling-policy.js +72 -0
  30. package/build/readme.generated.js +1 -1
  31. package/build/review-generation.js +219 -0
  32. package/build/run-unit-tests-launcher.js +5 -0
  33. package/build/setup-epic.js +514 -23
  34. package/build/ticket-key-utils.js +4 -3
  35. package/build/ticket-review-artifact-gate.js +461 -0
  36. package/build/upgrade-cli.js +5 -26
  37. package/build/version.generated.js +3 -3
  38. package/docs/install/mcp-tool-integrations.md +23 -1
  39. package/package.json +1 -1
  40. package/pipelines/review-ticket.json +17 -4
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The executor CLAIM SCOPE: one vocabulary, one validator, two commands.
3
+ *
4
+ * BAPI-1026 (R54) made the claim scope mandatory and explicit: an executor
5
+ * started with neither `--epic-run-id` nor `--repo-wide` REFUSES TO START,
6
+ * because the server no longer treats an omitted scope as repository-wide and
7
+ * such an executor would poll forever and claim nothing. Refusing loudly at
8
+ * startup replaced that silent idle.
9
+ *
10
+ * BAPI-1102 gave `plane up` executor lanes of its own to spawn, which made the
11
+ * refusal reachable from a second command — and made a copied string literal the
12
+ * obvious hazard. A plane that re-worded the executor's refusal would tell an
13
+ * operator something subtly different about the same rule, and the two wordings
14
+ * would drift the first time either was edited. So the vocabulary and the rule
15
+ * move HERE, and both CLIs render what this module returns, verbatim.
16
+ *
17
+ * Deliberately free of process startup, filesystem access, network access, and
18
+ * CLI output. It is imported by `executor/cli.ts` and by `plane/`, and a shared
19
+ * module that reached for any of those would drag the whole executor runtime into
20
+ * the plane's import graph to validate two flags.
21
+ */
22
+ /**
23
+ * The exact refusal for an invocation carrying BOTH forms of scope.
24
+ *
25
+ * Module-level constants, not inline literals, and exported so a test can assert
26
+ * that the plane renders the executor's own wording rather than a lookalike.
27
+ * Neither interpolates anything: these are fixed prose about flags.
28
+ */
29
+ export const EXECUTOR_CLAIM_SCOPE_MUTUALLY_EXCLUSIVE_MESSAGE = "--repo-wide cannot be combined with --epic-run-id: pass one claim scope, not both";
30
+ /**
31
+ * The shape of a server-minted epic run id (BAPI-794).
32
+ *
33
+ * `epic_run_id` is always minted as `str(uuid.uuid4())` in
34
+ * `api/library/db/epic_runs.py`, so a standard UUID shape — any version — is a
35
+ * cheap, safe pre-validation: it rejects an obvious typo before any credential
36
+ * resolution or executor-loop startup, without over-constraining the CLI to a
37
+ * version the server does not itself enforce.
38
+ *
39
+ * It lives HERE, with the rest of the scope vocabulary, rather than in
40
+ * `executor/cli.ts` where it began. `plane up` accepted any non-blank id and the
41
+ * executor it spawns then refused the same value at startup — the operator saw a
42
+ * plane come up and a lane die, instead of one refusal at the point of entry
43
+ * (BAPI-1102 review).
44
+ */
45
+ export const EXECUTOR_EPIC_RUN_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
46
+ /** Is this a plausibly server-minted epic run id? */
47
+ export function isValidExecutorEpicRunId(value) {
48
+ return EXECUTOR_EPIC_RUN_ID_PATTERN.test(value.trim());
49
+ }
50
+ /**
51
+ * The refusal for an id that cannot be a server-minted run id. Interpolates the
52
+ * offending value, which is argv the operator just typed — never a credential.
53
+ */
54
+ export function executorEpicRunIdRefusal(value) {
55
+ return `--epic-run-id has an invalid value: ${value}`;
56
+ }
57
+ /** The exact refusal for an invocation carrying NO scope at all. */
58
+ export const EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE = "a claim scope is required: pass --epic-run-id <id> (repeatable) to serve " +
59
+ "specific epic runs, or --repo-wide to deliberately claim repository-wide";
60
+ /**
61
+ * Validate a parsed scope selection.
62
+ *
63
+ * Order matters and is part of the contract: the mutual-exclusion refusal is
64
+ * reported BEFORE the missing-scope refusal, so an invocation that supplies both
65
+ * is told it supplied both rather than being told to supply one. One invocation
66
+ * therefore yields exactly one message, deterministically.
67
+ *
68
+ * Blank and whitespace-only ids are dropped before the emptiness test, so
69
+ * `--epic-run-id ""` is "no scope supplied" rather than a scope naming a run that
70
+ * cannot exist — the same reading the executor's own argv parse already takes.
71
+ *
72
+ * Each surviving id is then checked for the server-minted UUID shape, and that
73
+ * check runs LAST: an invocation with no scope at all is told to supply one
74
+ * rather than being told its zero ids are malformed.
75
+ */
76
+ export function validateExecutorClaimScope(input) {
77
+ const epicRunIds = input.epicRunIds.map((id) => id.trim()).filter((id) => id.length > 0);
78
+ if (input.repoWide && epicRunIds.length > 0) {
79
+ return { ok: false, message: EXECUTOR_CLAIM_SCOPE_MUTUALLY_EXCLUSIVE_MESSAGE };
80
+ }
81
+ if (input.repoWide)
82
+ return { ok: true, scope: { kind: "repo-wide" } };
83
+ if (epicRunIds.length === 0) {
84
+ return { ok: false, message: EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE };
85
+ }
86
+ const malformed = epicRunIds.find((id) => !isValidExecutorEpicRunId(id));
87
+ if (malformed !== undefined) {
88
+ return { ok: false, message: executorEpicRunIdRefusal(malformed) };
89
+ }
90
+ return { ok: true, scope: { kind: "epic-runs", epicRunIds } };
91
+ }
92
+ /**
93
+ * Render a validated scope as executor argv, in the executor's own flag spelling.
94
+ *
95
+ * The single place that knows how a scope becomes arguments, so the plane's lane
96
+ * roster cannot spell the flags differently from the executor that parses them.
97
+ * One `--epic-run-id <id>` PAIR per id, in the supplied order; `--repo-wide`
98
+ * exactly once and valueless.
99
+ */
100
+ export function executorClaimScopeArgs(scope) {
101
+ if (scope.kind === "repo-wide")
102
+ return ["--repo-wide"];
103
+ return scope.epicRunIds.flatMap((id) => ["--epic-run-id", id]);
104
+ }
@@ -11,6 +11,7 @@ import { VERSION } from "../version.generated.js";
11
11
  import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
12
12
  import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
13
13
  import { DEFAULT_EXECUTOR_AGENT_ID } from "./agent-identity.js";
14
+ import { executorEpicRunIdRefusal, isValidExecutorEpicRunId, validateExecutorClaimScope, } from "./claim-scope.js";
14
15
  import { createDefaultExecutorDeps } from "./deps.js";
15
16
  import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
16
17
  import { createExecutorHttpClient } from "./http-client.js";
@@ -72,17 +73,6 @@ function parseIntArg(value, flag) {
72
73
  }
73
74
  return { ok: true, value: n };
74
75
  }
75
- /**
76
- * BAPI-794 — `epic_run_id` is always server-minted as `str(uuid.uuid4())`
77
- * (`api/library/db/epic_runs.py`), so a standard UUID shape (any version) is a
78
- * cheap, safe pre-validation: it rejects an obvious typo before any credential
79
- * resolution or executor-loop startup, without over-constraining the CLI to a
80
- * version the server does not itself enforce.
81
- */
82
- const EPIC_RUN_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
83
- function isValidEpicRunId(value) {
84
- return EPIC_RUN_ID_PATTERN.test(value);
85
- }
86
76
  /** Parse the executor CLI arguments into resolved options (pure, synchronous). */
87
77
  export function parseExecutorArgs(argv, context) {
88
78
  const repos = [];
@@ -121,8 +111,11 @@ export function parseExecutorArgs(argv, context) {
121
111
  if (!v || !v.trim())
122
112
  return { kind: "error", message: "--epic-run-id requires a value" };
123
113
  const trimmed = v.trim();
124
- if (!isValidEpicRunId(trimmed)) {
125
- return { kind: "error", message: `--epic-run-id has an invalid value: ${trimmed}` };
114
+ // BAPI-1102 — the shape rule moved to the shared claim-scope module, so
115
+ // `plane up` refuses the same value at ITS entry point instead of coming
116
+ // up and letting the lane it spawned die on the same argument.
117
+ if (!isValidExecutorEpicRunId(trimmed)) {
118
+ return { kind: "error", message: executorEpicRunIdRefusal(trimmed) };
126
119
  }
127
120
  if (!epicRunIds.includes(trimmed))
128
121
  epicRunIds.push(trimmed);
@@ -200,18 +193,14 @@ export function parseExecutorArgs(argv, context) {
200
193
  // forever and claim nothing. Refusing to START is the loud failure that
201
194
  // replaces that silent idle: a misconfiguration is a startup error, visible
202
195
  // 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
- };
196
+ //
197
+ // BAPI-1102 moved the rule and both messages into `./claim-scope.js` so
198
+ // `plane up` — which now spawns executor lanes of its own — enforces the SAME
199
+ // rule and renders the SAME wording, rather than carrying a second copy that
200
+ // drifts. The returned message is rendered VERBATIM; nothing is re-worded here.
201
+ const scope = validateExecutorClaimScope({ epicRunIds, repoWide });
202
+ if (!scope.ok) {
203
+ return { kind: "error", message: scope.message };
215
204
  }
216
205
  const options = {
217
206
  executorId: executorIdFinal,
@@ -31,6 +31,27 @@
31
31
  * all produce `ok: false`; only a second listing that shows no matching entry
32
32
  * produces `ok: true`.
33
33
  *
34
+ * TRACKED TEMPLATES SURVIVE (BAPI-1104, R81). The `.env.` prefix rule below is
35
+ * deliberately wide, and it caught this repository's own git-TRACKED
36
+ * `.env.example` — deleting it from every worker worktree, which showed up as a
37
+ * spurious dirty tree in the BAPI-1061 run. A tracked file is not an operator's
38
+ * environment file: it is repository content the worker is entitled to see, and
39
+ * its contents are already public in the checkout. So a candidate that git
40
+ * reports as tracked is kept, reported as `kept_tracked`, and excluded from the
41
+ * confirming re-list below.
42
+ *
43
+ * The exemption does NOT widen this module's reach. The lookup is a narrow seam
44
+ * that takes basenames and returns basenames — it never resolves, reads, or stats
45
+ * a path, so the symlink guarantee above is untouched. And it is best-effort by
46
+ * design: when the lookup fails, NOTHING is exempt and every match is stripped
47
+ * exactly as before. That is strictly the safer direction. The guarantee this
48
+ * module makes is about what reaches the worker, and it is proven by the
49
+ * post-removal re-list, not by knowing which files were tracked — so a failed
50
+ * lookup leaves the proof exactly as strong as it was and costs only the
51
+ * convenience the exemption was added for. Refusing the spawn instead would
52
+ * convert a transient git hiccup into a dead job, adding a failure mode the guard
53
+ * never had.
54
+ *
34
55
  * REDACTION. A failure returns a fixed CATEGORY and nothing else — no filename,
35
56
  * no path, no resolved target, no exception text. This value reaches the job row
36
57
  * and the executor's stderr, and a worktree path contains an operator's username.
@@ -53,6 +74,29 @@ import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
53
74
  export function isWorkerEnvFileName(name) {
54
75
  return name === ".env" || name.startsWith(".env.");
55
76
  }
77
+ /**
78
+ * True for a name safe to hand to a subprocess as a literal pathspec (BAPI-1104).
79
+ *
80
+ * `readdir` cannot produce a name containing a separator or a NUL, so in
81
+ * production this is already true of every candidate. It is checked anyway
82
+ * because the value crosses a process boundary: the property that matters —
83
+ * "only exact `.env`/`.env.*` root basenames ever reach git" — should be
84
+ * enforced at the boundary rather than inferred from what `readdir` happens to
85
+ * guarantee. A name that fails this is simply not exempted; it is still stripped.
86
+ *
87
+ * Shell metacharacters need no handling here and none is attempted:
88
+ * `deps.runCommand` is `execFile` with `shell: false`, so a name like
89
+ * `.env; rm -rf /` is delivered as one inert argv element and no shell ever
90
+ * parses it. The real (narrow) hazard is git reading a leading-dash argument as
91
+ * an OPTION, which the `--` pathspec separator at the call site closes — and
92
+ * which the `.env` prefix rule above already makes unreachable.
93
+ */
94
+ function isSafePathspecBasename(name) {
95
+ return (isWorkerEnvFileName(name) &&
96
+ !name.includes("/") &&
97
+ !name.includes("\\") &&
98
+ !name.includes("\0"));
99
+ }
56
100
  /**
57
101
  * Strip every `.env` / `.env.*` entry from a worktree ROOT and prove it worked.
58
102
  *
@@ -74,7 +118,37 @@ export async function stripWorkerEnvFiles(worktreePath, deps) {
74
118
  // Sorted so a successful `removed` list is deterministic and two job rows for
75
119
  // the same condition render identically.
76
120
  const matches = entries.filter(isWorkerEnvFileName).sort();
77
- for (const name of matches) {
121
+ // BAPI-1104 which matches are git-TRACKED repository content rather than an
122
+ // operator's environment file. Asked ONCE for the whole candidate set: one
123
+ // bounded subprocess at the last step before every worker spawn is a cost worth
124
+ // paying; one per `.env*` entry is not.
125
+ //
126
+ // Every value that comes back is re-intersected against `matches`. The seam is
127
+ // narrow but it is still a seam, and a lookup that returned a path, a nested
128
+ // name, or a name that was never a candidate must not be able to exempt
129
+ // anything — so the guard trusts the set it computed itself, not the answer.
130
+ let keptTracked = [];
131
+ const lookupCandidates = matches.filter(isSafePathspecBasename);
132
+ if (deps.listTrackedNames && lookupCandidates.length > 0) {
133
+ try {
134
+ const reported = await deps.listTrackedNames(worktreePath, lookupCandidates);
135
+ if (Array.isArray(reported)) {
136
+ const candidateSet = new Set(lookupCandidates);
137
+ keptTracked = Array.from(new Set(reported.filter((name) => typeof name === "string" && candidateSet.has(name)))).sort();
138
+ }
139
+ }
140
+ catch {
141
+ // Exempt NOTHING and continue. The guarantee is proven by the re-list
142
+ // below, which a failed lookup does not weaken — so stripping the tracked
143
+ // file too is strictly safer than refusing a spawn the guard can still
144
+ // confirm clean. No category, no diagnostic: the caller that owns the
145
+ // subprocess owns the one bounded log line about it.
146
+ keptTracked = [];
147
+ }
148
+ }
149
+ const keptSet = new Set(keptTracked);
150
+ const removed = matches.filter((name) => !keptSet.has(name));
151
+ for (const name of removed) {
78
152
  const target = pathApi.join(worktreePath, name);
79
153
  try {
80
154
  // Non-following metadata. The result is deliberately unused for a decision:
@@ -106,8 +180,13 @@ export async function stripWorkerEnvFiles(worktreePath, deps) {
106
180
  catch {
107
181
  return { ok: false, category: "verification_listing_failed" };
108
182
  }
109
- if (remaining.some(isWorkerEnvFileName)) {
183
+ // A KEPT tracked name is an expected survivor, not a failure. Without this the
184
+ // exemption would be self-defeating: the re-list would see the `.env.example`
185
+ // it deliberately preserved and refuse the spawn with `entry_survived`.
186
+ if (remaining.some((name) => isWorkerEnvFileName(name) && !keptSet.has(name))) {
110
187
  return { ok: false, category: "entry_survived" };
111
188
  }
112
- return { ok: true, removed: matches };
189
+ return keptTracked.length > 0
190
+ ? { ok: true, removed, kept_tracked: keptTracked }
191
+ : { ok: true, removed };
113
192
  }
@@ -48,6 +48,23 @@ import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
48
48
  import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
49
49
  import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
50
50
  import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
51
+ /**
52
+ * Bound on the pre-spawn git tracked-file lookup (BAPI-1104, R81).
53
+ *
54
+ * This runs at the LAST step before every worker spawn, so a hung `git ls-files`
55
+ * would stall spawning rather than merely delay one exemption. Five seconds is
56
+ * far beyond what an index read of a handful of pathspecs takes, and a timeout
57
+ * degrades to "exempt nothing" rather than to a refusal.
58
+ */
59
+ const ENV_FILE_TRACKED_LOOKUP_TIMEOUT_MS = 5_000;
60
+ /**
61
+ * The ONE fixed line emitted when that lookup fails. No path (a worktree path
62
+ * carries an operator's username), no filename, no errno, no command output, no
63
+ * exception text — the same bar `WORKER_ENV_FILE_PRESENT_MESSAGE` and the guard's
64
+ * closed categories hold. It is advisory: the spawn proceeds, with nothing
65
+ * exempted.
66
+ */
67
+ const ENV_FILE_TRACKED_LOOKUP_FAILED_MESSAGE = "[executor] tracked env-file lookup unavailable; exempting nothing";
51
68
  /** Default runtime for the no-op smoke process (ms). */
52
69
  const DEFAULT_SMOKE_DURATION_MS = 100;
53
70
  /** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
@@ -1599,6 +1616,49 @@ async function runPreparedSpawn(params) {
1599
1616
  // enforcement: no `stat`, `readlink`, or `readFile` is reachable from here.
1600
1617
  lstat: (target) => lstat(target),
1601
1618
  unlink: (target) => unlink(target),
1619
+ // BAPI-1104 (R81): which candidates git reports as TRACKED. This repository's
1620
+ // own `.env.example` is tracked content, not an operator's environment file,
1621
+ // and the `.env.` prefix rule was deleting it from every worker worktree.
1622
+ //
1623
+ // `deps.runCommand` is `execFile` with `shell: false`, so the basenames are
1624
+ // delivered as inert argv elements and no shell parses them. `--` is
1625
+ // mandatory: it stops git reading any argument as an option (unreachable for
1626
+ // `.env*` names, but the separator is what makes that a property rather than
1627
+ // an argument). `--error-unmatch` is deliberately NOT used — it exits 1 and
1628
+ // prints nothing for the WHOLE invocation as soon as one pathspec is
1629
+ // untracked, so on the mixed tracked/untracked worktree this feature exists
1630
+ // for it would silently report "nothing is tracked" and delete the
1631
+ // `.env.example` anyway. Plain `ls-files` lists only tracked paths and exits
1632
+ // zero, so the tracked subset falls out of stdout directly.
1633
+ listTrackedNames: async (root, candidates) => {
1634
+ let result;
1635
+ try {
1636
+ result = await deps.runCommand("git", ["ls-files", "-z", "--", ...candidates], { cwd: root, timeoutMs: ENV_FILE_TRACKED_LOOKUP_TIMEOUT_MS });
1637
+ }
1638
+ catch {
1639
+ // `runCommand` surfaces a non-zero exit as `exitCode` rather than
1640
+ // throwing, so reaching here means the process could not be run at all.
1641
+ deps.errorLog(ENV_FILE_TRACKED_LOOKUP_FAILED_MESSAGE);
1642
+ return [];
1643
+ }
1644
+ if (result.exitCode !== 0 || typeof result.stdout !== "string") {
1645
+ // Fail-OPEN into "nothing is exempt": the guard then strips every match,
1646
+ // which is strictly safer than refusing a spawn it can still confirm
1647
+ // clean. One fixed line, and nothing from the command in it — stdout
1648
+ // would carry worktree-relative paths and stderr an errno.
1649
+ deps.errorLog(ENV_FILE_TRACKED_LOOKUP_FAILED_MESSAGE);
1650
+ return [];
1651
+ }
1652
+ // NUL-delimited, never logged. Filtered to the exact candidate set here as
1653
+ // well as in the guard: the seam's contract is basenames-in / basenames-out
1654
+ // restricted to what was asked about, and a producer that honors its own
1655
+ // contract is easier to reason about than one that relies on its consumer
1656
+ // to clean up after it. The guard's re-intersection stays the enforcement.
1657
+ const asked = new Set(candidates);
1658
+ return result.stdout
1659
+ .split("\0")
1660
+ .filter((name) => name.length > 0 && asked.has(name));
1661
+ },
1602
1662
  platform: deps.platform,
1603
1663
  });
1604
1664
  if (!stripped.ok) {