@bridge_gpt/mcp-server 0.2.20 → 0.2.23

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.
package/CONDUCTOR.md CHANGED
@@ -3,14 +3,62 @@
3
3
  Conductor is the **opt-in, off-by-default** coordination layer for running many
4
4
  agent sessions together (epic supervision, inter-agent messaging, done-gate
5
5
  evaluation, and conditional auto-merge). A normal `start-tickets` run does **not**
6
- involve Conductor — you opt in per run with `--conductor`, and epic-tick dispatch
7
- enables it internally.
6
+ involve Conductor — you opt in per run with `--conductor`.
8
7
 
9
- This document is the reference for Conductor's observability stream, local git
10
- hooks, and the per-repo done-gate / auto-merge config. For the everyday
11
- `start-tickets` flags and cross-platform behavior, see
8
+ This document is the reference for Conductor's architecture, epic setup,
9
+ observability stream, local git hooks, and the per-repo done-gate / auto-merge
10
+ config. For the everyday `start-tickets` flags and cross-platform behavior, see
12
11
  [README → CLI Subcommands](./README.md#cli-subcommands).
13
12
 
13
+ ## Epic Conductor v2 — how an epic is actually driven
14
+
15
+ **The v1 `conductor epic-tick` command is frozen.** It throws
16
+ `EPIC_TICK_V1_FROZEN` on every invocation and advances nothing. There is nothing
17
+ to schedule locally. If you have an epic-tick schedule registered from an earlier
18
+ release, cancel it (`schedule-run cancel --id <id>`) — it is a dead timer.
19
+ `conductor doctor` flags one if it finds it.
20
+
21
+ v2 splits the old local tick into two halves:
22
+
23
+ | Half | Where it runs | What it does |
24
+ | --- | --- | --- |
25
+ | **Reconciler** | **Server-side**, on the Bridge API worker dyno, every 30s | Selects every epic run whose status is `active`, evaluates gates, and enqueues executor jobs. Nothing to install or schedule. |
26
+ | **Executor** | **Locally**, on your machine | Polls for jobs, claims them, spawns worker agents, heartbeats. This is the only piece you run. |
27
+
28
+ ```
29
+ npx -y @bridge_gpt/mcp-server executor --repo <name>
30
+ ```
31
+
32
+ The reconciler drives a run purely off `epic_runs.status = 'active'` — there is no
33
+ local schedule, and no per-repo "enable the tick" flag. (An `epic_tick_enabled`
34
+ config field existed briefly and was inert; it has been removed.)
35
+
36
+ ## Setting up an epic
37
+
38
+ One command creates the run, stores the plan, and approves it:
39
+
40
+ ```
41
+ npx -y @bridge_gpt/mcp-server setup-epic --epic-key BAPI-405 \
42
+ --plan-file docs/tmp/epic-plans/<slug>/epic-plan.dag.json
43
+ ```
44
+
45
+ The plan sidecar (`epic-plan.dag.json`) is produced by the `decompose-epic`
46
+ pipeline step. `setup-epic` validates it locally (unique ticket keys, resolvable
47
+ `depends_on`/edge references, acyclicity) before sending anything, so a malformed
48
+ plan fails legibly instead of as a bare HTTP 400.
49
+
50
+ It is **idempotent**: re-running it on an epic that already has a live run reuses
51
+ that run rather than minting a second one. Use `--dry-run` to validate a plan and
52
+ preview the calls without mutating anything.
53
+
54
+ Once the plan is approved the run becomes `active`, the server-side reconciler
55
+ picks it up within ~30s, and your local `executor` starts claiming jobs.
56
+
57
+ > **A caveat worth knowing before you plan an epic.** Conductor has no
58
+ > merge-conflict handling, and plan-time file-overlap serialization is currently
59
+ > dark (the planner does not yet emit the `touched_files` metadata it needs). If
60
+ > two sibling tickets touch the same files, do not let them dispatch in parallel.
61
+
14
62
  ## Conductor observability (opt-in via `--conductor`, BAPI-394)
15
63
 
16
64
  Conductor is **off by default** — without `--conductor` no `BAPI_CONDUCTOR_*` env,
@@ -28,7 +76,7 @@ in the env, hook command, or run metadata. Override the gate/supervisor labels w
28
76
  `BAPI_CONDUCTOR_GATE_NAME` / `BAPI_CONDUCTOR_SUPERVISOR_MODE`. Inspect the stream
29
77
  with `conductor doctor`. Observability is best-effort: a conductor failure never
30
78
  blocks or aborts a spawn, and `--dry-run` performs no conductor side effects.
31
- (Epic-tick dispatch always enables conductor internally, independent of this flag.)
79
+ (Epic dispatch always enables conductor internally, independent of this flag.)
32
80
 
33
81
  When `--conductor` is set, the spawn boundary also injects
34
82
  `BRIDGE_MCP_PROFILE=conductor` so each worker registers the 8 conductor/event/
@@ -61,10 +109,28 @@ hook presence and managed-snippet status **read-only** (a new `git hooks` sectio
61
109
  MCP tool drives CI polling and gate evaluation regardless of whether hooks are
62
110
  installed.
63
111
 
64
- ## `conductor_done_gate` config
112
+ ## Supervisor config — where the done gate and auto-merge live
113
+
114
+ > **These are not config fields.** They used to be `conductor_done_gate` and
115
+ > `conductor_auto_merge_enabled` on the generic config-field route; BAPI-438 moved
116
+ > them onto dedicated supervisor endpoints. Setting them via
117
+ > `PUT /jira/config-field/...` returns **HTTP 400 "Invalid config field"** — that
118
+ > rejection is correct, not a bug. Use the endpoints below.
119
+
120
+ Two scopes exist for each: a per-repo **project default**, and a per-epic override.
65
121
 
66
- The per-repo `conductor_done_gate` config field (read through the existing
67
- config-field route) defines the v1 done gate. It supports exactly one condition,
122
+ | Setting | Endpoint (project default) |
123
+ | --- | --- |
124
+ | `done_gate_config` | `PUT /jira/epic-runs/supervisor-setup/defaults/?repo_name=<repo>` |
125
+ | `auto_merge_enabled`, `merge_approval_required` | `PUT /jira/epic-runs/supervisor-config/defaults/?repo_name=<repo>` |
126
+
127
+ Swap `…/defaults/` for `…/runs/{epic_key}/…` to scope a setting to one epic. The
128
+ matching `GET` on each returns the effective value, with a `source` of `epic`,
129
+ `project_default`, or `none` (nothing configured — fails closed).
130
+
131
+ ### `done_gate_config`
132
+
133
+ Defines the done gate. It supports exactly one condition,
68
134
  `required_ci_checks_green`:
69
135
 
70
136
  ```json
@@ -81,30 +147,23 @@ config`) only when every listed required check is present, complete, and green f
81
147
  the bound PR head SHA. The gate **fails closed**: an unset, disabled (`enabled` not
82
148
  strictly `true`), malformed, empty, or unsupported config emits no `gate.met`.
83
149
 
84
- ## `conductor_auto_merge_enabled` config (C6 conditional auto-merge)
150
+ ### `auto_merge_enabled` (C6 conditional auto-merge)
85
151
 
86
152
  When a worker's PR meets the done gate (`gate.met`), the supervisor can autonomously
87
- merge it — but **only** when the repo has explicitly opted in. The per-repo
88
- `conductor_auto_merge_enabled` config field (read through the same config-field route
89
- as `conductor_done_gate`) is the opt-in switch:
90
-
91
- ```json
92
- { "enabled": true }
93
- ```
153
+ merge it — but **only** when the repo has explicitly opted in.
154
+ `auto_merge_enabled` on the supervisor-config endpoint is the opt-in switch.
94
155
 
95
- A bare JSON boolean (`true`) is also accepted. **Auto-merge is disabled by default.**
96
- Behavior:
156
+ **Auto-merge is disabled by default.** Behavior:
97
157
 
98
- - **Disabled / unset / malformed → dry-run.** Anything other than `true` or
99
- `{"enabled": true}` — including unset, `false`, `{"enabled": false}`, or any
100
- malformed value — fails **closed**: the supervisor records a `merge.dry_run` event
101
- and **no PR is ever merged**.
158
+ - **Disabled / unset / unconfigured → dry-run.** Anything other than an explicit
159
+ `true` — including a repo with no supervisor config at all (`source: "none"`)
160
+ fails **closed**: the supervisor records a `merge.dry_run` event and **no PR is
161
+ ever merged**.
102
162
  - **Enabled → autonomous merge** when the gate is met and the deterministic guards
103
163
  pass.
104
- - **Kill-switch.** Set `conductor_auto_merge_enabled` to `false` or remove the field
105
- to immediately stop autonomous merges. The protected merge endpoint
106
- **independently re-enforces** the flag, so even a conductor that calls it cannot
107
- merge while the flag is off.
164
+ - **Kill-switch.** Set `auto_merge_enabled` to `false` to immediately stop
165
+ autonomous merges. The protected merge endpoint **independently re-enforces** the
166
+ flag, so even a conductor that calls it cannot merge while the flag is off.
108
167
 
109
168
  Merge authority is **deterministic code, never an LLM**. The deterministic guards,
110
169
  all bound to **PR number + expected head SHA (never a branch name)**:
package/README.md CHANGED
@@ -37,6 +37,17 @@ store, and opens a fresh agent session to finish setup (`/install-bridge` then
37
37
  API web UI **Security** page) and a **repo name** — everything else is derived. Add
38
38
  `--dry-run` to preview every step without writing, pinging, or spawning anything.
39
39
 
40
+ **Were you sent a bootstrap invite?** Then you don't need an API key or the web UI
41
+ at all — run the command your operator gave you:
42
+
43
+ ```bash
44
+ npx -y @bridge_gpt/mcp-server@latest install-bridge --invite
45
+ ```
46
+
47
+ That one-liner is deliberately **secret-free**: the CLI prompts for the bootstrap
48
+ invite token with **echo suppressed**, and sends it only in the request body. It
49
+ creates your project and mints your own admin API key in a single command.
50
+
40
51
  To upgrade later, run:
41
52
 
42
53
  ```bash
@@ -76,19 +87,55 @@ derived). Resolution order:
76
87
  - **API key:** `--api-key <key>` → `BAPI_API_KEY` env → an interactive (no-echo)
77
88
  prompt. Generate one first on the Bridge API web UI **Security** page (see
78
89
  [Generate an API Key](#2-generate-an-api-key)); the command consumes a key, it
79
- never mints one. The key is **never printed or logged**.
90
+ never mints one **`--invite` is the one exception** (below). The key is
91
+ **never printed or logged**.
80
92
  - **Repo name:** `--repo <name>` → `BAPI_REPO_NAME` env → an inferred default you
81
93
  confirm interactively. It MUST match the server-side repository registration.
82
94
 
95
+ #### Bootstrap-invite onboarding (`--invite`)
96
+
97
+ With a **bootstrap invite** there is no pre-existing key and no web UI: this is the
98
+ one mode where `install-bridge` **creates** the project and its first admin key
99
+ instead of consuming one. Run `install-bridge --invite` and it:
100
+
101
+ 1. **Prompts for the bootstrap invite token** with echo suppressed (the default —
102
+ see below).
103
+ 2. **Generates your `key_secret`** (32 CSPRNG bytes) and **fsyncs it locally**
104
+ *before* contacting the server. If that write fails the run aborts and the
105
+ invite is **not** spent.
106
+ 3. **Redeems the invite** — `POST /setup/bootstrap` with the token, repo name, and
107
+ `key_secret` in the **body** — which creates the project and mints your admin
108
+ key. (This replaces the connectivity ping: there is no key to ping with yet.)
109
+ 4. **Verifies the newly-minted key**, then writes the per-host MCP config.
110
+ 5. **Promotes the credential** to `bapi:<repo>` and **opens the agent session** —
111
+ the same Steps 3–5 as the normal flow.
112
+
113
+ Because the locally-saved `key_secret` is the only proof that can replay a
114
+ redemption, a re-run after a network failure is safe: it re-sends the *same* secret
115
+ and gets back the *same* project and key. If the repo name you chose is already
116
+ taken (names are globally unique) the server rolls back — your invite is untouched —
117
+ and the CLI asks for a different name and retries with the same token.
118
+
119
+ **The delivered one-liner is secret-free, by design.** "A copy/paste one-liner" and
120
+ "the token never touches shell history" are contradictory, so the token is *not* in
121
+ the command: the CLI asks for it, and it travels only in the request body.
122
+ `--invite <token>`, `--invite=<token>`, and `BAPI_INVITE` still work for
123
+ **scripting only** — and both forms **expose the token to your shell history and to
124
+ the process list**. Prefer the prompt.
125
+
83
126
  Useful flags:
84
127
 
85
128
  - `--dry-run` — preview every step (scaffold targets, config files and keys with
86
129
  the key value **redacted**, the ping target, the credential store target, and
87
130
  the exact agent spawn command) without writing, pinging, or spawning anything.
88
- - `--force` overwrite an existing real `BAPI_API_KEY` in a host config without
89
- prompting (re-running is otherwise non-destructive).
131
+ With `--invite` it also never calls the exchange endpoint and never generates or
132
+ stores a secret.
133
+ - `--force` — overwrite an existing real `BAPI_API_KEY` in a host config, or in the
134
+ credential store, without prompting (re-running is otherwise non-destructive).
90
135
  - `--agent claude|cursor-agent` — which agent to launch for the agentic remainder
91
136
  (default `claude`).
137
+ - `--invite [token]` — redeem a bootstrap invite (mutually exclusive with
138
+ `--api-key`). Omit the value to get the hidden prompt.
92
139
 
93
140
  That's it — once `install-bridge` finishes you're connected. If you prefer to do
94
141
  it by hand (or just want to understand each step), the manual flow below does the
@@ -276,7 +323,14 @@ These features are useful for most tickets.
276
323
  - **What it does:** Creates one git worktree per ticket and spawns an agent session in each to implement them in parallel.
277
324
  - **When it's useful:** (Implementation | Automation) When you're ready to start building one or more refined tickets concurrently.
278
325
  - **How to use it:** `/start-tickets BAPI-248 BAPI-250` (see [CLI Subcommands](#cli-subcommands) for the full flag table and cross-platform behavior).
279
- - **Flags:** `--auto` skip the approval gates · `--base-branch <branch>` branch off something other than the default.
326
+ - **Flags:** `--auto` skip the approval gates · `--base-branch <branch>` branch off something other than the default · `--workflow implement|review-and-implement` selects which slash command each spawned worktree runs (default `implement`, byte-identical to today; `review-and-implement` runs `/review-ticket` then, after a per-ticket halt gate, `/implement-ticket` in the same session) · `--rounds=1|2` review-only, forwarded to the review phase, valid only with `--workflow review-and-implement`.
327
+
328
+ **2b. Review and Start**
329
+ - **What it does:** Spawns one worktree per ticket, each running review then (after a per-ticket human proceed/halt gate) implementation — the chained `review → gate → implement` composition.
330
+ - **When it's useful:** (Refinement | Implementation | Automation) The **recommended front door** for "review these tickets, then implement the ones that pass," starting from existing ticket keys (unlike `/full-automation`, which only accepts an idea).
331
+ - **How to use it:** `/review-and-start BAPI-248 BAPI-250` (single or multiple keys flow through the identical code path).
332
+ - **Flags:** `--auto` a single chain-level flag that auto-approves both the review and the implementation phase of every spawned session · `--rounds=1|2` forwarded to the review phase · `--agent`, `--base-branch`, `--max-parallel`, `--dry-run` mirror `/start-tickets`.
333
+ - Under the hood, this is a thin shim over `start-tickets --workflow review-and-implement <KEYS>` — the lower-level launcher seam documented above; the halt-gate decision logic lives in the spawned `/review-and-implement` session, never in this command or the CLI.
280
334
 
281
335
  **3. Brainstorm**
282
336
  - **What it does:** Fans your problem out to two different LLMs and returns their approaches directly. Runs in one of three modes, selected via `mode`: **`technical`** (default — implementation/architecture approaches), **`design`** (UI/UX and visual direction), or **`discovery`** (stakeholder discovery questions for early/vague tasks, grouped into `Technical Discovery Questions` and `Business / Stakeholder Discovery Questions` and tagged `[HUMAN]`/`[CODE]`/`[TICKET]`). Discovery needs no extra configuration. The legacy boolean `design=true` still works and maps to `mode: "design"`.
@@ -511,7 +565,7 @@ Beyond `--init` / `--upgrade`, the package ships operational subcommands of the
511
565
 
512
566
  ### `start-tickets`
513
567
 
514
- Spawns one Worktrunk worktree + selected-agent session per Jira ticket and backs the `/start-tickets` slash command. The agent defaults to **Claude Code** (`claude`) and is configurable via `--agent`.
568
+ Spawns one Worktrunk worktree + selected-agent session per Jira ticket and backs the `/start-tickets` slash command. The agent defaults to **Claude Code** (`claude`) and is configurable via `--agent`. For existing ticket keys, `/review-and-start` (see [Usage Documentation → Review and Start](#tier-1--regularly-useful)) is the recommended enriched front door over this CLI's `--workflow review-and-implement` seam; using the CLI directly (below) remains the advanced/lower-level path.
515
569
 
516
570
  ```
517
571
  npx -y @bridge_gpt/mcp-server start-tickets [flags] KEY [KEY ...]
@@ -520,6 +574,8 @@ npx -y @bridge_gpt/mcp-server start-tickets [flags] KEY [KEY ...]
520
574
  | Flag | Default | Meaning |
521
575
  |---|---|---|
522
576
  | `--agent claude\|cursor-agent` | `claude` | Agent command to launch in each worktree |
577
+ | `--workflow implement\|review-and-implement` | `implement` | Slash command each spawned worktree runs. `implement` is byte-identical to today's `/implement-ticket <KEY> [--auto]`. `review-and-implement` spawns `/review-and-implement <KEY> [--auto] [--rounds=<n>]`, which runs `/review-ticket` then, after a per-ticket halt gate, `/implement-ticket` in the same session. `--auto` applies to the selected workflow as a whole. |
578
+ | `--rounds 1\|2` | unset | Review round count forwarded to the review phase. Review-only — valid only with `--workflow review-and-implement`. |
523
579
  | `--terminal terminal\|iterm` | auto-detect via `$TERM_PROGRAM` | Override the macOS terminal app (honored on macOS only) |
524
580
  | `--dry-run` | off | Print intended actions; create no worktrees, open no tabs (any OS) |
525
581
  | `--branch KEY=BRANCH` | `feature/<KEY>` | Use a custom branch for that ticket (repeatable) |
@@ -557,9 +613,27 @@ npx -y @bridge_gpt/mcp-server doctor [--agent <name>]
557
613
 
558
614
  It is **read-only**: it never installs anything, modifies your system, adds an npm `postinstall`, spawns a terminal, or starts the MCP server, and there is no `--fix`. For each prerequisite it prints found/missing and, when missing, the exact per-OS install command **as a manual instruction you run yourself**. The checked set is the `start-tickets` preflight prerequisites **plus `uv`** **plus the selected agent's command** (`claude` by default, or `cursor-agent` with `--agent cursor-agent`). The Worktrunk binary is probed via the resolved name (honoring `BAPI_WORKTRUNK_BIN`), not a hard-coded one. **Exit code:** `0` when all required prerequisites are present, non-zero when any is missing or the platform is unsupported. A failing `start-tickets` preflight now hints you to run `doctor` for an actionable diagnostics report.
559
615
 
616
+ ### `setup-epic`
617
+
618
+ Bootstraps an Epic Conductor v2 run in one command — creates the epic run, stores the plan DAG, and approves it:
619
+
620
+ ```
621
+ npx -y @bridge_gpt/mcp-server setup-epic --epic-key <KEY> --plan-file <path-to-epic-plan.dag.json>
622
+ ```
623
+
624
+ The plan sidecar is produced by the `decompose-epic` pipeline step. `setup-epic` validates it locally (unique ticket keys, resolvable dependency references, acyclicity) before sending anything, so a malformed plan fails legibly instead of as a bare HTTP 400. It is **idempotent**: re-running it on an epic that already has a live run reuses that run rather than minting a second one. `--dry-run` validates and previews the calls without mutating anything; `--json` emits a single machine-readable result object.
625
+
626
+ Once the plan is approved, the **server-side reconciler** picks the run up within ~30s. To execute claimed jobs on your machine, run `executor`:
627
+
628
+ ```
629
+ npx -y @bridge_gpt/mcp-server executor --repo <name>
630
+ ```
631
+
560
632
  ### Conductor (epic & multi-agent orchestration)
561
633
 
562
- Conductor is an **opt-in, off-by-default** layer for epic supervision, inter-agent messaging, done-gate evaluation, local git-hook event producers, and conditional auto-merge. Its full reference — `conductor install-git-hooks`, the `conductor_done_gate` and `conductor_auto_merge_enabled` config fields, and the observability stream — lives in **[CONDUCTOR.md](./CONDUCTOR.md)**.
634
+ Conductor is an **opt-in, off-by-default** layer for epic supervision, inter-agent messaging, done-gate evaluation, local git-hook event producers, and conditional auto-merge. Its full reference — the v2 architecture (server-side reconciler + local executor), `setup-epic`, `conductor install-git-hooks`, the supervisor `done_gate_config` / `auto_merge_enabled` settings, and the observability stream — lives in **[CONDUCTOR.md](./CONDUCTOR.md)**.
635
+
636
+ > The v1 `conductor epic-tick` command is **frozen** — it throws on every invocation. There is nothing to schedule locally, and `conductor doctor` flags any epic-tick schedule left over from an earlier release so you can cancel it.
563
637
 
564
638
  ## Custom Pipelines
565
639
 
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Shared remote-base resolution (BAPI-586).
3
+ *
4
+ * A focused, executor-agnostic helper that BOTH Conductor dispatch paths use to
5
+ * validate, fetch, and pin the configured remote base commit before creating a
6
+ * fresh worktree:
7
+ *
8
+ * - the interactive `start-tickets` non-mutating dispatch path
9
+ * (`orchestrateStartTickets`), and
10
+ * - the Epic Conductor v2 executor fresh worktree path
11
+ * (`executor/worktree.ts#ensureExecutorWorktree`).
12
+ *
13
+ * Extracted out of the large `start-tickets.ts` CLI module so executor code can
14
+ * depend on a small, focused module instead of pulling in the whole CLI. The
15
+ * operation is FETCH-ONLY: it never fast-forwards, resets, or force-moves any
16
+ * local branch ref — it fetches `origin/<base>` and resolves the fetched tip to
17
+ * an immutable commit SHA, which becomes Worktrunk's `-b <start-point>`.
18
+ *
19
+ * Import direction: this module imports the shared `RunCommand` type from
20
+ * `start-tickets.js` as a TYPE-ONLY import (erased at runtime) plus the
21
+ * `commandSucceeded` value from `start-tickets-prereqs.js`. `start-tickets.ts`
22
+ * imports the VALUES here and re-exports them, so the runtime graph stays
23
+ * acyclic (mirrors the existing `worktree-core.ts` / prereqs pattern).
24
+ */
25
+ import path from "path";
26
+ import { commandSucceeded } from "./start-tickets-prereqs.js";
27
+ /**
28
+ * Returns an error string for an unsafe branch name, or null when valid.
29
+ *
30
+ * Rejects names that Git itself refuses (`git check-ref-format`) as well as
31
+ * injection-shaped inputs, WITHOUT invoking Git: an empty/whitespace-only name,
32
+ * a leading `-` (which git would parse as a flag, e.g. `--upload-pack=evil`),
33
+ * ASCII control characters, a `..` sequence, and a `.lock` suffix. Every failure
34
+ * is a short, secret-free reason string that names only the rule that failed.
35
+ */
36
+ export function validateBranchName(branch) {
37
+ if (branch.trim().length === 0)
38
+ return "branch name must not be empty.";
39
+ if (branch.length > 255)
40
+ return "branch name must be 255 characters or fewer.";
41
+ if (branch.startsWith("-"))
42
+ return "branch name must not start with '-'.";
43
+ if (branch.includes(".."))
44
+ return "branch name must not contain '..'.";
45
+ if (branch.endsWith(".lock"))
46
+ return "branch name must not end with '.lock'.";
47
+ // Reject ASCII control characters (0x00-0x1F and 0x7F) without embedding
48
+ // raw control bytes in source.
49
+ for (let i = 0; i < branch.length; i++) {
50
+ const code = branch.charCodeAt(i);
51
+ if (code <= 0x1f || code === 0x7f) {
52
+ return "branch name must not contain control characters.";
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Per-repository fetch serialization
59
+ // ---------------------------------------------------------------------------
60
+ /**
61
+ * A per-repository async mutex chain. The executor runner fills up to
62
+ * `maxConcurrent` slots against ONE shared clone, so once a `git fetch` is
63
+ * introduced on the fresh path, simultaneous fresh jobs would race on
64
+ * `FETCH_HEAD.lock` / `index.lock`. Serializing fetch-and-resolve per repository
65
+ * removes that collision while still letting independent repositories proceed
66
+ * concurrently (the map is keyed per normalized repo path, not one global lock).
67
+ */
68
+ const repoFetchLocks = new Map();
69
+ /** Normalize a repo working directory into a stable, spelling-independent key. */
70
+ function normalizeRepoKey(cwd) {
71
+ // `path.resolve` collapses `.`/`..` segments and trailing separators so
72
+ // different spellings of the same directory share one lock entry.
73
+ return path.resolve(cwd);
74
+ }
75
+ /**
76
+ * Run `fn` while holding the per-repository lock. Concurrent calls for the same
77
+ * normalized repo run strictly one at a time; calls for different repos overlap.
78
+ * The lock is released and its map entry removed in `finally` — including on a
79
+ * thrown/failed operation — so a failed fetch never deadlocks later dispatches
80
+ * or leaks repository keys.
81
+ */
82
+ async function withRepoFetchLock(repoKey, fn) {
83
+ const previous = repoFetchLocks.get(repoKey) ?? Promise.resolve();
84
+ let releaseCurrent;
85
+ const current = new Promise((resolve) => {
86
+ releaseCurrent = resolve;
87
+ });
88
+ // Later waiters chain on `previous` THEN on our gate, so they cannot start
89
+ // until we release. `current` only ever resolves, so this chain never rejects.
90
+ const chained = previous.then(() => current);
91
+ repoFetchLocks.set(repoKey, chained);
92
+ await previous.catch(() => { });
93
+ try {
94
+ return await fn();
95
+ }
96
+ finally {
97
+ releaseCurrent();
98
+ // Drop the entry only if no later waiter chained on after us (we are still
99
+ // the tail). Otherwise the newest waiter owns the key.
100
+ if (repoFetchLocks.get(repoKey) === chained) {
101
+ repoFetchLocks.delete(repoKey);
102
+ }
103
+ }
104
+ }
105
+ /** Test-only: clear all per-repository fetch locks between tests. */
106
+ export function __resetBaseRefFetchLocksForTests() {
107
+ repoFetchLocks.clear();
108
+ }
109
+ /**
110
+ * Validate `baseBranch`, then (serialized per repository) fetch
111
+ * `origin/<baseBranch>` and resolve the fetched tip to an immutable commit SHA,
112
+ * WITHOUT ever fast-forwarding or force-moving any local branch ref (no
113
+ * `git merge --ff-only`, no `git branch --force`, no `git reset`). Pinning the
114
+ * SHA removes ambiguity and prevents Worktrunk or stale local branch state from
115
+ * selecting a sibling feature branch when the resulting SHA is passed as
116
+ * Worktrunk's `-b` start point.
117
+ *
118
+ * Validation runs BEFORE the lock is acquired so an injection-shaped ref name is
119
+ * rejected without any git invocation and without touching the mutex. Both the
120
+ * `git fetch` and the `git rev-parse` run inside the same per-repository lock so
121
+ * concurrent fresh jobs cannot collide on git lock files or resolve an unrelated
122
+ * fetch result. Failures name only the configured branch and the failed
123
+ * operation — never remote URLs, credentials, raw command arguments, or
124
+ * unbounded command output.
125
+ */
126
+ export async function fetchAndResolveBaseSha(deps, baseBranch) {
127
+ const validationError = validateBranchName(baseBranch);
128
+ if (validationError) {
129
+ return { ok: false, error: `Invalid base branch '${baseBranch}': ${validationError}` };
130
+ }
131
+ const repoKey = normalizeRepoKey(deps.cwd);
132
+ return withRepoFetchLock(repoKey, async () => {
133
+ const fetch = await deps.runCommand("git", ["fetch", "origin", baseBranch], {
134
+ cwd: deps.cwd,
135
+ });
136
+ if (!commandSucceeded(fetch)) {
137
+ return {
138
+ ok: false,
139
+ error: `git fetch origin ${baseBranch} failed. Check your network and 'git remote get-url origin', or pass --no-refresh-base to skip.`,
140
+ };
141
+ }
142
+ const resolve = await deps.runCommand("git", ["rev-parse", "--verify", `origin/${baseBranch}^{commit}`], { cwd: deps.cwd });
143
+ if (!commandSucceeded(resolve)) {
144
+ return {
145
+ ok: false,
146
+ error: `Failed to resolve origin/${baseBranch} to a commit SHA after fetch (git rev-parse --verify failed).`,
147
+ };
148
+ }
149
+ return { ok: true, base_sha: resolve.stdout.trim() };
150
+ });
151
+ }