chapterhouse 0.2.0 → 0.3.1

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/README.md CHANGED
@@ -239,8 +239,31 @@ The deployment assets for the shared instance set Entra auth and `CHAPTERHOUSE_M
239
239
  | `chapterhouse update --check-only` | Print current/latest version without updating |
240
240
  | `chapterhouse update --ref <ver>` | Install a specific version |
241
241
  | `chapterhouse daemon <sub>` | Manage the persistent background service |
242
+ | `chapterhouse squad worktree <sub>` | Manage per-agent git worktrees for concurrent squad work |
242
243
  | `chapterhouse help` | Show available commands |
243
244
 
245
+ ### Squad Worktree Commands
246
+
247
+ Squad agents that work on GitHub issues each get a dedicated git worktree so they never step on each other. The `chapterhouse squad worktree` subcommands manage these worktrees:
248
+
249
+ ```sh
250
+ chapterhouse squad worktree create <agent> <issue> [--base main] [--slug <slug>]
251
+ # Creates .worktrees/{agent}-{issue}/ and branch squad/{issue}-{slug}
252
+ # Prints the worktree path to stdout. Reuses existing worktree if present.
253
+
254
+ chapterhouse squad worktree list
255
+ # Shows all active squad worktrees: agent, issue, branch, status, path
256
+
257
+ chapterhouse squad worktree remove <agent> <issue> [--force] [--delete-branch]
258
+ # Removes a worktree. Refuses if dirty unless --force is passed.
259
+
260
+ chapterhouse squad worktree prune [--base main] [--dry-run]
261
+ # Removes all worktrees whose branch has been merged into main.
262
+ # Skips dirty worktrees with a warning.
263
+ ```
264
+
265
+ **How it works:** Worktrees live at `.worktrees/{agent}-{issue}/` inside the repo (gitignored). The Squad coordinator creates each worktree *before* spawning an agent and passes the path as `WORKTREE_PATH` in the spawn prompt. Agents do all their work—reads, edits, commits—inside that path. No agent ever runs `git checkout` in a working tree it doesn't own.
266
+
244
267
  ### Flags
245
268
 
246
269
  | Flag | Description |
@@ -284,6 +307,29 @@ chapterhouse daemon uninstall # stop, disable, and remove the unit file
284
307
  | macOS | `~/Library/Logs/chapterhouse.log` |
285
308
  | Linux | `journalctl --user -u chapterhouse` (no extra config needed) |
286
309
 
310
+ #### Timing contract
311
+
312
+ Chapterhouse enforces a **3-layer timing contract** so in-flight LLM streams can finish cleanly before the process is killed:
313
+
314
+ | Layer | What it controls | Config | Default |
315
+ |-------|-----------------|--------|---------|
316
+ | 1 — Orchestrator turn | How long the orchestrator waits per LLM turn | `CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS` | `1800000` (30 min) |
317
+ | 2 — Daemon shutdown grace | How long the daemon waits for in-flight work before force-exiting | `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS` | `60000` (60 s) |
318
+ | 3 — systemd kill window | How long systemd waits after SIGTERM before sending SIGKILL | `TimeoutStopSec` in generated unit | `90 s` (fixed) |
319
+
320
+ **Rule:** each layer must exceed the one above it. Do not tighten `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS` below `CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS`, and do not reduce `TimeoutStopSec` below `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS`.
321
+
322
+ #### Daemon PATH
323
+
324
+ The generated systemd unit and launchd plist compose a rich `PATH` that includes:
325
+ - The installing shell's `$PATH` (captured at install time)
326
+ - The binary's own directory
327
+ - Linuxbrew (`/home/linuxbrew/.linuxbrew/bin`), Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)
328
+ - `~/.cargo/bin`, `~/.bun/bin`, `~/.volta/bin`, `~/.local/bin`
329
+ - Standard system paths
330
+
331
+ This ensures sub-agents and spawned tools can find CLI dependencies even in a headless service context.
332
+
287
333
  ## Web UI
288
334
 
289
335
  The browser app at `http://localhost:7788` is split into a few views:
@@ -428,3 +474,13 @@ git push origin main --follow-tags
428
474
  ```
429
475
 
430
476
  `npm version` handles the commit and tag automatically. `prepublishOnly` runs `npm run build` before publish so the tarball always contains a fresh build. If you don't have CI set up, publish manually with `npm publish` after the tag push.
477
+
478
+ > **Pre-release gate:** `preversion` runs `npm run release:check` automatically before any `npm version` call. The script aborts with a clear error if the git working tree is dirty. Stash or commit all changes (including `.squad/` metadata edits) before bumping the version.
479
+
480
+ ### Commit message convention
481
+
482
+ All commits on this repository follow **[Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)**. The format is `<type>(<scope>): <subject>` (e.g. `feat(api): add session export endpoint`). Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `build`, `ci`, `revert`, `release`.
483
+
484
+ This is automatically enforced:
485
+ - **Locally:** `husky` installs a `commit-msg` git hook on `npm install` that runs `commitlint` against every commit message. Bad messages are rejected before the commit lands.
486
+ - **On PRs:** A GitHub Action (`lint-pr-title.yml`) validates the PR title on every open/edit. This matters because squash-merges use the PR title as the commit message on `main`.
package/dist/cli.js CHANGED
@@ -25,6 +25,7 @@ Commands:
25
25
  setup Pick a default model and write ~/.chapterhouse/.env
26
26
  update Check for updates and install the latest version
27
27
  daemon <sub> Manage the persistent background service (install/uninstall/start/stop/restart/status/logs)
28
+ squad <sub> Squad agent tools (worktree management)
28
29
  help Show this help message
29
30
 
30
31
  Flags (start):
@@ -178,6 +179,29 @@ switch (command) {
178
179
  }
179
180
  break;
180
181
  }
182
+ case "squad": {
183
+ const squadSub = args[1];
184
+ if (squadSub === 'worktree') {
185
+ const { runWorktreeCli, printWorktreeHelp } = await import("./squad/worktree.js");
186
+ await runWorktreeCli(args.slice(2));
187
+ }
188
+ else {
189
+ if (squadSub) {
190
+ console.error(`Unknown squad subcommand: ${squadSub}\n`);
191
+ }
192
+ console.log(`
193
+ chapterhouse squad — Squad agent tools
194
+
195
+ Subcommands:
196
+ worktree Manage per-agent git worktrees (create / list / remove / prune)
197
+
198
+ Run \`chapterhouse squad worktree\` for worktree subcommand help.
199
+ `.trim());
200
+ if (squadSub)
201
+ process.exit(1);
202
+ }
203
+ break;
204
+ }
181
205
  case "help":
182
206
  case "--help":
183
207
  case "-h":
@@ -315,8 +315,19 @@ export async function initOrchestrator(client) {
315
315
  log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
316
316
  }
317
317
  }
318
- /** How long to wait for the orchestrator to finish a turn (10 min). */
319
- const ORCHESTRATOR_TIMEOUT_MS = 600_000;
318
+ /** How long to wait for the orchestrator to finish a turn (30 min default).
319
+ * Override with CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS env var (parsed as integer ms).
320
+ * Part of the 3-layer timing contract — see systemd unit TimeoutStopSec comment. */
321
+ const DEFAULT_ORCHESTRATOR_TIMEOUT_MS = 1_800_000;
322
+ export const ORCHESTRATOR_TIMEOUT_MS = (() => {
323
+ const env = process.env.CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS;
324
+ if (env) {
325
+ const parsed = parseInt(env, 10);
326
+ if (!isNaN(parsed) && parsed > 0)
327
+ return parsed;
328
+ }
329
+ return DEFAULT_ORCHESTRATOR_TIMEOUT_MS;
330
+ })();
320
331
  /** Send a prompt on a session identified by sessionKey, return the response. */
321
332
  async function executeOnSession(sessionKey, prompt, callback, attachments, onActivity) {
322
333
  const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
@@ -619,7 +619,7 @@ test("S5-01: subagent.failed event updates agent_tasks status to error", async (
619
619
  toolCallId: "subagent-call-003",
620
620
  agentName: "Zoe",
621
621
  agentDisplayName: "Zoe — QA",
622
- error: "Timeout after 600s",
622
+ error: "Timeout after 1800s",
623
623
  });
624
624
  const errorWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("error"));
625
625
  assert.ok(errorWrite, "subagent.failed must UPDATE agent_tasks to error status");
@@ -53,9 +53,35 @@ export function resolveChapterhouseBin() {
53
53
  return argv1;
54
54
  return "chapterhouse";
55
55
  }
56
+ /**
57
+ * Compose a rich PATH for launchd-spawned children (macOS).
58
+ * Prepends the installing shell's $PATH so any tool already on the user's PATH
59
+ * is available to sub-agents, then adds Homebrew and language-toolchain dirs.
60
+ */
61
+ function composeMacOSPath(binDir, shellPath) {
62
+ const home = homedir();
63
+ return [
64
+ shellPath,
65
+ binDir,
66
+ "/opt/homebrew/bin",
67
+ "/usr/local/bin",
68
+ `${home}/.cargo/bin`,
69
+ `${home}/.bun/bin`,
70
+ `${home}/.volta/bin`,
71
+ `${home}/.local/bin`,
72
+ "/usr/bin",
73
+ "/bin",
74
+ "/usr/sbin",
75
+ "/sbin",
76
+ ]
77
+ .filter(Boolean)
78
+ .join(":");
79
+ }
56
80
  /** Generate the launchd plist XML string. */
57
81
  export function generatePlist(options) {
58
82
  const label = options.label ?? DAEMON_LABEL;
83
+ const shellPath = options.shellPath ?? process.env.PATH ?? "";
84
+ const richPath = composeMacOSPath(dirname(options.binPath), shellPath);
59
85
  return `<?xml version="1.0" encoding="UTF-8"?>
60
86
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
61
87
  <plist version="1.0">
@@ -78,16 +104,39 @@ export function generatePlist(options) {
78
104
  <key>EnvironmentVariables</key>
79
105
  <dict>
80
106
  <key>PATH</key>
81
- <string>${dirname(options.binPath)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
107
+ <string>${richPath}</string>
82
108
  </dict>
83
109
  </dict>
84
110
  </plist>
85
111
  `;
86
112
  }
113
+ /**
114
+ * Compose a rich PATH for systemd-spawned children (Linux).
115
+ * Uses systemd's %h specifier for the home directory so the unit stays portable
116
+ * across uid changes. Prepends the installing shell's $PATH for robustness.
117
+ */
118
+ function composeSystemdPath(binDir, shellPath) {
119
+ return [
120
+ shellPath,
121
+ binDir,
122
+ "/home/linuxbrew/.linuxbrew/bin",
123
+ "%h/.cargo/bin",
124
+ "%h/.bun/bin",
125
+ "%h/.volta/bin",
126
+ "%h/.local/bin",
127
+ "/opt/homebrew/bin",
128
+ "/usr/local/bin",
129
+ "/usr/bin",
130
+ "/bin",
131
+ ]
132
+ .filter(Boolean)
133
+ .join(":");
134
+ }
87
135
  /** Generate the systemd user unit file string. */
88
136
  export function generateSystemdUnit(options) {
89
137
  const description = options.description ?? "Chapterhouse AI assistant daemon";
90
- const pathDir = dirname(options.binPath);
138
+ const shellPath = options.shellPath ?? process.env.PATH ?? "";
139
+ const richPath = composeSystemdPath(dirname(options.binPath), shellPath);
91
140
  return `[Unit]
92
141
  Description=${description}
93
142
  After=network.target
@@ -97,7 +146,17 @@ Type=simple
97
146
  ExecStart=${options.binPath} start
98
147
  Restart=on-failure
99
148
  RestartSec=5s
100
- Environment="PATH=${pathDir}:/usr/local/bin:/usr/bin:/bin"
149
+ # Rich PATH ensures spawned sub-agents and tools can find CLI dependencies
150
+ # (Linuxbrew, Homebrew, Cargo, Bun, Volta). %h is expanded to $HOME by systemd.
151
+ Environment="PATH=${richPath}"
152
+ # ── Timing contract (three layers) ─────────────────────────────────────────────
153
+ # Layer 1 │ Orchestrator turn timeout │ CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS (default 1800 s)
154
+ # Layer 2 │ Daemon graceful shutdown │ CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS (default 60 s)
155
+ # Layer 3 │ systemd force-kill window │ TimeoutStopSec = 90 s (must exceed layer 2)
156
+ # Each layer must exceed the one above it. Do NOT tighten TimeoutStopSec without
157
+ # first reducing CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS, or in-flight LLM streams will
158
+ # be killed mid-response.
159
+ TimeoutStopSec=90
101
160
 
102
161
  [Install]
103
162
  WantedBy=default.target
@@ -41,6 +41,23 @@ test("generatePlist includes bin directory in PATH environment", () => {
41
41
  const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
42
42
  assert.ok(plist.includes(dirname(MOCK_BIN_DARWIN)), "plist PATH should include bin directory");
43
43
  });
44
+ test("generatePlist PATH includes Homebrew and toolchain directories", () => {
45
+ const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, shellPath: "" });
46
+ const home = homedir();
47
+ assert.ok(plist.includes("/opt/homebrew/bin"), "plist PATH should include /opt/homebrew/bin");
48
+ assert.ok(plist.includes(`${home}/.cargo/bin`), "plist PATH should include ~/.cargo/bin");
49
+ assert.ok(plist.includes(`${home}/.bun/bin`), "plist PATH should include ~/.bun/bin");
50
+ assert.ok(plist.includes(`${home}/.volta/bin`), "plist PATH should include ~/.volta/bin");
51
+ assert.ok(plist.includes(`${home}/.local/bin`), "plist PATH should include ~/.local/bin");
52
+ });
53
+ test("generatePlist PATH prepends installing shell PATH", () => {
54
+ const shellPath = "/custom/shell/bin:/another/dir";
55
+ const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, shellPath });
56
+ assert.ok(plist.includes(shellPath), "plist PATH should include the installing shell PATH");
57
+ const pathStart = plist.indexOf(shellPath);
58
+ const binDirStart = plist.indexOf(dirname(MOCK_BIN_DARWIN), plist.indexOf("<key>PATH</key>"));
59
+ assert.ok(pathStart < binDirStart, "shell PATH should appear before bin dir in composed PATH");
60
+ });
44
61
  test("generatePlist respects a custom label override", () => {
45
62
  const customLabel = "com.example.test";
46
63
  const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, label: customLabel });
@@ -78,6 +95,24 @@ test("generateSystemdUnit includes bin directory in PATH", () => {
78
95
  const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
79
96
  assert.ok(unit.includes(dirname(MOCK_BIN_LINUX)), "unit PATH should include bin directory");
80
97
  });
98
+ test("generateSystemdUnit PATH includes Linuxbrew and toolchain directories", () => {
99
+ const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX, shellPath: "" });
100
+ assert.ok(unit.includes("/home/linuxbrew/.linuxbrew/bin"), "unit PATH should include Linuxbrew");
101
+ assert.ok(unit.includes("%h/.cargo/bin"), "unit PATH should include %h/.cargo/bin");
102
+ assert.ok(unit.includes("%h/.bun/bin"), "unit PATH should include %h/.bun/bin");
103
+ assert.ok(unit.includes("%h/.volta/bin"), "unit PATH should include %h/.volta/bin");
104
+ assert.ok(unit.includes("%h/.local/bin"), "unit PATH should include %h/.local/bin");
105
+ assert.ok(unit.includes("/opt/homebrew/bin"), "unit PATH should include /opt/homebrew/bin");
106
+ });
107
+ test("generateSystemdUnit PATH prepends installing shell PATH", () => {
108
+ const shellPath = "/custom/shell/bin";
109
+ const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX, shellPath });
110
+ assert.ok(unit.includes(shellPath), "unit PATH should include the installing shell PATH");
111
+ });
112
+ test("generateSystemdUnit includes TimeoutStopSec=90", () => {
113
+ const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
114
+ assert.ok(unit.includes("TimeoutStopSec=90"), "unit should set TimeoutStopSec=90 for timing contract");
115
+ });
81
116
  test("generateSystemdUnit has all three sections", () => {
82
117
  const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
83
118
  assert.ok(unit.includes("[Unit]"), "unit should have [Unit] section");
package/dist/daemon.js CHANGED
@@ -19,6 +19,23 @@ import { registerShutdownSignals } from "./shutdown-signals.js";
19
19
  import { logger } from "./util/logger.js";
20
20
  const log = logger.child({ module: "daemon" });
21
21
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
22
+ /**
23
+ * How long the daemon waits for in-flight work to finish before forcing an exit.
24
+ * Layer 2 of the 3-layer timing contract:
25
+ * Layer 1 — orchestrator turn: CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS (default 1800 s)
26
+ * Layer 2 — daemon shutdown: CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS (default 60 s) ← this
27
+ * Layer 3 — systemd kill: TimeoutStopSec=90 s (must exceed layer 2)
28
+ * Allows in-flight LLM streams to complete before the process is torn down.
29
+ */
30
+ const SHUTDOWN_TIMEOUT_MS = (() => {
31
+ const env = process.env.CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS;
32
+ if (env) {
33
+ const parsed = parseInt(env, 10);
34
+ if (!isNaN(parsed) && parsed > 0)
35
+ return parsed;
36
+ }
37
+ return 60_000;
38
+ })();
22
39
  /** Remove orphaned session folders older than 7 days, preserving the current session. */
23
40
  function pruneOldSessions() {
24
41
  try {
@@ -165,11 +182,11 @@ async function shutdown() {
165
182
  }
166
183
  shutdownState = "shutting_down";
167
184
  log.info("Shutting down (Ctrl+C again to force)");
168
- // Force exit after 3 seconds no matter what
185
+ // Force exit after the configured grace period no matter what
169
186
  const forceTimer = setTimeout(() => {
170
187
  log.warn("Shutdown timed out — forcing exit");
171
188
  process.exit(1);
172
- }, 3000);
189
+ }, SHUTDOWN_TIMEOUT_MS);
173
190
  forceTimer.unref();
174
191
  // Destroy all active agent sessions
175
192
  await shutdownAgents();
@@ -4,4 +4,5 @@ export * from './registry.js';
4
4
  export * from './context.js';
5
5
  export * from './charter.js';
6
6
  export * from './mirror.js';
7
+ export * from './worktree.js';
7
8
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,295 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ // ---------------------------------------------------------------------------
5
+ // Path convention
6
+ // ---------------------------------------------------------------------------
7
+ /**
8
+ * Location: `.worktrees/{agent}-{issueNum}/` inside the repo root.
9
+ * This keeps worktrees discoverable (they're adjacent to the code) and
10
+ * `git worktree list` always surfaces them. The directory is gitignored.
11
+ *
12
+ * Tradeoff vs `~/.cache/chapterhouse-worktrees/<repo>/`:
13
+ * + Visible without knowing a cache path
14
+ * + `chapterhouse squad worktree list` doesn't need to guess the home
15
+ * - Must be added to .gitignore (done once, documented here)
16
+ */
17
+ export function getWorktreePath(repoRoot, agent, issueNum) {
18
+ return join(repoRoot, '.worktrees', `${agent}-${issueNum}`);
19
+ }
20
+ export function getBranchName(issueNum, slug) {
21
+ const safeslug = slug ? `-${slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}` : '';
22
+ return `squad/${issueNum}${safeslug}`;
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Git helpers
26
+ // ---------------------------------------------------------------------------
27
+ function git(args, cwd) {
28
+ const result = spawnSync('git', args, { cwd, encoding: 'utf-8' });
29
+ return {
30
+ ok: result.status === 0,
31
+ stdout: (result.stdout ?? '').trim(),
32
+ stderr: (result.stderr ?? '').trim(),
33
+ };
34
+ }
35
+ function hasUncommittedChanges(worktreePath) {
36
+ const r = git(['status', '--porcelain'], worktreePath);
37
+ return r.ok && r.stdout.length > 0;
38
+ }
39
+ function isBranchMerged(repoRoot, branch, base = 'main') {
40
+ // merged if branch tip is reachable from base
41
+ const r = git(['branch', '--merged', base, '--list', branch], repoRoot);
42
+ return r.ok && r.stdout.length > 0;
43
+ }
44
+ function getRepoRoot(cwd) {
45
+ const r = git(['rev-parse', '--show-toplevel'], cwd);
46
+ if (!r.ok)
47
+ throw new Error(`Not inside a git repo: ${r.stderr}`);
48
+ return r.stdout;
49
+ }
50
+ /**
51
+ * Create a dedicated worktree for an agent + issue pair.
52
+ * Branch: `squad/{issueNum}-{agent}` (off `main` by default).
53
+ * Returns the worktree path.
54
+ */
55
+ export function createWorktree(repoRoot, agent, issueNum, opts = {}) {
56
+ const baseBranch = opts.baseBranch ?? 'main';
57
+ const branch = getBranchName(issueNum, opts.slug ?? agent);
58
+ const worktreePath = getWorktreePath(repoRoot, agent, issueNum);
59
+ if (existsSync(worktreePath)) {
60
+ // Reuse existing worktree
61
+ console.log(`↩ Reusing existing worktree: ${relative(repoRoot, worktreePath)}`);
62
+ return worktreePath;
63
+ }
64
+ // Check if branch already exists (another agent on same issue)
65
+ const branchExists = git(['show-ref', '--verify', `refs/heads/${branch}`], repoRoot);
66
+ let result;
67
+ if (branchExists.ok) {
68
+ result = git(['worktree', 'add', worktreePath, branch], repoRoot);
69
+ }
70
+ else {
71
+ result = git(['worktree', 'add', '-b', branch, worktreePath, baseBranch], repoRoot);
72
+ }
73
+ if (!result.ok) {
74
+ throw new Error(`Failed to create worktree: ${result.stderr}`);
75
+ }
76
+ console.log(`✅ Worktree created: ${relative(repoRoot, worktreePath)} (branch: ${branch})`);
77
+ return worktreePath;
78
+ }
79
+ /**
80
+ * List all squad worktrees (worktrees under `.worktrees/` with `squad/` branches).
81
+ */
82
+ export function listWorktrees(repoRoot) {
83
+ const r = git(['worktree', 'list', '--porcelain'], repoRoot);
84
+ if (!r.ok)
85
+ throw new Error(`git worktree list failed: ${r.stderr}`);
86
+ const entries = parseWorktreePorcelain(r.stdout);
87
+ const squadEntries = entries.filter(e => e.path.includes('/.worktrees/'));
88
+ return squadEntries.map(e => {
89
+ const dirName = e.path.split('/.worktrees/')[1] ?? '';
90
+ const match = dirName.match(/^([^-]+)-(\d+)/);
91
+ const agent = match ? match[1] : dirName;
92
+ const issueNum = match ? match[2] : '?';
93
+ const dirty = existsSync(e.path) && hasUncommittedChanges(e.path);
94
+ return {
95
+ agent,
96
+ issueNum,
97
+ path: e.path,
98
+ branch: e.branch ?? '(detached)',
99
+ exists: existsSync(e.path),
100
+ dirty,
101
+ head: e.head ?? '',
102
+ };
103
+ });
104
+ }
105
+ /**
106
+ * Remove a specific worktree. Warns but refuses if the worktree has
107
+ * uncommitted changes (pass `force: true` to override).
108
+ */
109
+ export function removeWorktree(repoRoot, agent, issueNum, opts = {}) {
110
+ const worktreePath = getWorktreePath(repoRoot, agent, issueNum);
111
+ if (!existsSync(worktreePath)) {
112
+ console.log(`ℹ Worktree not found: ${worktreePath} (nothing to remove)`);
113
+ return;
114
+ }
115
+ if (!opts.force && hasUncommittedChanges(worktreePath)) {
116
+ console.error(`⚠ Worktree ${relative(repoRoot, worktreePath)} has uncommitted changes.\n` +
117
+ ` Commit or stash your work before removing, or pass --force to discard.`);
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ const removeArgs = ['worktree', 'remove', worktreePath];
122
+ if (opts.force)
123
+ removeArgs.push('--force');
124
+ const r = git(removeArgs, repoRoot);
125
+ if (!r.ok)
126
+ throw new Error(`Failed to remove worktree: ${r.stderr}`);
127
+ if (opts.deleteBranch) {
128
+ const branch = getBranchName(issueNum, agent);
129
+ const del = git(['branch', '-d', branch], repoRoot);
130
+ if (!del.ok) {
131
+ console.warn(`⚠ Could not delete branch ${branch}: ${del.stderr}`);
132
+ }
133
+ }
134
+ console.log(`🗑 Removed worktree: ${relative(repoRoot, worktreePath)}`);
135
+ }
136
+ /**
137
+ * Remove all squad worktrees whose branch has been merged into `main`.
138
+ * Warns and skips worktrees with uncommitted changes.
139
+ */
140
+ export function pruneWorktrees(repoRoot, opts = {}) {
141
+ const base = opts.base ?? 'main';
142
+ const worktrees = listWorktrees(repoRoot);
143
+ if (worktrees.length === 0) {
144
+ console.log('ℹ No squad worktrees found.');
145
+ return;
146
+ }
147
+ for (const wt of worktrees) {
148
+ const merged = isBranchMerged(repoRoot, wt.branch, base);
149
+ if (!merged) {
150
+ console.log(`↷ Skipping ${wt.agent}-${wt.issueNum} (branch not merged into ${base})`);
151
+ continue;
152
+ }
153
+ if (wt.dirty) {
154
+ console.warn(`⚠ Skipping ${wt.agent}-${wt.issueNum} (has uncommitted changes — clean up manually)`);
155
+ continue;
156
+ }
157
+ if (opts.dryRun) {
158
+ console.log(`[dry-run] Would remove: ${wt.agent}-${wt.issueNum} (branch: ${wt.branch})`);
159
+ continue;
160
+ }
161
+ removeWorktree(repoRoot, wt.agent, Number(wt.issueNum), { deleteBranch: true });
162
+ }
163
+ }
164
+ // ---------------------------------------------------------------------------
165
+ // Print helpers
166
+ // ---------------------------------------------------------------------------
167
+ export function printWorktreeList(repoRoot) {
168
+ const worktrees = listWorktrees(repoRoot);
169
+ if (worktrees.length === 0) {
170
+ console.log('No squad worktrees found.');
171
+ return;
172
+ }
173
+ const header = ['AGENT', 'ISSUE', 'BRANCH', 'STATUS', 'PATH'];
174
+ const rows = worktrees.map(wt => [
175
+ wt.agent,
176
+ wt.issueNum,
177
+ wt.branch,
178
+ wt.dirty ? '⚠ dirty' : '✓ clean',
179
+ wt.path,
180
+ ]);
181
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
182
+ const fmt = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(' ');
183
+ console.log(fmt(header));
184
+ console.log(widths.map(w => '-'.repeat(w)).join(' '));
185
+ for (const row of rows)
186
+ console.log(fmt(row));
187
+ }
188
+ export function printWorktreeHelp() {
189
+ console.log(`
190
+ chapterhouse squad worktree — manage per-agent git worktrees
191
+
192
+ Usage:
193
+ chapterhouse squad worktree <subcommand>
194
+
195
+ Subcommands:
196
+ create <agent> <issue> [--base <branch>] [--slug <slug>]
197
+ Create a dedicated worktree for an agent + issue.
198
+ Path: .worktrees/{agent}-{issue}/ Branch: squad/{issue}-{agent}
199
+
200
+ list
201
+ List all squad worktrees with agent, branch, and status.
202
+
203
+ remove <agent> <issue> [--force] [--delete-branch]
204
+ Remove a worktree. Refuses if there are uncommitted changes
205
+ unless --force is passed (which discards the changes).
206
+
207
+ prune [--base <branch>] [--dry-run]
208
+ Remove all worktrees whose branch has been merged into main
209
+ (or the specified --base branch). Skips dirty worktrees.
210
+ `.trim());
211
+ }
212
+ function parseWorktreePorcelain(output) {
213
+ const entries = [];
214
+ let current = null;
215
+ for (const line of output.split('\n')) {
216
+ if (line.startsWith('worktree ')) {
217
+ if (current?.path)
218
+ entries.push(current);
219
+ current = { path: line.slice('worktree '.length) };
220
+ }
221
+ else if (line.startsWith('HEAD ') && current) {
222
+ current.head = line.slice('HEAD '.length);
223
+ }
224
+ else if (line.startsWith('branch ') && current) {
225
+ current.branch = line.slice('branch refs/heads/'.length);
226
+ }
227
+ else if (line === '' && current?.path) {
228
+ entries.push(current);
229
+ current = null;
230
+ }
231
+ }
232
+ if (current?.path)
233
+ entries.push(current);
234
+ return entries;
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // CLI entry point
238
+ // ---------------------------------------------------------------------------
239
+ export async function runWorktreeCli(args) {
240
+ let repoRoot;
241
+ try {
242
+ repoRoot = getRepoRoot(process.cwd());
243
+ }
244
+ catch {
245
+ console.error('❌ Not inside a git repository.');
246
+ process.exit(1);
247
+ }
248
+ const subcommand = args[0];
249
+ switch (subcommand) {
250
+ case 'create': {
251
+ const agent = args[1];
252
+ const issueNum = args[2];
253
+ if (!agent || !issueNum) {
254
+ console.error('Usage: chapterhouse squad worktree create <agent> <issue> [--base <branch>] [--slug <slug>]');
255
+ process.exit(1);
256
+ }
257
+ const baseIdx = args.indexOf('--base');
258
+ const baseBranch = baseIdx !== -1 ? args[baseIdx + 1] : undefined;
259
+ const slugIdx = args.indexOf('--slug');
260
+ const slug = slugIdx !== -1 ? args[slugIdx + 1] : undefined;
261
+ const path = createWorktree(repoRoot, agent, issueNum, { baseBranch, slug });
262
+ console.log(path);
263
+ break;
264
+ }
265
+ case 'list':
266
+ printWorktreeList(repoRoot);
267
+ break;
268
+ case 'remove': {
269
+ const agent = args[1];
270
+ const issueNum = args[2];
271
+ if (!agent || !issueNum) {
272
+ console.error('Usage: chapterhouse squad worktree remove <agent> <issue> [--force] [--delete-branch]');
273
+ process.exit(1);
274
+ }
275
+ removeWorktree(repoRoot, agent, issueNum, {
276
+ force: args.includes('--force'),
277
+ deleteBranch: args.includes('--delete-branch'),
278
+ });
279
+ break;
280
+ }
281
+ case 'prune': {
282
+ const baseIdx = args.indexOf('--base');
283
+ const base = baseIdx !== -1 ? args[baseIdx + 1] : undefined;
284
+ pruneWorktrees(repoRoot, { base, dryRun: args.includes('--dry-run') });
285
+ break;
286
+ }
287
+ default:
288
+ if (subcommand)
289
+ console.error(`Unknown worktree subcommand: ${subcommand}\n`);
290
+ printWorktreeHelp();
291
+ if (subcommand)
292
+ process.exit(1);
293
+ }
294
+ }
295
+ //# sourceMappingURL=worktree.js.map