@bridge_gpt/mcp-server 0.1.13 → 0.1.16

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.
@@ -0,0 +1,1210 @@
1
+ /**
2
+ * start-tickets — packaged CLI subcommand of the bridge-api-mcp-server bin.
3
+ *
4
+ * Spawns one Worktrunk worktree + selected-agent session per Jira ticket key.
5
+ * The agent defaults to Claude Code (`claude`) and is configurable via `--agent`
6
+ * (see agent-registry.ts). This module ports the behaviour of the former repo-local
7
+ * `scripts/start-tickets.sh` into TypeScript that ships inside the
8
+ * `@bridge_gpt/mcp-server` npm package, fixing the distribution bug where
9
+ * external `--init` consumers received a `/start-tickets` slash command that
10
+ * shelled out to a script that does not exist in their checkout.
11
+ *
12
+ * ---------------------------------------------------------------------------
13
+ * Implemented worktree-creation model (BAPI-302)
14
+ * ---------------------------------------------------------------------------
15
+ * The CLI owns worktree creation; the terminal tabs only run the agent:
16
+ *
17
+ * 1. The CLI creates / switches Worktrunk worktrees up front via
18
+ * `wt switch [--create] -y <branch> --format=json` (no `-x`; `-y`
19
+ * auto-approves hooks since there is no TTY), parsing the JSON output to
20
+ * capture each worktree path and per-ticket success. On Windows the
21
+ * Worktrunk binary is `git-wt`, NOT `wt` (see platform routing below).
22
+ * 2. Worktree operations are throttled by `--max-parallel` (default 3) so the
23
+ * expensive per-worktree `pre-start` hooks (uv venv / uv pip sync) do not
24
+ * all run at once.
25
+ * 3. Only after a worktree is created does the CLI open one terminal tab per
26
+ * successful worktree, running the per-platform agent shell command for the
27
+ * selected agent (POSIX `cd '<path>' && <agent> '...'` or PowerShell
28
+ * `Set-Location ...; <agent> '...'`; the agent defaults to `claude`).
29
+ *
30
+ * ---------------------------------------------------------------------------
31
+ * Cross-platform spawning (BAPI-303)
32
+ * ---------------------------------------------------------------------------
33
+ * Spawning is routed per platform behind the injected `spawnTerminalTab`
34
+ * boundary:
35
+ *
36
+ * - macOS (darwin): Terminal.app / iTerm via `osascript`.
37
+ * - Windows (win32): Windows Terminal (`wt.exe new-tab`) with a
38
+ * `Start-Process powershell.exe` fallback when Windows Terminal is absent.
39
+ * - Linux (linux): one detached `tmux` session per ticket (pane retained
40
+ * via `; exec $SHELL`); attach with `tmux attach -t <session>`.
41
+ *
42
+ * Any other `process.platform` value fails fast with a clear "unsupported
43
+ * platform" message. `--dry-run` short-circuits before routing so it previews
44
+ * the platform-correct command form on any OS.
45
+ *
46
+ * The single highest-risk Windows detail is the `wt` name collision: Windows
47
+ * Terminal is `wt.exe` (tab launcher) while Worktrunk installs as `git-wt`
48
+ * (worktree creator). The two are resolved and referenced independently.
49
+ *
50
+ * Subprocess safety: every git / Worktrunk / AppleScript / Windows Terminal /
51
+ * PowerShell / tmux invocation goes through the injected `runCommand` boundary
52
+ * using list-based arguments (`execFile`, never `shell: true`), so all logic is
53
+ * unit-testable on Linux CI without spawning real commands or terminals.
54
+ */
55
+ import { execFile } from "child_process";
56
+ import path from "path";
57
+ // Per-OS prerequisite knowledge + low-level command probes live in the shared
58
+ // prereqs module so `runPreflight` (enforce) and the read-only `doctor` (render)
59
+ // can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
60
+ // module imports only TYPES back, so the runtime graph stays acyclic.
61
+ import { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, DEFAULT_WINDOWS_WORKTRUNK_BINARY, DEFAULT_POSIX_WORKTRUNK_BINARY, TMUX_COMMAND, GIT_FOR_WINDOWS_BASH_HINT, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, resolveWorktrunkBinary, commandSucceeded, getCommandProbe, isCommandOnPath, resolveFirstCommandOnPath, enforcePreflightPrerequisites, appendDoctorHint, } from "./start-tickets-prereqs.js";
62
+ import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
63
+ // Re-export the shared prereq surface (constants, platform helpers, command
64
+ // probes) so existing import sites that read them from "./start-tickets.js"
65
+ // keep working unchanged.
66
+ export { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, DEFAULT_WINDOWS_WORKTRUNK_BINARY, DEFAULT_POSIX_WORKTRUNK_BINARY, TMUX_COMMAND, GIT_FOR_WINDOWS_BASH_HINT, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, resolveWorktrunkBinary, commandSucceeded, getCommandProbe, isCommandOnPath, resolveFirstCommandOnPath, };
67
+ // ---------------------------------------------------------------------------
68
+ // Constants
69
+ // ---------------------------------------------------------------------------
70
+ /** Jira-style ticket key, e.g. BAPI-248. */
71
+ export const TICKET_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
72
+ /** Default cap on concurrently-created worktrees. */
73
+ export const DEFAULT_MAX_PARALLEL = 3;
74
+ /** Default tmux session-name prefix; one detached session is created per ticket. */
75
+ export const DEFAULT_TMUX_SESSION_PREFIX = "bridge-start-tickets";
76
+ /** Environment variable overriding the tmux session-name prefix. */
77
+ export const TMUX_SESSION_OVERRIDE_ENV = "BAPI_TMUX_SESSION";
78
+ // ---------------------------------------------------------------------------
79
+ // Usage / argument parsing
80
+ // ---------------------------------------------------------------------------
81
+ /** User-facing usage text for the packaged `start-tickets` subcommand. */
82
+ export function getStartTicketsUsage() {
83
+ return [
84
+ "Usage:",
85
+ " npx -y @bridge_gpt/mcp-server start-tickets [flags] KEY [KEY ...]",
86
+ "",
87
+ "Flags:",
88
+ " --agent claude|cursor-agent Agent command to launch in each worktree (default: claude)",
89
+ " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
90
+ " --dry-run Print intended actions; create no worktrees, open no tabs",
91
+ " --branch KEY=BRANCH Use BRANCH instead of feature/KEY for that ticket (repeatable)",
92
+ " --no-refresh-main Skip 'git fetch origin main' + fast-forward of local main",
93
+ " --max-parallel N Max worktrees to create concurrently (default: 3)",
94
+ " -h, --help Show this help",
95
+ "",
96
+ "Environment:",
97
+ ` ${WORKTRUNK_BINARY_OVERRIDE_ENV} Override the Worktrunk executable name/path for nonstandard installs`,
98
+ ` ${TMUX_SESSION_OVERRIDE_ENV} Override the tmux session-name prefix on Linux (default: ${DEFAULT_TMUX_SESSION_PREFIX})`,
99
+ "",
100
+ "Prerequisites:",
101
+ " macOS wt, git, osascript",
102
+ " Windows git-wt, Git for Windows / Git Bash, Windows Terminal or PowerShell",
103
+ " Linux wt, git, tmux",
104
+ "",
105
+ "Each KEY must match [A-Z]+-[0-9]+ (e.g., BAPI-248).",
106
+ ].join("\n");
107
+ }
108
+ /**
109
+ * Parse argv strictly and without an arg-parser dependency. Returns a
110
+ * discriminated result: help, a validation error, or validated options.
111
+ */
112
+ export function parseStartTicketsArgs(argv) {
113
+ // Help is honoured before any other validation.
114
+ if (argv.includes("-h") || argv.includes("--help")) {
115
+ return { status: "help", usage: getStartTicketsUsage() };
116
+ }
117
+ let terminal;
118
+ let dryRun = false;
119
+ let refreshMain = true;
120
+ let maxParallelRaw;
121
+ let agentName = DEFAULT_AGENT_NAME;
122
+ const branchEntries = [];
123
+ const keys = [];
124
+ for (let i = 0; i < argv.length; i++) {
125
+ const arg = argv[i];
126
+ const takeValue = () => {
127
+ // The `--flag=value` form is handled inline by callers; this reads the
128
+ // separate next token for the `--flag value` form.
129
+ if (i + 1 >= argv.length)
130
+ return undefined;
131
+ i += 1;
132
+ return argv[i];
133
+ };
134
+ if (arg === "--agent" || arg.startsWith("--agent=")) {
135
+ let value;
136
+ if (arg.startsWith("--agent=")) {
137
+ value = arg.slice("--agent=".length);
138
+ }
139
+ else {
140
+ value = takeValue();
141
+ if (value === undefined) {
142
+ return { status: "error", message: "--agent requires a value (an agent name)." };
143
+ }
144
+ }
145
+ if (!isAgentName(value)) {
146
+ return {
147
+ status: "error",
148
+ message: `Invalid --agent value: '${value}' (allowed agents: ${formatValidAgentNames()}).`,
149
+ };
150
+ }
151
+ agentName = value;
152
+ continue;
153
+ }
154
+ if (arg === "--terminal" || arg.startsWith("--terminal=")) {
155
+ let value;
156
+ if (arg.startsWith("--terminal=")) {
157
+ value = arg.slice("--terminal=".length);
158
+ }
159
+ else {
160
+ value = takeValue();
161
+ if (value === undefined) {
162
+ return { status: "error", message: "--terminal requires a value (terminal or iterm)." };
163
+ }
164
+ }
165
+ if (value !== "terminal" && value !== "iterm") {
166
+ return {
167
+ status: "error",
168
+ message: `Invalid --terminal value: '${value}' (allowed values: terminal, iterm).`,
169
+ };
170
+ }
171
+ terminal = value;
172
+ continue;
173
+ }
174
+ if (arg === "--max-parallel" || arg.startsWith("--max-parallel=")) {
175
+ if (arg.startsWith("--max-parallel=")) {
176
+ maxParallelRaw = arg.slice("--max-parallel=".length);
177
+ }
178
+ else {
179
+ const value = takeValue();
180
+ if (value === undefined) {
181
+ return { status: "error", message: "--max-parallel requires a positive integer value." };
182
+ }
183
+ maxParallelRaw = value;
184
+ }
185
+ continue;
186
+ }
187
+ if (arg === "--branch" || arg.startsWith("--branch=")) {
188
+ let value;
189
+ if (arg.startsWith("--branch=")) {
190
+ value = arg.slice("--branch=".length);
191
+ }
192
+ else {
193
+ value = takeValue();
194
+ if (value === undefined) {
195
+ return { status: "error", message: "--branch requires a KEY=BRANCH value." };
196
+ }
197
+ }
198
+ branchEntries.push(value);
199
+ continue;
200
+ }
201
+ if (arg === "--dry-run") {
202
+ dryRun = true;
203
+ continue;
204
+ }
205
+ if (arg === "--no-refresh-main") {
206
+ refreshMain = false;
207
+ continue;
208
+ }
209
+ if (arg.startsWith("-")) {
210
+ return { status: "error", message: `Unknown flag: ${arg}` };
211
+ }
212
+ keys.push(arg);
213
+ }
214
+ // --- key validation ---
215
+ if (keys.length === 0) {
216
+ return {
217
+ status: "error",
218
+ message: "At least one ticket key is required (e.g., BAPI-248).",
219
+ };
220
+ }
221
+ const seen = new Set();
222
+ for (const key of keys) {
223
+ if (!TICKET_KEY_PATTERN.test(key)) {
224
+ return {
225
+ status: "error",
226
+ message: `Invalid ticket key: '${key}' (keys must match [A-Z]+-[0-9]+, e.g., BAPI-248).`,
227
+ };
228
+ }
229
+ if (seen.has(key)) {
230
+ return { status: "error", message: `Duplicate ticket key: '${key}'.` };
231
+ }
232
+ seen.add(key);
233
+ }
234
+ // --- max-parallel validation ---
235
+ let maxParallel = DEFAULT_MAX_PARALLEL;
236
+ if (maxParallelRaw !== undefined) {
237
+ if (!/^[0-9]+$/.test(maxParallelRaw) || Number(maxParallelRaw) < 1) {
238
+ return {
239
+ status: "error",
240
+ message: `Invalid --max-parallel value: '${maxParallelRaw}' (must be a positive integer).`,
241
+ };
242
+ }
243
+ maxParallel = Number(maxParallelRaw);
244
+ }
245
+ // --- branch override validation ---
246
+ const branchOverrides = {};
247
+ for (const entry of branchEntries) {
248
+ const sepIndex = entry.indexOf("=");
249
+ if (sepIndex <= 0) {
250
+ return {
251
+ status: "error",
252
+ message: `Invalid --branch override: '${entry}' (expected KEY=BRANCH).`,
253
+ };
254
+ }
255
+ const overrideKey = entry.slice(0, sepIndex);
256
+ const branchName = entry.slice(sepIndex + 1);
257
+ if (!TICKET_KEY_PATTERN.test(overrideKey)) {
258
+ return {
259
+ status: "error",
260
+ message: `Invalid --branch override key: '${overrideKey}' (keys must match [A-Z]+-[0-9]+).`,
261
+ };
262
+ }
263
+ if (!seen.has(overrideKey)) {
264
+ return {
265
+ status: "error",
266
+ message: `--branch override key '${overrideKey}' is not one of the requested tickets.`,
267
+ };
268
+ }
269
+ const branchError = validateBranchName(branchName);
270
+ if (branchError) {
271
+ return {
272
+ status: "error",
273
+ message: `Invalid branch name for ${overrideKey}: ${branchError}`,
274
+ };
275
+ }
276
+ branchOverrides[overrideKey] = branchName;
277
+ }
278
+ return {
279
+ status: "ok",
280
+ options: { keys, terminal, dryRun, refreshMain, maxParallel, branchOverrides, agentName },
281
+ };
282
+ }
283
+ /** Returns an error string for an unsafe branch name, or null when valid. */
284
+ function validateBranchName(branch) {
285
+ if (branch.length === 0)
286
+ return "branch name must not be empty.";
287
+ if (branch.startsWith("-"))
288
+ return "branch name must not start with '-'.";
289
+ // Reject ASCII control characters (0x00-0x1F and 0x7F) without embedding
290
+ // raw control bytes in source.
291
+ for (let i = 0; i < branch.length; i++) {
292
+ const code = branch.charCodeAt(i);
293
+ if (code <= 0x1f || code === 0x7f) {
294
+ return "branch name must not contain control characters.";
295
+ }
296
+ }
297
+ return null;
298
+ }
299
+ /** Resolve the branch for a ticket: explicit override, else feature/<KEY>. */
300
+ export function resolveBranchForTicket(key, overrides) {
301
+ if (Object.prototype.hasOwnProperty.call(overrides, key)) {
302
+ return overrides[key];
303
+ }
304
+ return `feature/${key}`;
305
+ }
306
+ /**
307
+ * Determine which macOS terminal to drive. An explicit choice wins; otherwise
308
+ * auto-detect iTerm from `$TERM_PROGRAM` (case-insensitive), defaulting to
309
+ * Terminal.app. Only consumed by the macOS spawner; ignored on Windows/Linux.
310
+ */
311
+ export function detectTerminal(explicit, env) {
312
+ if (explicit)
313
+ return explicit;
314
+ const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase();
315
+ if (termProgram.includes("iterm"))
316
+ return "iterm";
317
+ return "terminal";
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ // Platform routing
321
+ // ---------------------------------------------------------------------------
322
+ //
323
+ // `isSupportedStartTicketsPlatform`, `unsupportedPlatformMessage`, and
324
+ // `resolveWorktrunkBinary` now live in ./start-tickets-prereqs.js (imported and
325
+ // re-exported above) so preflight enforcement and the doctor share them.
326
+ /** Map a platform to its default terminal spawner. */
327
+ export function getDefaultSpawnTerminalTabForPlatform(platform) {
328
+ switch (platform) {
329
+ case "darwin":
330
+ return spawnMacOSTerminalTab;
331
+ case "win32":
332
+ return spawnWindowsTerminalTab;
333
+ case "linux":
334
+ return spawnLinuxTmuxTerminalTab;
335
+ default:
336
+ return spawnUnsupportedPlatformTerminalTab;
337
+ }
338
+ }
339
+ /**
340
+ * The single source of truth for platform selection. Resolves the Worktrunk
341
+ * binary, closes over the selected `agent` to build the per-platform shell
342
+ * command, and preserves the injected `deps.spawnTerminalTab` (so mocks/overrides
343
+ * are always honored). Returns a structured error for unsupported platforms;
344
+ * never throws.
345
+ */
346
+ export function resolveStartTicketsPlatformConfig(deps, agent) {
347
+ if (!isSupportedStartTicketsPlatform(deps.platform)) {
348
+ return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
349
+ }
350
+ const platform = deps.platform;
351
+ return {
352
+ ok: true,
353
+ config: {
354
+ platform,
355
+ worktrunkBinary: resolveWorktrunkBinary(platform, deps.env),
356
+ buildAgentShellCommand: (key, worktreePath) => buildAgentShellCommand(agent, key, worktreePath, platform),
357
+ spawnTerminalTab: deps.spawnTerminalTab,
358
+ },
359
+ };
360
+ }
361
+ // ---------------------------------------------------------------------------
362
+ // Pure escaping + slug helpers
363
+ // ---------------------------------------------------------------------------
364
+ /**
365
+ * Escape a string for inclusion inside a single-quoted shell context: each
366
+ * embedded single-quote becomes the sequence `'\''`. Returns only the inner
367
+ * escaped text (no surrounding quotes). Mirrors the bash `sh_squote_inner`.
368
+ */
369
+ export function shSquoteInner(value) {
370
+ return value.replace(/'/g, "'\\''");
371
+ }
372
+ /**
373
+ * Escape a string for inclusion inside an AppleScript double-quoted literal:
374
+ * backslashes are doubled first, then double quotes are backslash-escaped.
375
+ * Returns only the inner escaped text (no surrounding quotes). Mirrors the
376
+ * bash `applescript_dquote_inner`.
377
+ */
378
+ export function applescriptDquoteInner(value) {
379
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
380
+ }
381
+ /**
382
+ * Escape a string for inclusion inside a PowerShell single-quoted literal: each
383
+ * embedded single-quote is doubled (`'` -> `''`). Returns only the inner
384
+ * escaped text (no surrounding quotes). Mirrors the POSIX `shSquoteInner`
385
+ * pattern for the PowerShell quoting rules.
386
+ */
387
+ export function powershellSquoteInner(value) {
388
+ return value.replace(/'/g, "''");
389
+ }
390
+ /** Wrap `value` in a PowerShell single-quoted literal, escaping inner quotes. */
391
+ export function powershellSquote(value) {
392
+ return `'${powershellSquoteInner(value)}'`;
393
+ }
394
+ /**
395
+ * Slugify a ticket summary into a branch-name fragment. Mirrors the slash
396
+ * command's enrichment rules so behaviour stays consistent; the CLI itself
397
+ * does NOT call the Bridge API — enrichment happens in the slash command and
398
+ * is passed in via `--branch` overrides.
399
+ *
400
+ * Rules: lowercase, collapse runs of [^a-z0-9]+ to a single '-', trim leading
401
+ * and trailing '-', truncate to at most 40 chars preferring a dash boundary.
402
+ * Returns "" when no usable slug remains.
403
+ */
404
+ export function slugifyTicketSummaryForBranch(summary) {
405
+ let slug = summary
406
+ .toLowerCase()
407
+ .replace(/[^a-z0-9]+/g, "-")
408
+ .replace(/^-+/, "")
409
+ .replace(/-+$/, "");
410
+ if (slug.length <= 40)
411
+ return slug;
412
+ let truncated = slug.slice(0, 40);
413
+ const lastDash = truncated.lastIndexOf("-");
414
+ if (lastDash > 0) {
415
+ truncated = truncated.slice(0, lastDash);
416
+ }
417
+ return truncated.replace(/-+$/, "");
418
+ }
419
+ // ---------------------------------------------------------------------------
420
+ // Subprocess boundary
421
+ // ---------------------------------------------------------------------------
422
+ /** Build the default dependency set backed by real subprocesses / process state. */
423
+ export function createDefaultStartTicketsDeps() {
424
+ const runCommand = (file, args, options) => new Promise((resolve) => {
425
+ execFile(file, args, {
426
+ cwd: options?.cwd,
427
+ // Worktrunk JSON / git porcelain output can be large; give it room.
428
+ maxBuffer: 64 * 1024 * 1024,
429
+ encoding: "utf-8",
430
+ }, (error, stdout, stderr) => {
431
+ const exitCode = error && typeof error.code === "number"
432
+ ? (error.code)
433
+ : error
434
+ ? 1
435
+ : 0;
436
+ resolve({
437
+ stdout: stdout ?? "",
438
+ stderr: stderr ?? "",
439
+ exitCode,
440
+ });
441
+ });
442
+ });
443
+ const deps = {
444
+ runCommand,
445
+ platform: process.platform,
446
+ env: process.env,
447
+ cwd: process.cwd(),
448
+ // The platform router is the single source of truth for spawner selection.
449
+ spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
450
+ };
451
+ return deps;
452
+ }
453
+ /** Combine a result's stderr + stdout into a single trimmed detail string. */
454
+ function combineCommandOutput(result) {
455
+ return [result.stderr, result.stdout]
456
+ .map((s) => s.trim())
457
+ .filter(Boolean)
458
+ .join(" ");
459
+ }
460
+ /**
461
+ * Verify a tool is on PATH via the platform-correct probe. An optional
462
+ * `installHint` is appended to the failure message. Thin wrapper around the
463
+ * shared `isCommandOnPath`; retained for callers/tests that prefer a structured
464
+ * `VoidResult` over a boolean.
465
+ */
466
+ export async function requireCommandOnPath(deps, tool, installHint) {
467
+ if (await isCommandOnPath(deps, tool))
468
+ return { ok: true };
469
+ const hint = installHint ? ` ${installHint}` : "";
470
+ return { ok: false, error: `Required command not found on PATH: ${tool}.${hint}` };
471
+ }
472
+ /** Succeed when any candidate is on PATH; otherwise return `errorMessage`. */
473
+ export async function requireAnyCommandOnPath(deps, candidates, errorMessage) {
474
+ const found = await resolveFirstCommandOnPath(deps, candidates);
475
+ if (found)
476
+ return { ok: true };
477
+ return { ok: false, error: errorMessage };
478
+ }
479
+ /**
480
+ * Minimal, fail-fast, per-platform pre-flight. Skipped entirely for dry runs.
481
+ * Delegates the per-OS prerequisite knowledge (worktrunk binary, git, the
482
+ * platform launchers, Git Bash, and the git work-tree check) to the shared
483
+ * `enforcePreflightPrerequisites`, so `runPreflight` and the read-only `doctor`
484
+ * never drift. A dependency-missing failure is annotated with a hint to run the
485
+ * read-only `doctor`; an unsupported-platform failure is returned as-is (doctor
486
+ * cannot fix it, and orchestration/CLI tests assert the bare message). `uv` and
487
+ * the selected agent are NOT enforced here — they are doctor-only. Never throws.
488
+ */
489
+ export async function runPreflight(deps, options) {
490
+ if (options.dryRun)
491
+ return { ok: true };
492
+ const result = await enforcePreflightPrerequisites(deps);
493
+ if (result.ok)
494
+ return { ok: true };
495
+ if (result.reason === "unsupported-platform") {
496
+ return { ok: false, error: result.error };
497
+ }
498
+ return { ok: false, error: appendDoctorHint(result.error) };
499
+ }
500
+ // ---------------------------------------------------------------------------
501
+ // main refresh + worktree parsing
502
+ // ---------------------------------------------------------------------------
503
+ /** Parse `git worktree list --porcelain` into entries with path + optional branch. */
504
+ export function parseGitWorktreeList(output) {
505
+ const entries = [];
506
+ let current = null;
507
+ for (const rawLine of output.split("\n")) {
508
+ const line = rawLine.replace(/\r$/, "");
509
+ if (line.startsWith("worktree ")) {
510
+ if (current)
511
+ entries.push(current);
512
+ current = { path: line.slice("worktree ".length) };
513
+ }
514
+ else if (line.startsWith("branch ") && current) {
515
+ const ref = line.slice("branch ".length);
516
+ current.branch = ref.startsWith("refs/heads/")
517
+ ? ref.slice("refs/heads/".length)
518
+ : ref;
519
+ }
520
+ }
521
+ if (current)
522
+ entries.push(current);
523
+ return entries;
524
+ }
525
+ /** Return the worktree path owning branch `main`, or null. */
526
+ export function findMainWorktreePath(entries) {
527
+ for (const entry of entries) {
528
+ if (entry.branch === "main")
529
+ return entry.path;
530
+ }
531
+ return null;
532
+ }
533
+ /**
534
+ * Fetch origin/main and fast-forward local `main`. No-op when `refreshMain` is
535
+ * false. Handles both checkout states:
536
+ *
537
+ * - `main` IS checked out in a worktree → `git merge --ff-only` inside that
538
+ * worktree, so its index/working tree stay consistent (git forbids
539
+ * force-moving a checked-out branch ref).
540
+ * - `main` is NOT checked out anywhere (e.g. the primary checkout is on a
541
+ * feature/chore branch) → fast-forward the ref directly with
542
+ * `git branch --force`, since no working tree depends on it. This is guarded
543
+ * by an ancestry check so a diverged local `main` is never clobbered, and it
544
+ * creates `main` from origin/main when no local ref exists yet.
545
+ *
546
+ * Returns a structured failure for any expected problem (fetch failure, diverged
547
+ * main, or a failed ref update).
548
+ */
549
+ export async function refreshMainBranch(deps, options) {
550
+ if (!options.refreshMain)
551
+ return { ok: true };
552
+ const fetch = await deps.runCommand("git", ["fetch", "origin", "main"], {
553
+ cwd: deps.cwd,
554
+ });
555
+ if (!commandSucceeded(fetch)) {
556
+ return {
557
+ ok: false,
558
+ error: "git fetch origin main failed. Check your network and 'git remote get-url origin', or pass --no-refresh-main to skip.",
559
+ };
560
+ }
561
+ const list = await deps.runCommand("git", ["worktree", "list", "--porcelain"], { cwd: deps.cwd });
562
+ if (!commandSucceeded(list)) {
563
+ return {
564
+ ok: false,
565
+ error: "git worktree list --porcelain failed; cannot locate the main worktree.",
566
+ };
567
+ }
568
+ const mainPath = findMainWorktreePath(parseGitWorktreeList(list.stdout));
569
+ // `main` is checked out somewhere: fast-forward it in place. git refuses to
570
+ // force-move a checked-out branch ref, so we must go through merge.
571
+ if (mainPath) {
572
+ const merge = await deps.runCommand("git", ["merge", "--ff-only", "origin/main"], { cwd: mainPath });
573
+ if (!commandSucceeded(merge)) {
574
+ return {
575
+ ok: false,
576
+ error: `Local main has diverged from origin/main (checked out at ${mainPath}). Resolve the divergence manually, or rerun with --no-refresh-main.`,
577
+ };
578
+ }
579
+ return { ok: true };
580
+ }
581
+ // `main` is not checked out in any worktree. No working tree depends on the
582
+ // ref, so fast-forward it directly — but only when it is a true fast-forward,
583
+ // never clobbering local-only commits. When local `main` does not exist yet,
584
+ // create it from origin/main.
585
+ if (await branchExists(deps, "main")) {
586
+ const ancestor = await deps.runCommand("git", ["merge-base", "--is-ancestor", "main", "origin/main"], { cwd: deps.cwd });
587
+ if (!commandSucceeded(ancestor)) {
588
+ return {
589
+ ok: false,
590
+ error: "Local main has diverged from origin/main. Resolve the divergence manually, or rerun with --no-refresh-main.",
591
+ };
592
+ }
593
+ }
594
+ const update = await deps.runCommand("git", ["branch", "--force", "main", "origin/main"], { cwd: deps.cwd });
595
+ if (!commandSucceeded(update)) {
596
+ return {
597
+ ok: false,
598
+ error: "Failed to fast-forward local main to origin/main. Resolve manually, or rerun with --no-refresh-main.",
599
+ };
600
+ }
601
+ return { ok: true };
602
+ }
603
+ // ---------------------------------------------------------------------------
604
+ // Concurrency + worktree creation
605
+ // ---------------------------------------------------------------------------
606
+ /**
607
+ * Run `worker` over `items` with at most `limit` concurrent workers, preserving
608
+ * result order regardless of completion order.
609
+ */
610
+ export async function runWithConcurrency(items, limit, worker) {
611
+ const results = new Array(items.length);
612
+ const effectiveLimit = Math.max(1, Math.floor(limit));
613
+ let nextIndex = 0;
614
+ async function runner() {
615
+ while (true) {
616
+ const index = nextIndex;
617
+ if (index >= items.length)
618
+ return;
619
+ nextIndex += 1;
620
+ results[index] = await worker(items[index], index);
621
+ }
622
+ }
623
+ const runners = [];
624
+ const poolSize = Math.min(effectiveLimit, items.length);
625
+ for (let i = 0; i < poolSize; i++) {
626
+ runners.push(runner());
627
+ }
628
+ await Promise.all(runners);
629
+ return results;
630
+ }
631
+ /** True only when `git show-ref --verify --quiet refs/heads/<branch>` exits 0. */
632
+ export async function branchExists(deps, branch) {
633
+ const result = await deps.runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: deps.cwd });
634
+ return commandSucceeded(result);
635
+ }
636
+ /**
637
+ * Build `wt switch` args. Include `--create` only when the branch does not yet
638
+ * exist. Always include `-y` (auto-approve): the CLI runs the Worktrunk binary
639
+ * via `execFile` with no TTY, so without `-y` the `pre-start` / `post-start`
640
+ * hook-approval prompts would have no input source and the call would hang or
641
+ * fail — this mirrors the deleted `scripts/start-tickets.sh`, which ran
642
+ * `wt switch -c -y`. Never includes `-x` — the CLI owns worktree creation and
643
+ * tab spawning separately. The args are platform-agnostic; only the binary
644
+ * differs (`wt` vs `git-wt`).
645
+ */
646
+ export function buildWtSwitchArgs(branch, exists) {
647
+ if (exists) {
648
+ return ["switch", "-y", branch, "--format=json"];
649
+ }
650
+ return ["switch", "--create", "-y", branch, "--format=json"];
651
+ }
652
+ /** Return the Node `path` API matching a platform (`win32` vs POSIX). */
653
+ export function pathApiForPlatform(platform) {
654
+ return platform === "win32" ? path.win32 : path.posix;
655
+ }
656
+ /**
657
+ * Extract a worktree path from Worktrunk JSON stdout. Accepts several common
658
+ * shapes defensively and resolves relative paths against `cwd` using the
659
+ * platform-correct path semantics (so Windows-style paths resolve correctly
660
+ * even when the test/host OS differs). Throws when no usable path can be
661
+ * extracted.
662
+ */
663
+ export function extractWorktreePath(stdout, cwd, platform = process.platform) {
664
+ let parsed;
665
+ try {
666
+ parsed = JSON.parse(stdout);
667
+ }
668
+ catch {
669
+ throw new Error(`Could not parse Worktrunk JSON output: ${stdout.slice(0, 200)}`);
670
+ }
671
+ const candidate = pickWorktreePathField(parsed);
672
+ if (!candidate) {
673
+ throw new Error(`Worktrunk JSON did not include a worktree path: ${stdout.slice(0, 200)}`);
674
+ }
675
+ const pathApi = pathApiForPlatform(platform);
676
+ return pathApi.isAbsolute(candidate) ? candidate : pathApi.resolve(cwd, candidate);
677
+ }
678
+ function pickWorktreePathField(parsed) {
679
+ if (!parsed || typeof parsed !== "object")
680
+ return undefined;
681
+ const obj = parsed;
682
+ if (typeof obj.path === "string")
683
+ return obj.path;
684
+ if (typeof obj.worktree_path === "string")
685
+ return obj.worktree_path;
686
+ if (typeof obj.directory === "string")
687
+ return obj.directory;
688
+ if (obj.worktree && typeof obj.worktree === "object") {
689
+ const nested = obj.worktree;
690
+ if (typeof nested.path === "string")
691
+ return nested.path;
692
+ }
693
+ return undefined;
694
+ }
695
+ /**
696
+ * Create / switch the worktree for a single ticket using the resolved Worktrunk
697
+ * binary (`wt` on macOS/Linux, `git-wt` on Windows). Returns a `created` row on
698
+ * success (with key, branch, path) or a `create-failed` row on any expected
699
+ * failure — never throws for per-ticket problems.
700
+ */
701
+ export async function createWorktreeForTicket(deps, key, branchOverrides, worktrunkBinary) {
702
+ const branch = resolveBranchForTicket(key, branchOverrides);
703
+ try {
704
+ const exists = await branchExists(deps, branch);
705
+ const args = buildWtSwitchArgs(branch, exists);
706
+ const result = await deps.runCommand(worktrunkBinary, args, { cwd: deps.cwd });
707
+ if (!commandSucceeded(result)) {
708
+ const reason = (result.stderr || result.stdout || "").trim();
709
+ return {
710
+ key,
711
+ branch,
712
+ status: "create-failed",
713
+ error: `${worktrunkBinary} ${args.join(" ")} failed${reason ? `: ${reason}` : ""}`,
714
+ };
715
+ }
716
+ const worktreePath = extractWorktreePath(result.stdout, deps.cwd, deps.platform);
717
+ return { key, branch, status: "created", path: worktreePath };
718
+ }
719
+ catch (err) {
720
+ const message = err instanceof Error ? err.message : String(err);
721
+ return { key, branch, status: "create-failed", error: message };
722
+ }
723
+ }
724
+ /**
725
+ * Create / switch worktrees for every ticket, throttled to `maxParallel`, using
726
+ * the resolved Worktrunk binary. Returns one row per ticket in original key
727
+ * order; per-ticket failures are recorded as `create-failed` rows rather than
728
+ * aborting the run.
729
+ */
730
+ export async function createWorktrees(deps, options, worktrunkBinary) {
731
+ return runWithConcurrency(options.keys, options.maxParallel, (key) => createWorktreeForTicket(deps, key, options.branchOverrides, worktrunkBinary));
732
+ }
733
+ // ---------------------------------------------------------------------------
734
+ // Per-platform shell-command construction
735
+ // ---------------------------------------------------------------------------
736
+ /** The starter prompt handed to the selected agent. Identical for every agent. */
737
+ export function buildAgentPrompt(key) {
738
+ return `/implement-ticket ${key}`;
739
+ }
740
+ /**
741
+ * Build the agent invocation (`<command> <quotedPrompt>`) for the agent's prompt
742
+ * style. Only `positional` exists today; the `switch` keeps a future flag-style
743
+ * agent a typed extension rather than a silent fall-through. `quote` applies the
744
+ * platform-correct quoting to the prompt.
745
+ */
746
+ export function buildAgentInvocation(agent, prompt, quote) {
747
+ switch (agent.promptArgStyle) {
748
+ case "positional":
749
+ return `${agent.command} ${quote(prompt)}`;
750
+ default: {
751
+ const exhaustive = agent.promptArgStyle;
752
+ throw new Error(`Unsupported agent promptArgStyle: ${String(exhaustive)}`);
753
+ }
754
+ }
755
+ }
756
+ /** POSIX agent shell command: `cd '<path>' && <agent> '<prompt>'`. */
757
+ export function buildPosixAgentShellCommand(agent, key, worktreePath) {
758
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key), (p) => `'${shSquoteInner(p)}'`);
759
+ return `cd '${shSquoteInner(worktreePath)}' && ${invocation}`;
760
+ }
761
+ /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> '<prompt>'`. */
762
+ export function buildPowerShellAgentShellCommand(agent, key, worktreePath) {
763
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key), powershellSquote);
764
+ return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${invocation}`;
765
+ }
766
+ /**
767
+ * Build the shell command run inside each spawned tab/session, dispatched by
768
+ * platform. PowerShell on Windows; POSIX everywhere else (incl. the unsupported
769
+ * dry-run fallback). The selected `agent` (never a module-level constant)
770
+ * determines the launched command.
771
+ */
772
+ export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin") {
773
+ if (platform === "win32")
774
+ return buildPowerShellAgentShellCommand(agent, key, worktreePath);
775
+ return buildPosixAgentShellCommand(agent, key, worktreePath);
776
+ }
777
+ // ---------------------------------------------------------------------------
778
+ // macOS terminal spawning (behind the injected boundary)
779
+ // ---------------------------------------------------------------------------
780
+ /** Generate AppleScript that runs `shellCommand` in a Terminal.app tab. */
781
+ export function buildTerminalAppleScript(shellCommand) {
782
+ const esc = applescriptDquoteInner(shellCommand);
783
+ return [
784
+ 'tell application "Terminal"',
785
+ " activate",
786
+ " if (count of windows) is 0 then",
787
+ ` do script "${esc}"`,
788
+ " else",
789
+ ' tell application "System Events" to keystroke "t" using command down',
790
+ " delay 0.2",
791
+ ` do script "${esc}" in selected tab of front window`,
792
+ " end if",
793
+ "end tell",
794
+ ].join("\n");
795
+ }
796
+ /** Generate AppleScript that runs `shellCommand` in an iTerm2 tab. */
797
+ export function buildITermAppleScript(shellCommand) {
798
+ const esc = applescriptDquoteInner(shellCommand);
799
+ return [
800
+ 'tell application "iTerm"',
801
+ " activate",
802
+ " if (count of windows) = 0 then",
803
+ " set newWindow to (create window with default profile)",
804
+ ` tell current session of newWindow to write text "${esc}"`,
805
+ " else",
806
+ " tell current window",
807
+ " set newTab to (create tab with default profile)",
808
+ ` tell current session of newTab to write text "${esc}"`,
809
+ " end tell",
810
+ " end if",
811
+ "end tell",
812
+ ].join("\n");
813
+ }
814
+ /**
815
+ * Spawn a single macOS terminal tab running `shellCommand`. Selects the
816
+ * AppleScript builder by terminal choice and runs it via `osascript -e`. The
817
+ * optional `context` is accepted to satisfy the shared spawner signature but is
818
+ * unused on macOS. Returns a structured failure for expected spawn errors
819
+ * (never throws).
820
+ */
821
+ export async function spawnMacOSTerminalTab(deps, terminal, shellCommand, _context) {
822
+ const script = terminal === "iterm"
823
+ ? buildITermAppleScript(shellCommand)
824
+ : buildTerminalAppleScript(shellCommand);
825
+ const result = await deps.runCommand("osascript", ["-e", script]);
826
+ if (commandSucceeded(result))
827
+ return { ok: true };
828
+ const reason = (result.stderr || result.stdout || "").trim();
829
+ return {
830
+ ok: false,
831
+ error: `osascript failed to open a ${terminal} tab${reason ? `: ${reason}` : ""}`,
832
+ };
833
+ }
834
+ // ---------------------------------------------------------------------------
835
+ // Windows terminal spawning (behind the injected boundary)
836
+ // ---------------------------------------------------------------------------
837
+ /**
838
+ * List-based Windows Terminal args: open at `-d <path>` and run PowerShell.
839
+ *
840
+ * `wt.exe` parses `;` inside ANY argv item as a sub-command delimiter, so a raw
841
+ * `Set-Location ...; claude ...` PowerShell command would be split — the tab
842
+ * would only run `Set-Location` and the agent would never launch. Windows
843
+ * Terminal's documented escape for a literal semicolon is `\;`; wt.exe unescapes
844
+ * it back to `;` before handing the command to PowerShell. We therefore escape
845
+ * every `;` in the PowerShell command passed to wt.exe. (The PowerShell
846
+ * `Start-Process` fallback below does its own quoting via `-ArgumentList` and is
847
+ * unaffected.)
848
+ */
849
+ export function buildWindowsTerminalArgs(worktreePath, shellCommand) {
850
+ const wtEscapedCommand = shellCommand.replace(/;/g, "\\;");
851
+ return ["new-tab", "-d", worktreePath, "powershell.exe", "-NoExit", "-Command", wtEscapedCommand];
852
+ }
853
+ /**
854
+ * Build the PowerShell `Start-Process` command used as the no-Windows-Terminal
855
+ * fallback. `Start-Process` opens a detached, visible window per ticket (a bare
856
+ * `powershell.exe -Command` invoked through `execFile` cannot). Nested quoting
857
+ * uses PowerShell single-quote escaping.
858
+ */
859
+ export function buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand) {
860
+ const argumentList = `@('-NoExit', '-Command', ${powershellSquote(shellCommand)})`;
861
+ return (`Start-Process -FilePath 'powershell.exe' ` +
862
+ `-WorkingDirectory ${powershellSquote(worktreePath)} ` +
863
+ `-ArgumentList ${argumentList}`);
864
+ }
865
+ /**
866
+ * Spawn a Windows tab. Prefers Windows Terminal (`wt.exe new-tab`); falls back
867
+ * to `Start-Process powershell.exe` when Windows Terminal is absent. Never uses
868
+ * the Worktrunk `git-wt` binary for tab launching. Returns a structured failure
869
+ * (never throws).
870
+ */
871
+ export async function spawnWindowsTerminalTab(deps, _terminal, shellCommand, context) {
872
+ const worktreePath = context?.worktreePath;
873
+ if (!worktreePath) {
874
+ return {
875
+ ok: false,
876
+ error: "Windows spawner requires a worktreePath context to open a tab.",
877
+ };
878
+ }
879
+ if (await isCommandOnPath(deps, WINDOWS_TERMINAL_COMMAND)) {
880
+ const args = buildWindowsTerminalArgs(worktreePath, shellCommand);
881
+ const result = await deps.runCommand(WINDOWS_TERMINAL_COMMAND, args);
882
+ if (commandSucceeded(result))
883
+ return { ok: true };
884
+ const reason = combineCommandOutput(result);
885
+ return {
886
+ ok: false,
887
+ error: `wt.exe failed to open a Windows Terminal tab${reason ? `: ${reason}` : ""}`,
888
+ };
889
+ }
890
+ const powershell = await resolveFirstCommandOnPath(deps, WINDOWS_POWERSHELL_CANDIDATES);
891
+ if (!powershell) {
892
+ return {
893
+ ok: false,
894
+ error: "Windows Terminal (wt.exe) or PowerShell is required to open a tab, but neither was found on PATH.",
895
+ };
896
+ }
897
+ const fallback = buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand);
898
+ const result = await deps.runCommand(powershell, [
899
+ "-NoProfile",
900
+ "-ExecutionPolicy",
901
+ "Bypass",
902
+ "-Command",
903
+ fallback,
904
+ ]);
905
+ if (commandSucceeded(result))
906
+ return { ok: true };
907
+ const reason = combineCommandOutput(result);
908
+ return {
909
+ ok: false,
910
+ error: `PowerShell failed to open a window via Start-Process${reason ? `: ${reason}` : ""}`,
911
+ };
912
+ }
913
+ // ---------------------------------------------------------------------------
914
+ // Linux tmux spawning (behind the injected boundary)
915
+ // ---------------------------------------------------------------------------
916
+ /** Sanitize a value into a tmux-safe identifier (no whitespace, '.', or ':'). */
917
+ export function sanitizeTmuxName(value) {
918
+ const cleaned = value
919
+ .replace(/[^A-Za-z0-9_-]+/g, "-")
920
+ .replace(/^-+/, "")
921
+ .replace(/-+$/, "");
922
+ return cleaned.length > 0 ? cleaned : "ticket";
923
+ }
924
+ /** Safe tmux window name derived from the ticket key. */
925
+ export function tmuxWindowNameForTicket(key) {
926
+ return sanitizeTmuxName(key);
927
+ }
928
+ /** Resolve the tmux session-name prefix (env override, else the default). */
929
+ export function tmuxSessionPrefix(deps) {
930
+ const override = deps.env[TMUX_SESSION_OVERRIDE_ENV];
931
+ if (override !== undefined) {
932
+ const trimmed = override.trim();
933
+ if (trimmed.length > 0)
934
+ return trimmed;
935
+ }
936
+ return DEFAULT_TMUX_SESSION_PREFIX;
937
+ }
938
+ /** One detached tmux session per ticket: `<prefix>-<safe-key>`. */
939
+ export function tmuxSessionNameForTicket(deps, key) {
940
+ return `${tmuxSessionPrefix(deps)}-${sanitizeTmuxName(key)}`;
941
+ }
942
+ /**
943
+ * Wrap the agent shell command so the tmux pane stays open after the agent
944
+ * exits or crashes (otherwise tmux closes the window and the user loses all
945
+ * output). `exec $SHELL` replaces the pane with an interactive login shell.
946
+ */
947
+ export function buildTmuxPaneCommand(shellCommand) {
948
+ return `${shellCommand}; exec $SHELL`;
949
+ }
950
+ /** Args for `tmux new-session -d -s <session> -n <window> -c <path> <cmd>`. */
951
+ export function buildTmuxNewSessionArgs(session, window, worktreePath, paneCommand) {
952
+ return ["new-session", "-d", "-s", session, "-n", window, "-c", worktreePath, paneCommand];
953
+ }
954
+ /** Args for `tmux new-window -t <session> -n <window> -c <path> <cmd>`. */
955
+ export function buildTmuxNewWindowArgs(session, window, worktreePath, paneCommand) {
956
+ return ["new-window", "-t", session, "-n", window, "-c", worktreePath, paneCommand];
957
+ }
958
+ /**
959
+ * Spawn a Linux tmux session for one ticket. Creates one detached session per
960
+ * ticket (adding a window if that session already exists, e.g. on re-run),
961
+ * keeping the pane open after the agent exits. Emits a clear, actionable error
962
+ * when tmux is missing. No per-emulator detection. Returns a structured failure
963
+ * (never throws).
964
+ */
965
+ export async function spawnLinuxTmuxTerminalTab(deps, _terminal, shellCommand, context) {
966
+ const worktreePath = context?.worktreePath;
967
+ const key = context?.key;
968
+ if (!worktreePath || !key) {
969
+ return {
970
+ ok: false,
971
+ error: "Linux tmux spawner requires a worktreePath context to open a session.",
972
+ };
973
+ }
974
+ if (!(await isCommandOnPath(deps, TMUX_COMMAND))) {
975
+ return {
976
+ ok: false,
977
+ error: "tmux is required to spawn Linux sessions but was not found on PATH. Install tmux and retry.",
978
+ };
979
+ }
980
+ const session = tmuxSessionNameForTicket(deps, key);
981
+ const window = tmuxWindowNameForTicket(key);
982
+ const paneCommand = buildTmuxPaneCommand(shellCommand);
983
+ const hasSession = await deps.runCommand(TMUX_COMMAND, ["has-session", "-t", session]);
984
+ const args = commandSucceeded(hasSession)
985
+ ? buildTmuxNewWindowArgs(session, window, worktreePath, paneCommand)
986
+ : buildTmuxNewSessionArgs(session, window, worktreePath, paneCommand);
987
+ const result = await deps.runCommand(TMUX_COMMAND, args);
988
+ if (commandSucceeded(result))
989
+ return { ok: true };
990
+ const reason = combineCommandOutput(result);
991
+ return {
992
+ ok: false,
993
+ error: `tmux failed to create a session/window${reason ? `: ${reason}` : ""}`,
994
+ };
995
+ }
996
+ // ---------------------------------------------------------------------------
997
+ // Unsupported-platform spawner
998
+ // ---------------------------------------------------------------------------
999
+ /** Spawner for unsupported platforms: always a structured failure, never throws. */
1000
+ export async function spawnUnsupportedPlatformTerminalTab(deps, _terminal, _shellCommand, _context) {
1001
+ return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
1002
+ }
1003
+ // ---------------------------------------------------------------------------
1004
+ // Tab spawning across created worktrees
1005
+ // ---------------------------------------------------------------------------
1006
+ /**
1007
+ * Open one tab/session per successfully-created worktree, building each shell
1008
+ * command with the platform-correct `buildShellCommand` and passing per-ticket
1009
+ * context to the spawner. `created` rows become `spawned` (or `spawn-failed`);
1010
+ * `create-failed` rows pass through untouched. The `terminal` choice is only
1011
+ * consumed by the macOS spawner; Windows/Linux spawners intentionally ignore it.
1012
+ */
1013
+ export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildShellCommand) {
1014
+ const out = [];
1015
+ for (const row of rows) {
1016
+ if (row.status !== "created" || !row.path) {
1017
+ out.push(row);
1018
+ continue;
1019
+ }
1020
+ const shellCommand = buildShellCommand(row.key, row.path);
1021
+ const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
1022
+ key: row.key,
1023
+ worktreePath: row.path,
1024
+ });
1025
+ if (result.ok) {
1026
+ out.push({ ...row, status: "spawned" });
1027
+ }
1028
+ else {
1029
+ out.push({ ...row, status: "spawn-failed", error: result.error });
1030
+ }
1031
+ }
1032
+ return out;
1033
+ }
1034
+ // ---------------------------------------------------------------------------
1035
+ // Dry-run, summary, orchestration, CLI wrapper
1036
+ // ---------------------------------------------------------------------------
1037
+ /** Build one dry-run summary row per ticket with resolved branches. */
1038
+ export function buildDryRunResults(keys, overrides) {
1039
+ return keys.map((key) => ({
1040
+ key,
1041
+ branch: resolveBranchForTicket(key, overrides),
1042
+ status: "dry-run",
1043
+ }));
1044
+ }
1045
+ /**
1046
+ * Pure platform resolution for dry-run rendering only (no side effects). Closes
1047
+ * over the selected `agent` to build the preview command. Uses `git-wt` +
1048
+ * PowerShell on Windows, `wt` + POSIX on macOS/Linux, and a non-throwing `wt` +
1049
+ * POSIX fallback for unsupported platforms.
1050
+ */
1051
+ export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env) {
1052
+ return {
1053
+ worktrunkBinary: resolveWorktrunkBinary(platform, env),
1054
+ buildAgentShellCommand: (key, worktreePath) => buildAgentShellCommand(agent, key, worktreePath, platform),
1055
+ };
1056
+ }
1057
+ /**
1058
+ * Build the user-facing dry-run detail lines for one ticket, rendering the
1059
+ * platform-correct Worktrunk binary and the selected agent's shell command. Pure
1060
+ * platform formatting only — no preflight, no routing failures.
1061
+ */
1062
+ export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env) {
1063
+ const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env);
1064
+ const wtArgs = buildWtSwitchArgs(branch, false);
1065
+ const agentInvocation = build(key, "<worktree-path>");
1066
+ return [
1067
+ `DRY-RUN: ${key} -> branch=${branch}`,
1068
+ `DRY-RUN: ${worktrunkBinary} ${wtArgs.join(" ")}`,
1069
+ `DRY-RUN: ${agentInvocation}`,
1070
+ ];
1071
+ }
1072
+ /**
1073
+ * Format the final summary report. Emits a delimited `Summary` section with one
1074
+ * stable parseable line per ticket (`KEY branch=BRANCH status=STATUS`, plus
1075
+ * ` path=PATH` when available) followed by warning lines for any create/spawn
1076
+ * failures.
1077
+ */
1078
+ export function formatSummaryReport(rows) {
1079
+ const lines = ["Summary:"];
1080
+ for (const row of rows) {
1081
+ let line = `${row.key} branch=${row.branch} status=${row.status}`;
1082
+ if (row.path)
1083
+ line += ` path=${row.path}`;
1084
+ lines.push(line);
1085
+ }
1086
+ const failures = rows.filter((r) => r.status === "create-failed" || r.status === "spawn-failed");
1087
+ if (failures.length > 0) {
1088
+ lines.push("");
1089
+ lines.push("Warnings:");
1090
+ for (const row of failures) {
1091
+ lines.push(` ${row.key}: ${row.error ?? row.status}`);
1092
+ }
1093
+ }
1094
+ return lines.join("\n");
1095
+ }
1096
+ /**
1097
+ * Top-level orchestration. Dry runs short-circuit to dry-run rows (before any
1098
+ * preflight or routing). Otherwise run preflight, resolve the platform config,
1099
+ * refresh main (unless disabled), create worktrees with throttling using the
1100
+ * resolved Worktrunk binary, then spawn tabs/sessions for successful worktrees
1101
+ * using the platform-correct shell builder. Global preflight/refresh failures
1102
+ * are returned as `{ ok: false }`; per-ticket failures stay in the rows.
1103
+ */
1104
+ export async function orchestrateStartTickets(deps, options) {
1105
+ if (options.dryRun) {
1106
+ return { ok: true, rows: buildDryRunResults(options.keys, options.branchOverrides) };
1107
+ }
1108
+ // Resolve the selected agent once, before any side effects, so an invalid
1109
+ // agent fails fast with a clear error and no Worktrunk/terminal work occurs.
1110
+ const agent = resolveAgentSpec(options.agentName);
1111
+ if (!agent) {
1112
+ return {
1113
+ ok: false,
1114
+ error: `Unknown agent: '${options.agentName}'. Valid agents: ${formatValidAgentNames()}.`,
1115
+ };
1116
+ }
1117
+ const preflight = await runPreflight(deps, options);
1118
+ if (!preflight.ok)
1119
+ return { ok: false, error: preflight.error };
1120
+ const platformConfig = resolveStartTicketsPlatformConfig(deps, agent);
1121
+ if (!platformConfig.ok)
1122
+ return { ok: false, error: platformConfig.error };
1123
+ const refresh = await refreshMainBranch(deps, options);
1124
+ if (!refresh.ok)
1125
+ return { ok: false, error: refresh.error };
1126
+ const created = await createWorktrees(deps, options, platformConfig.config.worktrunkBinary);
1127
+ const terminal = detectTerminal(options.terminal, deps.env);
1128
+ const rows = await spawnTabsForCreatedWorktrees(deps, created, terminal, platformConfig.config.buildAgentShellCommand);
1129
+ return { ok: true, rows };
1130
+ }
1131
+ /** Platform-specific guidance printed when one or more tabs fail to spawn. */
1132
+ function spawnFailureHintForPlatform(platform) {
1133
+ if (platform === "darwin") {
1134
+ return ("One or more tabs failed to spawn. If osascript reported a permission error, grant the " +
1135
+ "required macOS permissions under System Settings -> Privacy & Security, then re-run: " +
1136
+ "Automation (to let the terminal app be controlled) and, for the Terminal.app path, " +
1137
+ "Accessibility (the new-tab step sends Cmd-T via System Events keystrokes, which Automation " +
1138
+ "alone does not cover).");
1139
+ }
1140
+ if (platform === "win32") {
1141
+ return ("One or more tabs failed to spawn. Ensure Windows Terminal (wt.exe) or PowerShell is " +
1142
+ "installed and on PATH; see the per-ticket warnings above for details.");
1143
+ }
1144
+ if (platform === "linux") {
1145
+ return ("One or more tabs failed to spawn. Ensure tmux is installed and on PATH; see the " +
1146
+ "per-ticket warnings above for details.");
1147
+ }
1148
+ return "One or more tabs failed to spawn; see the per-ticket warnings above for details.";
1149
+ }
1150
+ /**
1151
+ * CLI entry for the `start-tickets` subcommand. Returns a process exit code.
1152
+ * Help returns 0; validation / global failures return 1; a normal run returns
1153
+ * 0 even when individual tickets have create-failed / spawn-failed statuses.
1154
+ */
1155
+ export async function runStartTicketsCli(argv, overrides = {}) {
1156
+ const log = overrides.log ?? ((m) => console.log(m));
1157
+ const errorLog = overrides.errorLog ?? ((m) => console.error(m));
1158
+ const parsed = parseStartTicketsArgs(argv);
1159
+ if (parsed.status === "help") {
1160
+ log(parsed.usage);
1161
+ return 0;
1162
+ }
1163
+ if (parsed.status === "error") {
1164
+ errorLog(`Error: ${parsed.message}`);
1165
+ errorLog("");
1166
+ errorLog(getStartTicketsUsage());
1167
+ return 1;
1168
+ }
1169
+ const options = parsed.options;
1170
+ const deps = overrides.deps ?? createDefaultStartTicketsDeps();
1171
+ const orchestrate = overrides.orchestrate ?? orchestrateStartTickets;
1172
+ // The parser validates `--agent`, so this resolves to a real spec; the guard
1173
+ // is defensive only.
1174
+ const agent = resolveAgentSpec(options.agentName);
1175
+ if (!agent) {
1176
+ errorLog(`Error: Unknown agent: '${options.agentName}'. Valid agents: ${formatValidAgentNames()}.`);
1177
+ return 1;
1178
+ }
1179
+ if (options.dryRun) {
1180
+ for (const key of options.keys) {
1181
+ const branch = resolveBranchForTicket(key, options.branchOverrides);
1182
+ for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env)) {
1183
+ log(line);
1184
+ }
1185
+ }
1186
+ log("");
1187
+ }
1188
+ const result = await orchestrate(deps, options);
1189
+ if (!result.ok) {
1190
+ errorLog(`Error: ${result.error}`);
1191
+ return 1;
1192
+ }
1193
+ log(formatSummaryReport(result.rows));
1194
+ // Linux: each spawned ticket runs in a detached tmux session — tell the user
1195
+ // how to attach.
1196
+ const spawned = result.rows.filter((r) => r.status === "spawned");
1197
+ if (deps.platform === "linux" && spawned.length > 0) {
1198
+ log("");
1199
+ log("Linux: each ticket runs in a detached tmux session. Attach with:");
1200
+ for (const row of spawned) {
1201
+ log(` tmux attach -t ${tmuxSessionNameForTicket(deps, row.key)}`);
1202
+ }
1203
+ }
1204
+ const spawnFailures = result.rows.filter((r) => r.status === "spawn-failed");
1205
+ if (spawnFailures.length > 0) {
1206
+ errorLog("");
1207
+ errorLog(spawnFailureHintForPlatform(deps.platform));
1208
+ }
1209
+ return 0;
1210
+ }