dreamteamer 0.20.0 → 0.22.0

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,666 @@
1
+ // src/checkout.js — which checkout am I, and how does it become ready.
2
+ // WORKSPACE layer: knows git and the compiler, never the harness.
3
+ import path from 'node:path';
4
+ import fs, { realpathSync } from 'node:fs';
5
+ import { execFileSync, spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { staleness, compile, discoverModules } from './compile.js';
8
+ import { install as restoreGitModules } from './init.js';
9
+ import { findWorkspace } from './workspace.js';
10
+
11
+ export const defaultGit = (args, cwd) => {
12
+ try {
13
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
14
+ } catch (e) {
15
+ // ⚠ GIT'S REASON LEADS. execFileSync does append the child's stderr to its message, but
16
+ // BEHIND `Command failed: git <argv>` — and every summary in this file reads
17
+ // `message.split('\n')[0]` (describeCheckout's parenthetical, applyInstall's per-step ✖), so
18
+ // the one line that survived was the command line and never the explanation. `dt rm` on a
19
+ // LOCKED worktree led with the argv and mentioned the lock underneath it.
20
+ const why = String(e.stderr ?? '').trim().split('\n').filter(Boolean);
21
+ if (why.length) e.message = [...why, `(git ${args.join(' ')})`].join('\n ');
22
+ throw e;
23
+ }
24
+ };
25
+
26
+ // Git answers --git-common-dir as a REALPATH, so the two sides of the insideRoot test must be
27
+ // spelled the same way or a checkout reached through a symlink reads as outside its own primary
28
+ // (macOS /tmp → /private/tmp is the everyday case). A unit test's git is a fake and its paths need
29
+ // not exist, so a path that cannot be resolved on disk keeps the spelling it came with.
30
+ const real = (p) => { try { return realpathSync(p); } catch { return p; } };
31
+
32
+ /** Primary vs linked, derived from git — never configured. `primary` is the common dir's parent. */
33
+ export function describeCheckout(rootArg, git = defaultGit) {
34
+ const root = path.resolve(rootArg);
35
+ let gitDir, commonDir;
36
+ try {
37
+ gitDir = git(['rev-parse', '--git-dir'], root);
38
+ commonDir = git(['rev-parse', '--git-common-dir'], root);
39
+ } catch (e) {
40
+ throw new Error(`${root} is not a git checkout — dreamteamer install needs one (${e.message.split('\n')[0]})`);
41
+ }
42
+ const abs = (p) => path.resolve(root, p);
43
+ const linked = abs(gitDir) !== abs(commonDir);
44
+ // The common dir's parent is the primary checkout whichever kind this is — and unlike `root` it
45
+ // stays the primary when the caller hands us a SUBDIRECTORY of one.
46
+ const primary = path.dirname(abs(commonDir));
47
+ const rel = path.relative(real(primary), real(root));
48
+ return { root, kind: linked ? 'linked' : 'primary', gitDir: abs(gitDir), commonDir: abs(commonDir), primary,
49
+ insideRoot: !rel.startsWith('..') && !path.isAbsolute(rel) };
50
+ }
51
+
52
+ /** The install plan as DATA: every step names what it found and what it would do, so the same
53
+ * decisions are testable without a disk and printable without being run. Task 3 observes the state
54
+ * and executes the steps; nothing here touches fs or git. */
55
+ export function planInstall(state, opts = {}) {
56
+ const { checkout: c } = state;
57
+ const steps = [];
58
+ steps.push(state.hasEngine
59
+ ? { id: 'engine', label: 'engine: node_modules/dreamteamer present', state: 'already' }
60
+ : { id: 'engine', label: 'engine: npm ci --prefer-offline (package-lock.json) or npm install', state: 'todo' });
61
+ if (c.kind === 'primary') steps.push({ id: 'env', label: '.env: primary checkout — nothing to link', state: 'skip' });
62
+ else if (state.hasEnv && !state.envIsLink) steps.push({ id: 'env', label: '.env: this worktree carries its own .env — left alone', state: 'already' });
63
+ else if (state.hasEnv && state.envIsLink) steps.push({ id: 'env', label: '.env: linked to the primary', state: 'already' });
64
+ else if (!state.primaryHasEnv) steps.push({ id: 'env', label: '.env: the primary has none — nothing to link', state: 'skip' });
65
+ else if (!c.insideRoot && !opts.linkEnv) steps.push({ id: 'env', label: '.env: NOT linked', state: 'skip',
66
+ why: 'this worktree lies outside the primary root — credentials are not linked there by default; pass --link-env to override for this run' });
67
+ else steps.push({ id: 'env', label: `.env: link → ${c.primary}/.env`, state: 'todo' });
68
+ for (const a of state.localAssets) {
69
+ const id = `asset:${a.rel}`;
70
+ if (a.presentHere && !a.isLinkHere) steps.push({ id, label: `${a.rel}: a real directory is here — left alone`, state: 'already' });
71
+ else if (a.isLinkHere) steps.push({ id, label: `${a.rel}: linked`, state: 'already' });
72
+ else if (c.kind === 'primary') steps.push({ id, label: `${a.rel}: primary checkout — nothing to link`, state: 'skip' });
73
+ else if (!a.presentInPrimary) steps.push({ id, label: `${a.rel}: absent in the primary — skipped (the doctor reports the capability degraded)`, state: 'skip' });
74
+ else steps.push({ id, label: `${a.rel}: link → ${c.primary}/${a.rel}`, state: 'todo' });
75
+ }
76
+ // gitModules carries the declared clones that are MISSING on this checkout, not every declared
77
+ // one — the observer narrows it, which is what makes an empty array mean "nothing to restore"
78
+ // rather than "none declared", and what lets a settled checkout plan with nothing todo.
79
+ steps.push({ id: 'git-modules', label: state.gitModules.length ? `git modules: restore ${state.gitModules.join(', ')}` : 'git modules: nothing to restore', state: state.gitModules.length ? 'todo' : 'skip' });
80
+ steps.push({ id: 'compile', label: state.stale ? 'compile: runtime missing or stale' : 'compile: fresh', state: state.stale ? 'todo' : 'already' });
81
+ steps.push(state.postinstall
82
+ ? { id: 'postinstall', label: `postinstall: ${state.postinstall}`, state: 'todo' }
83
+ : { id: 'postinstall', label: 'postinstall: none declared', state: 'skip' });
84
+ return steps;
85
+ }
86
+
87
+ // ---- the impure half: observe this checkout, run the plan, print the board -----------------
88
+
89
+ // ⚠ BOTH OF THESE FOLLOW SYMLINKS, and that is the whole point. A DANGLING link is exactly what a
90
+ // moved or deleted primary leaves behind, and it is the one state where "there is a symlink here"
91
+ // and "this checkout has the file" disagree. Reading it as present would print `✔ linked to the
92
+ // primary` over a file that cannot be opened — the board lying about the one step nobody eyeballs.
93
+ // So: present means it RESOLVES, and a link counts as a link only when it resolves too.
94
+ const resolves = (p) => fs.existsSync(p);
95
+ const isLink = (p) => { try { return fs.lstatSync(p).isSymbolicLink() && fs.existsSync(p); } catch { return false; } };
96
+
97
+ /** Every declared local asset, workspace-level (root-relative) and module-level (module-relative),
98
+ * DEDUPED by rel with the first declaration winning: a step's id is `asset:<rel>`, so two modules
99
+ * declaring the same path would plan two steps under one id and the second would be dropped
100
+ * silently by any id-keyed read of the board. */
101
+ export function declaredLocalAssets(ws) {
102
+ const seen = new Map(); // rel → {rel, module}
103
+ const add = (raw, module) => {
104
+ // ⚠ A REL MAY NOT CLIMB OUT OF THE WORKSPACE. `placeLink` writes wherever the rel points, so
105
+ // `local-assets: ['../../x']` in a careless module declaration would drop a symlink beside
106
+ // the workspace with nothing consulted. Refused when the plan is BUILT, so the board never
107
+ // prints a step it must not run. (Task 4 teaches compile the same rule; a runtime that
108
+ // writes outside the root should not wait for the compiler to be run.)
109
+ const rel = path.normalize(raw);
110
+ if (path.isAbsolute(rel) || rel === '..' || rel.startsWith(`..${path.sep}`)) {
111
+ throw new Error(`local-assets: "${raw}"${module ? ` (declared by module ${module})` : ''} resolves outside the workspace root — a local asset must be a path INSIDE the workspace`);
112
+ }
113
+ if (!seen.has(rel)) seen.set(rel, { rel, module });
114
+ };
115
+ for (const rel of ws.pkg.dreamteamer?.['local-assets'] ?? []) add(rel, null);
116
+ for (const m of discoverModules(ws.root, ws.pkg).modules) {
117
+ let mp = {};
118
+ try { mp = JSON.parse(fs.readFileSync(path.join(m.root, 'package.json'), 'utf8')); } catch { /* no package.json */ }
119
+ for (const rel of mp.dreamteamer?.['local-assets'] ?? []) add(path.relative(ws.root, path.join(m.root, rel)), m.name);
120
+ }
121
+ return [...seen.values()];
122
+ }
123
+
124
+ /** What is true of this checkout right now — the only function here that reads the disk, so
125
+ * `planInstall` stays pure and the whole board is decidable from this one object. */
126
+ export function observeState(ws) {
127
+ const checkout = describeCheckout(ws.root);
128
+ const here = (rel) => path.join(ws.root, rel), there = (rel) => path.join(checkout.primary, rel);
129
+ const s = staleness(ws.root);
130
+ return {
131
+ checkout,
132
+ hasEngine: resolves(here('node_modules/dreamteamer')),
133
+ hasEnv: resolves(here('.env')), envIsLink: isLink(here('.env')), primaryHasEnv: resolves(there('.env')),
134
+ localAssets: declaredLocalAssets(ws).map((a) => ({ ...a, presentHere: resolves(here(a.rel)), isLinkHere: isLink(here(a.rel)), presentInPrimary: resolves(there(a.rel)) })),
135
+ // ⚠ THE MISSING clones, not every declared one. A settled worktree would otherwise print
136
+ // `▶ git modules: restore <names>` for ever, and this is the step whose result nobody looks
137
+ // at — so the narrowing here is what makes the empty case mean "nothing to restore".
138
+ gitModules: Object.keys(ws.pkg.dreamteamer?.['git-modules'] ?? {}).filter((n) => !resolves(here(path.join('git_modules', n)))),
139
+ stale: !s.compiled || s.stale.length > 0,
140
+ postinstall: ws.pkg.dreamteamer?.postinstall ?? null,
141
+ };
142
+ }
143
+
144
+ /** Place a symlink, replacing a dangling one. A step only reaches here because the observer read
145
+ * the path as absent, which a broken link is — so the leftover has to be cleared, never trusted. */
146
+ function placeLink(target, at) {
147
+ fs.mkdirSync(path.dirname(at), { recursive: true });
148
+ try { if (fs.lstatSync(at).isSymbolicLink()) fs.unlinkSync(at); } catch { /* nothing there */ }
149
+ fs.symlinkSync(target, at);
150
+ return 0;
151
+ }
152
+
153
+ /** ⚠ EVERY EXECUTOR ANSWERS WITH A CODE, NEVER A THROW. The board's contract is that one failure
154
+ * names itself and the remaining steps still run — and under `--json` a throw out of here would
155
+ * abandon the payload as well as the rest of the plan. `compile` throws on a source error, and fs
156
+ * throws on everything from EACCES to a primary that vanished mid-run, so the guard is the rule
157
+ * here rather than the exception. */
158
+ const guard = (name, fn) => (...a) => {
159
+ try { return fn(...a) ?? 0; } catch (e) { console.error(`✖ ${name}: ${e.message.split('\n')[0]}`); return 1; }
160
+ };
161
+
162
+ /** npm, resolved BESIDE the running node first and on PATH second.
163
+ *
164
+ * ⚠ THE ORDER IS THE MEASUREMENT (spec §15). A hook runs under `sh`, which reads no startup file,
165
+ * so PATH may carry nothing at all — while `process.execPath` is an absolute path to the node that
166
+ * is running this line, and every installer that ships node ships npm beside it. Trusting PATH
167
+ * first would pick a stale global npm on a machine that has both, and find nothing on a machine
168
+ * reached through the shim. Returns null when neither resolves, which is a board line, not a throw.
169
+ */
170
+ export function resolveNpm(execPath = process.execPath, env = process.env) {
171
+ const bin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
172
+ // ⚠ EXECUTABLE, not merely present. `resolves` is `existsSync`, which is true of a DIRECTORY
173
+ // called `npm` and of a file nobody may run — and this path is then spawned, so the difference
174
+ // between "it is there" and "it can be executed" is the difference between a named board line
175
+ // and an EACCES nobody planned for. `prove`'s `requires: { bin: … }` already learned this.
176
+ const runnable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
177
+ const beside = path.join(path.dirname(execPath), bin);
178
+ if (runnable(beside)) return beside;
179
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
180
+ if (dir && runnable(path.join(dir, bin))) return path.join(dir, bin);
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /** The environment a shelled-out step gets: this process's, plus the directory of the node that is
186
+ * running it, at the FRONT of PATH.
187
+ *
188
+ * ⚠ MEASURED, and it is the other half of `resolveNpm`. Resolving npm absolutely is not enough:
189
+ * npm's own shebang is `#!/usr/bin/env node`, so under a hook's environment
190
+ * (`env -i PATH=/usr/bin:/bin`) the resolved npm was found, spawned, and died at exit 127 with
191
+ * `env: node: No such file or directory` — the interpreter lookup, one level below the one the
192
+ * shim fixes. A declared `postinstall` has exactly the same problem for exactly the same reason,
193
+ * so both steps are handed the same environment. */
194
+ export const childEnv = () => ({ ...process.env, PATH: [path.dirname(process.execPath), process.env.PATH ?? ''].filter(Boolean).join(path.delimiter) });
195
+
196
+ // One executor per step id, keyed by the id's kind. None decides ANYTHING — whether a step runs at
197
+ // all was settled by `planInstall`. `stdio` is the caller's, so a `--json` run can send a
198
+ // subprocess's chatter to stderr and keep stdout for the payload.
199
+ const RUN = {
200
+ // ⚠ `npm` ARRIVES AS AN ARGUMENT rather than being resolved here, and that is what makes the
201
+ // refusal below testable at all: with the resolution inlined, deleting the guard left the suite
202
+ // green, because no fixture can make npm unresolvable beside the node running the test.
203
+ engine: guard('engine', (ws, st, rel, stdio, npm) => {
204
+ // ⚠ THE HONEST BOARD LINE, not a crash. `npm` is missing far more often than `node` is —
205
+ // a hook's `sh` finds neither, and the shim resolves only node — so the step has to say
206
+ // WHICH of the two it could not find. `✖ engine:` is prepended by the guard.
207
+ if (!npm) throw new Error('cannot install — node found, npm not on PATH');
208
+ return spawnSync(npm, [fs.existsSync(path.join(ws.root, 'package-lock.json')) ? 'ci' : 'install', '--prefer-offline', '--no-audit', '--no-fund'], { cwd: ws.root, stdio, env: childEnv() }).status ?? 1;
209
+ }),
210
+ env: guard('.env', (ws, st) => placeLink(path.join(st.checkout.primary, '.env'), path.join(ws.root, '.env'))),
211
+ asset: guard('asset', (ws, st, rel) => placeLink(path.join(st.checkout.primary, rel), path.join(ws.root, rel))),
212
+ 'git-modules': guard('git modules', (ws) => restoreGitModules(ws)),
213
+ compile: guard('compile', (ws) => compile(ws)),
214
+ postinstall: guard('postinstall', (ws, st, rel, stdio) => spawnSync(st.postinstall, { cwd: ws.root, shell: true, stdio, env: { ...childEnv(), DT_PRIMARY: st.checkout.primary } }).status ?? 1),
215
+ };
216
+
217
+ /** Print the board and run the todo steps in order. `dryRun` prints and runs nothing. Returns 1 if
218
+ * any step errored — one failure never abandons the rest, because a checkout half-made-ready with
219
+ * a named failure is more useful than one that stopped at the first thing it could not do. */
220
+ export function applyInstall(ws, state, steps, { dryRun = false, log = console.log, stdio = 'inherit', npm = resolveNpm() } = {}) {
221
+ let failed = 0;
222
+ for (const s of steps) {
223
+ const glyph = s.state === 'todo' ? '▶' : s.state === 'already' ? '✔' : '—';
224
+ log(`${glyph} ${s.label}${s.why ? `\n ${s.why}` : ''}`);
225
+ if (s.state !== 'todo' || dryRun) continue;
226
+ const [kind, rel] = s.id.split(/:(.+)/);
227
+ const code = RUN[kind](ws, state, rel, stdio, npm);
228
+ if (code !== 0) { failed++; log(`✖ ${s.id} failed (exit ${code})`); }
229
+ }
230
+ return failed ? 1 : 0;
231
+ }
232
+
233
+ // ---- the harness hook forms ----------------------------------------------------------------
234
+ //
235
+ // A hook is not a person: it does not type a target, it hands the engine a JSON object on stdin and
236
+ // reads whatever comes back on stdout. Two of this file's verbs grow that form, and one function
237
+ // parses the payload for both.
238
+
239
+ /** The hook's payload, read to EOF. fd 0 rather than a stream, because every caller here is
240
+ * synchronous and a hook's stdin is a pipe that the harness closes.
241
+ *
242
+ * ⚠ A TTY DOES NOT COME BACK EMPTY — IT BLOCKS FOREVER (measured). `readFileSync(0)` on a terminal
243
+ * waits for an EOF the operator has no reason to know he must send, so `dt install --hook` typed
244
+ * by hand hung silently with no prompt and no output: the worst failure shape a CLI has, because
245
+ * there is nothing to read and nothing to search for. So the terminal case is refused BEFORE the
246
+ * read. `isTTY` is a parameter so the refusal can be tested without a pty. A closed or unreadable
247
+ * descriptor still comes back empty, and `readHookInput` names that one. */
248
+ export function readStdin(isTTY = process.stdin.isTTY) {
249
+ if (isTTY) throw new Error('--hook reads the harness\'s JSON on stdin — nothing is piped');
250
+ try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
251
+ }
252
+
253
+ /** What the harness said, as `{ cwd, name, raw }`.
254
+ *
255
+ * ⚠ THE FIELD NAMES ARE THE HARNESS'S. Claude Code's hooks reference documents every event as
256
+ * carrying `cwd`, and the two worktree events as additionally carrying `worktree_name` and
257
+ * `worktree_path` — so `worktree_name` is the primary spelling here and a bare `name` is only the
258
+ * fallback, not the other way round. `raw` is kept whole because a later verb reads a field these
259
+ * three do not name (`land` wants `worktree_path`), and because the KEYS are the whole diagnostic
260
+ * when a hook has been wired to the wrong event: a well-formed payload of the wrong shape is
261
+ * indistinguishable from a broken one until you can see what it did carry. */
262
+ export function readHookInput(stdinText) {
263
+ let raw;
264
+ try { raw = JSON.parse(stdinText); } catch { throw new Error('hook input is not JSON'); }
265
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('hook input is not JSON');
266
+ const cwd = raw.cwd ?? raw.worktree_path ?? null;
267
+ const name = raw.worktree_name ?? raw.name ?? null;
268
+ if (!cwd && !name) {
269
+ throw new Error(`hook input carries neither a cwd nor a worktree_name — keys received: ${Object.keys(raw).join(', ') || '(none)'}`);
270
+ }
271
+ return { cwd, name, raw };
272
+ }
273
+
274
+ // The three worktree-lifecycle events and the verb each one runs. NO MATCHER on any of them
275
+ // (spec §13.9): bootstrap is idempotent precisely so the session-start hook may fire on every
276
+ // event — `startup` alone would silence it on resume, clear, compact and fork, which is most of
277
+ // what a long worktree session actually does.
278
+ const CLAUDE_HOOKS = {
279
+ SessionStart: 'install --hook',
280
+ WorktreeCreate: 'add worktrees --hook',
281
+ WorktreeRemove: 'land --hook --dry-run',
282
+ };
283
+
284
+ /** Print the harness snippets for this workspace's declared harnesses.
285
+ *
286
+ * ⚠ NO ABSOLUTE MACHINE PATH IS EVER RENDERED, and the command is `sh <script>` rather than a bare
287
+ * `npm`/`npx`/`node` — the two constraints that between them decide every character of the line.
288
+ * `$CLAUDE_PROJECT_DIR` is documented to stay at the MAIN checkout even inside a worktree, which is
289
+ * exactly the engine wanted: the primary's pinned one, never whatever a fresh worktree lacks. The
290
+ * worktree's own cwd arrives in the hook's stdin instead.
291
+ *
292
+ * ⚠ AND THE ENGINE NEVER WRITES A HARNESS SETTINGS FILE. `.claude/settings.json` is the operator's,
293
+ * reviewed like any config change; a fifth harness channel writing into a user-owned file is a
294
+ * decision this engine has not made (spec §13.7). So this verb PRINTS — snippets on stdout, so the
295
+ * output can be piped, and everything else on stderr so it stays parseable. */
296
+ export function printAdapters(ws, { harnesses = ws.pkg.dreamteamer?.harnesses ?? ['claude-code'] } = {}) {
297
+ // ⚠ AN EMPTY RENDER IS A REFUSAL, NOT A SUCCESS. The whole point of this verb is that its stdout
298
+ // is redirected into a settings file — so printing nothing at exit 0 writes an EMPTY hooks.json
299
+ // over whatever was there, silently, on the one workspace that never declared the harness.
300
+ if (!harnesses.includes('claude-code')) {
301
+ console.error('no claude-code harness declared — nothing to render');
302
+ return 1;
303
+ }
304
+ for (const h of harnesses) {
305
+ // `claude-code` is the ONLY spelling: it is what KNOWN_HARNESSES holds and what every real
306
+ // package.json carries. The design doc's shorter `claude` names no harness this engine
307
+ // compiles for, so accepting it would only ever mask a misspelling.
308
+ if (h !== 'claude-code') {
309
+ console.error(`${h}: adapter not yet shipped (decision 315) — see using-dreamteamer › references/worktrees.md`);
310
+ continue;
311
+ }
312
+ const hooks = {};
313
+ for (const [event, verb] of Object.entries(CLAUDE_HOOKS)) {
314
+ hooks[event] = [{ hooks: [{ type: 'command', command: `sh "$CLAUDE_PROJECT_DIR/node_modules/dreamteamer/bin/dt-hook.sh" ${verb}`, timeout: 600 }] }];
315
+ }
316
+ console.error('# merge into .claude/settings.json — writing it is the operator\'s act, never the engine\'s');
317
+ console.log(JSON.stringify({ hooks }, null, 2));
318
+ }
319
+ return 0;
320
+ }
321
+
322
+ /** `dt install` on THIS checkout.
323
+ *
324
+ * ⚠ `--json` IS NOT A DRY RUN: it applies the plan and then reports it as data. Which means the
325
+ * run that most needs to be parseable — the FIRST install in a fresh worktree — is also the one
326
+ * with the most to say: `compile` logs its summary through console.log, and the shelled-out steps
327
+ * write to whatever handles they inherit. So under `--json` stdout carries the payload and
328
+ * NOTHING else: the board goes to stderr (a human watching a piped run still wants it),
329
+ * console.log is pointed at stderr for the duration, and each subprocess is handed stderr for its
330
+ * own stdout. A `--json` that only parses on an already-settled checkout is not an interface. */
331
+ export function installCommand(ws, rest) {
332
+ const flags = new Set(rest.filter((a) => a.startsWith('--')));
333
+ if (flags.has('--print-adapters')) return printAdapters(ws, {});
334
+ const hook = flags.has('--hook');
335
+ // ⚠ NEVER `process.chdir`. The hook runs in the PRIMARY (that is what $CLAUDE_PROJECT_DIR
336
+ // resolves to, worktree or not), and the checkout it is about is the one named on stdin — so the
337
+ // workspace is rebuilt from that path and everything downstream is unchanged. Installing
338
+ // `process.cwd()` instead would print a green board about the wrong checkout on every spawn.
339
+ if (hook) {
340
+ const input = readHookInput(readStdin());
341
+ if (!input.cwd) throw new Error(`hook input carries no cwd — keys received: ${Object.keys(input.raw).join(', ')}`);
342
+ ws = findWorkspace(input.cwd);
343
+ }
344
+ const json = flags.has('--json');
345
+ const state = observeState(ws);
346
+ const steps = planInstall(state, { linkEnv: flags.has('--link-env') });
347
+ const board = [];
348
+ const log = (l) => { board.push(l); (json ? console.error : console.log)(l); };
349
+ log(state.checkout.kind === 'linked'
350
+ ? `linked worktree of ${state.checkout.primary}${state.checkout.insideRoot ? '' : ' (outside its root)'}`
351
+ : 'primary checkout');
352
+ const stdout = console.log;
353
+ if (json) console.log = console.error; // compile() and the git-modules restore report through it
354
+ let code;
355
+ try {
356
+ code = applyInstall(ws, state, steps, { dryRun: flags.has('--dry-run'), log, stdio: json ? ['ignore', 2, 2] : 'inherit' });
357
+ } finally {
358
+ console.log = stdout;
359
+ }
360
+ if (json) { console.log(JSON.stringify({ checkout: state.checkout, steps, log: board, code }, null, 2)); return code; }
361
+ // ⚠ THE BOARD IS THE SESSION'S CONTEXT when a session-start hook runs it, so its LAST line is
362
+ // the landing instruction (spec §13.10) — the one thing a spawned session cannot work out for
363
+ // itself and the one thing it has to do before it finishes.
364
+ if (state.checkout.kind === 'linked') {
365
+ const name = path.basename(ws.root);
366
+ log(hook
367
+ ? `\nthis is worktree ${name} of ${state.checkout.primary}; before you finish, dt commit your records and tell the operator to run dt land worktrees/${name}`
368
+ : `\nbefore you finish here: dt commit your records, then the operator runs dt land worktrees/${name}.`);
369
+ }
370
+ return code;
371
+ }
372
+
373
+ // ---- worktrees: an OBSERVED entity ---------------------------------------------------------
374
+ //
375
+ // There is no `worktrees` collection and no record. `git worktree list` is the authority, and a
376
+ // stored copy of it could only ever drift — a worktree the operator removed by hand, a branch
377
+ // deleted from the primary, a directory moved. So every row below is DERIVED: git's porcelain plus
378
+ // two cheap reads per row (the dirty records under the data path, and whether a manifest is there).
379
+
380
+ /** Every checkout of this repo, primary first, as git reports it.
381
+ *
382
+ * ⚠ `ahead` IS NULL FOR A DETACHED WORKTREE, not 0 — `rev-list <primary>..<no branch>` has nothing
383
+ * to count, and `--detach` is how anyone bisects, so the null case is ordinary rather than exotic.
384
+ * `dirtyRecords` is deliberately scoped to the DATA path: uncommitted records are the thing that
385
+ * cannot be recovered from the primary, and they are invisible from it. */
386
+ export function listWorktrees(ws, git = defaultGit) {
387
+ const c = describeCheckout(ws.root, git);
388
+ const primaryBranch = git(['rev-parse', '--abbrev-ref', 'HEAD'], c.primary);
389
+ const dataPath = ws.pkg.dreamteamer?.['data-path'] ?? 'data';
390
+ const rows = [];
391
+ let cur = null;
392
+ for (const line of git(['worktree', 'list', '--porcelain'], c.primary).split('\n')) {
393
+ if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, head: null }; rows.push(cur); }
394
+ else if (line.startsWith('HEAD ')) cur.head = line.slice(5, 12);
395
+ else if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
396
+ }
397
+ return rows.map((w) => {
398
+ const primary = real(w.path) === real(c.primary);
399
+ // ⚠ THE NAME IS WHAT WAS TYPED, not the directory it landed in. `--path` lets the two
400
+ // differ, and the name is what `get`, the duplicate guard and the branch cleanup key on —
401
+ // so it is recovered from the branch this verb creates, which is the only place git keeps
402
+ // it. A worktree on someone else's branch, or a detached one, has nothing but its basename.
403
+ const name = !primary && w.branch?.startsWith('worktree-') ? w.branch.slice('worktree-'.length) : path.basename(w.path);
404
+ let ahead = null;
405
+ if (w.branch && !primary) {
406
+ try { ahead = Number(git(['rev-list', '--count', `${primaryBranch}..${w.branch}`], c.primary)); } catch { ahead = null; }
407
+ }
408
+ let dirtyRecords = 0;
409
+ try { dirtyRecords = git(['status', '--porcelain', '--', dataPath], w.path).split('\n').filter(Boolean).length; } catch { /* unreadable tree — a moved or deleted directory */ }
410
+ return {
411
+ name, path: w.path, branch: w.branch, head: w.head, primary, ahead, dirtyRecords,
412
+ bootstrapped: resolves(path.join(w.path, '.dreamteamer', 'manifest.yaml')),
413
+ };
414
+ });
415
+ }
416
+
417
+ /** A worktree by name or by path — one id shape, two spellings, because the name is what an
418
+ * operator types and the path is what a creation hook echoes. The PATH is tried first, since it is
419
+ * unique by construction and a name is not.
420
+ *
421
+ * ⚠ AN AMBIGUOUS NAME IS REFUSED, never resolved to whichever row git listed first. Two --temp
422
+ * sandboxes may share a name — their random holders keep the paths distinct, which is the whole
423
+ * point of having one — and silently picking one of them is how a removal lands on the wrong
424
+ * sandbox and takes work with it. */
425
+ export function findWorktree(ws, ref) {
426
+ if (!ref) return null;
427
+ const rows = listWorktrees(ws);
428
+ const byPath = rows.find((w) => real(w.path) === real(path.resolve(ws.root, ref)));
429
+ if (byPath) return byPath;
430
+ const byName = rows.filter((w) => w.name === ref);
431
+ if (byName.length > 1) {
432
+ throw new Error(`"${ref}" names ${byName.length} worktrees — address one by path:\n ${byName.map((w) => `worktrees/${w.path}`).join('\n ')}`);
433
+ }
434
+ return byName[0] ?? null;
435
+ }
436
+
437
+ /** The engine binary that is RUNNING — never `node_modules/dreamteamer` resolved in the workspace.
438
+ * The new tree may have no node_modules at all yet, and the engine the operator invoked is the one
439
+ * that should make it ready. */
440
+ const engineBin = () => fileURLToPath(new URL('../bin/dreamteamer.js', import.meta.url));
441
+
442
+ /**
443
+ * The worktree, MADE — everything `addWorktree` does except the final `console.log`, and the path it
444
+ * returns is that same line. Split out for `dt prove`, whose `writes` sandbox is a `--temp` worktree
445
+ * it has to keep the path of rather than read back off stdout.
446
+ *
447
+ * ⚠ `quiet` EXTENDS THE OPTION BAG, and it is not cosmetic. The install step runs with
448
+ * `stdio: 'inherit'`, so a caller printing a machine-readable stream (`dt prove --json` emits ONE
449
+ * object on stdout and nothing else) would have a compile transcript spliced in ahead of it.
450
+ * `addWorktree` never passes it, so what a `dt add worktrees` prints is unchanged.
451
+ */
452
+ export function createWorktree(ws, { name, dir, base = 'HEAD', temp = false, quiet = false }, git = defaultGit) {
453
+ if (!name) throw new Error('dt add worktrees needs --name <name>');
454
+ const c = describeCheckout(ws.root, git);
455
+ // ⚠ A --temp SANDBOX MAY REUSE A NAME, and refusing the second one would half-defeat the random
456
+ // holder that exists to allow it. So a sandbox is addressed by the PATH `add` printed, and the
457
+ // ambiguous name is refused at the READ instead (findWorktree).
458
+ if (!temp && findWorktree(ws, name)) throw new Error(`worktree "${name}" already exists — dt get worktrees/${name}`);
459
+ if (!temp && git(['branch', '--list', `worktree-${name}`], c.primary)) {
460
+ throw new Error(`branch worktree-${name} already exists — pick another name or delete the branch`);
461
+ }
462
+ // ⚠ --temp LIVES INSIDE THE PRIMARY ROOT TOO: `.worktrees/.tmp-<rand>/<name>`. Two measured
463
+ // reasons, neither cosmetic. `.env` is linked only for a worktree under the primary root, so a
464
+ // sandbox outside it would never get credentials; and git records the REALPATH of a worktree,
465
+ // while macOS resolves /var to /private/var — so an os.tmpdir() sandbox compares unequal to its
466
+ // own row in `git worktree list` and could be neither got nor removed by the path it printed.
467
+ // ⚠ NOT ACCEPTED AND IGNORED. --temp places the sandbox itself, so a --path alongside it names a
468
+ // directory that would silently not be the one made.
469
+ if (temp && dir) throw new Error('--temp places the sandbox itself (.worktrees/.tmp-<rand>/<name>) — pass either --temp or --path <dir>, not both');
470
+ const holder = path.join(c.primary, '.worktrees');
471
+ if (temp) fs.mkdirSync(holder, { recursive: true });
472
+ // ⚠ NEVER PRE-CREATE `target`: `git worktree add` creates it, and an empty pre-created folder is
473
+ // swept by compile's empty-directory pass.
474
+ const sandbox = temp ? fs.mkdtempSync(path.join(holder, '.tmp-')) : null;
475
+ const target = sandbox ? path.join(sandbox, name) : path.resolve(ws.root, dir ?? path.join(holder, name));
476
+ try {
477
+ git(temp ? ['worktree', 'add', '--detach', target, base] : ['worktree', 'add', '-b', `worktree-${name}`, target, base], c.primary);
478
+ } catch (e) {
479
+ // The holder was made a line ago and holds nothing yet: a bad --base would otherwise leave
480
+ // an empty `.tmp-<rand>` behind, and `.worktrees/` is ignored, so nobody would ever see it.
481
+ if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true });
482
+ throw e;
483
+ }
484
+ // The engine must be reachable from the new tree before `install` can compile there. When THIS
485
+ // tree's node_modules/dreamteamer is a SYMLINK (a dev shadow, a test fixture) mirror that ONE
486
+ // link — never the node_modules directory, which is a real folder holding it. Otherwise
487
+ // install's own engine step runs npm there, so a real workspace pays one `npm ci` per worktree
488
+ // and per --temp sandbox (from the npm cache).
489
+ const eng = path.join(ws.root, 'node_modules', 'dreamteamer');
490
+ if (isLink(eng)) {
491
+ fs.mkdirSync(path.join(target, 'node_modules'), { recursive: true });
492
+ fs.symlinkSync(realpathSync(eng), path.join(target, 'node_modules', 'dreamteamer'), 'dir');
493
+ }
494
+ // ⚠ AND THE SAME FOR A SHADOWING `git_modules` ENTRY, for the same reason one layer up. A
495
+ // workspace on the dev-clone toggle runs its engine — or one of its modules — from a SYMLINK
496
+ // under `git_modules/`, which is gitignored and therefore per checkout: a worktree cut from such
497
+ // a workspace got neither the link nor a clone, so its own `install` fell back to the PINNED npm
498
+ // copy and it compiled against a different compiler than the tree it was cut from. Measured in a
499
+ // sandbox of a shadowed workspace: compile hard-failed on a kind the pinned engine does not know
500
+ // and every proof in it reported FAIL.
501
+ //
502
+ // LINKS ONLY. A real `git_modules/<name>` clone is per-checkout working state that `install`
503
+ // restores from the lockfile; linking one would give two checkouts a single working tree.
504
+ const shadows = path.join(ws.root, 'git_modules');
505
+ for (const name of (fs.existsSync(shadows) ? fs.readdirSync(shadows) : [])) {
506
+ if (!isLink(path.join(shadows, name))) continue;
507
+ fs.mkdirSync(path.join(target, 'git_modules'), { recursive: true });
508
+ fs.symlinkSync(realpathSync(path.join(shadows, name)), path.join(target, 'git_modules', name), 'dir');
509
+ }
510
+ const r = spawnSync(process.execPath, [engineBin(), 'install'], { cwd: target, stdio: quiet ? 'pipe' : 'inherit' });
511
+ if (r.status !== 0) console.warn(`⚠ install inside ${target} exited ${r.status} — the worktree exists; re-run dt install there`);
512
+ return target;
513
+ }
514
+
515
+ /** `dt add worktrees --name <name>`. Contract: the PATH is the last line, and the code is 0.
516
+ *
517
+ * ⚠ `--json` IS HONOURED HERE OR NOWHERE. The flag was in the verb's table and read by neither
518
+ * form, so `dt add worktrees --name x --json` printed a bare path at exit 0 — a flag accepted and
519
+ * dropped, which is the class this file's own comments call a silent wrong answer. Under it stdout
520
+ * carries ONE object and nothing else, so the install inside runs quiet: its compile transcript
521
+ * would otherwise be spliced in ahead of the payload. */
522
+ export function addWorktree(ws, opts, git = defaultGit) {
523
+ const json = !!opts.json;
524
+ const target = createWorktree(ws, { ...opts, quiet: json }, git);
525
+ console.log(json ? JSON.stringify({ path: target }) : target); // LAST line, by contract: a creation hook echoes it
526
+ return 0;
527
+ }
528
+
529
+ /** ⚠ IT REFUSES BY DEFAULT, AND THE REASON IS NAMED. A worktree holds two things the primary cannot
530
+ * see: records written but not committed, and commits not yet landed. `git worktree remove` knows
531
+ * about neither — it checks a dirty tree and stops there — so the records, the one thing this
532
+ * engine exists to keep, are exactly what a bare `remove` would take with it. */
533
+ export function removeWorktree(ws, ref, { force = false } = {}, git = defaultGit) {
534
+ const w = findWorktree(ws, ref);
535
+ if (!w) throw new Error(`no worktree "${ref}" — dt list worktrees`);
536
+ if (w.primary) throw new Error('refusing to remove the primary checkout');
537
+ const c = describeCheckout(ws.root, git);
538
+ const primaryBranch = git(['rev-parse', '--abbrev-ref', 'HEAD'], c.primary);
539
+ // ⚠ THE DATA-LOSS PATH, and `--temp` makes it the ordinary one. `ahead` is null for a DETACHED
540
+ // worktree by construction (the field is specified that way and pinned by its own test), and
541
+ // `git worktree remove` checks only modified and untracked files — never reachability. So a
542
+ // sandbox whose work had been COMMITTED read as clean with nothing ahead and was removed at exit
543
+ // 0, orphaning every commit the moment its HEAD went with it.
544
+ //
545
+ // ⚠ AND THE DETACHED QUESTION IS A DIFFERENT QUESTION. A branch's work is held by the branch and
546
+ // merely un-LANDED (`primaryBranch..branch`, fixed by a merge); a detached HEAD's work is held by
547
+ // nothing but the HEAD about to be deleted, i.e. ORPHANED — so the measure is "reachable from no
548
+ // ref at all", and once any branch holds it the removal is safe. `--not --all` cannot answer
549
+ // this: `--all` examines every working tree, the sandbox's own HEAD included, so it answered 0
550
+ // for the very commits at risk (measured). `--branches --tags --remotes` is the honest ref set.
551
+ //
552
+ // ⚠ AND IT FAILS CLOSED. This measurement is what the refusal turns on, so a `catch` that set it
553
+ // back to null answered "nothing ahead" for a measurement that never ran — no refusal fired and
554
+ // the destructive removal went through at exit 0, which is the exact loss the guard exists to
555
+ // prevent, reached by the one path nobody walks. An unmeasured guard is a refusal, not a pass.
556
+ //
557
+ // ⚠ SO THE RAW STRING IS VALIDATED, NOT THE NUMBER, and the difference is a data-loss bug.
558
+ // `Number('')` is 0 and `Number.isInteger(0)` is true, so an integer check waves an EMPTY answer
559
+ // through as "nothing ahead" — the same fail-open, one layer down. `/^\d+$/` is the only gate
560
+ // that separates "git counted zero" from "git said nothing"; `Number()` runs after it, on a
561
+ // string already known to be a count. (`git` is an exported PARAMETER of this function, so the
562
+ // trimming, exit-code-checking `defaultGit` is not the only runner this has to survive.)
563
+ let ahead = w.ahead;
564
+ let unmeasured = null;
565
+ const orphaned = w.ahead === null && !w.primary && w.head;
566
+ if (orphaned) {
567
+ try {
568
+ const out = String(git(['rev-list', '--count', w.head, '--not', '--branches', '--tags', '--remotes'], c.primary) ?? '').trim();
569
+ if (/^\d+$/.test(out)) ahead = Number(out);
570
+ else unmeasured = out ? `git rev-list answered "${out}", which is not a count` : 'git rev-list answered nothing';
571
+ } catch (e) { unmeasured = String(e.message ?? e).split('\n')[0]; }
572
+ }
573
+ if (!force) {
574
+ // The unmeasured case leads, because it is the one refusal that cannot name what is at risk:
575
+ // a detached worktree's commits are held by nothing but the HEAD about to be deleted.
576
+ if (unmeasured) {
577
+ throw new Error(`refusing to remove worktree "${w.name}": whether its commits are reachable from anything else could not be measured — ${unmeasured}\n ${w.head} is held by nothing but this worktree unless a ref names it: git branch <name> ${w.head} to keep it, or --force to remove without the check`);
578
+ }
579
+ // ⚠ THE DIRECTORY CAN BE GONE while git still lists the worktree — someone deleted it by
580
+ // hand. `list` already reports that (NOT installed); here, reading its dirty state in a cwd
581
+ // that does not exist died as `✖ spawnSync git ENOENT`, a message about the wrong thing
582
+ // entirely on the one state where dropping the registration cannot lose anything.
583
+ if (!resolves(w.path)) throw new Error(`worktree "${w.name}" is registered but its directory is gone (${w.path}) — nothing to lose: dt rm worktrees/${w.name} --force drops the registration`);
584
+ const dirty = git(['status', '--porcelain'], w.path).split('\n').filter(Boolean).length;
585
+ const why = [];
586
+ if (w.dirtyRecords) why.push(`${w.dirtyRecords} dirty record(s) — dt commit them, or --force to discard`);
587
+ else if (dirty) why.push(`${dirty} uncommitted change(s) — commit or --force`);
588
+ if (ahead && orphaned) why.push(`${ahead} commit(s) reachable from NOTHING but this worktree — git branch <name> ${w.head} to keep them, or --force to discard`);
589
+ else if (ahead) why.push(`${ahead} commit(s) not on ${primaryBranch} — merge branch ${w.branch}, or --force to discard`);
590
+ if (why.length) throw new Error(`refusing to remove worktree "${w.name}":\n ${why.join('\n ')}`);
591
+ }
592
+ git(['worktree', 'remove', ...(force ? ['--force'] : []), w.path], c.primary);
593
+ // Only a branch this verb CREATED is deleted with the worktree. A worktree checked out on `main`
594
+ // or on someone's feature branch keeps it — and SAYS SO, because a branch left behind silently
595
+ // is a branch nobody knows to look at: `list` cannot show it once the worktree is gone.
596
+ if (w.branch === `worktree-${w.name}`) {
597
+ try { git(['branch', force ? '-D' : '-d', w.branch], c.primary); } catch { console.warn(`⚠ branch ${w.branch} kept (not merged)`); }
598
+ } else if (w.branch) {
599
+ console.log(` branch ${w.branch} kept — it is not the worktree-${w.name} this verb creates`);
600
+ }
601
+ // A sandbox lives alone in its own `.tmp-<rand>` holder; nothing else does. The holder goes with
602
+ // it, or every sandbox ever cut leaves an empty directory behind for ever — and `.worktrees/` is
603
+ // ignored, which is exactly why it would accumulate unnoticed.
604
+ const holder = path.dirname(w.path);
605
+ if (path.basename(holder).startsWith('.tmp-') && path.dirname(holder) === path.join(c.primary, '.worktrees')) {
606
+ try { fs.rmdirSync(holder); } catch { /* not empty — something else is in there */ }
607
+ }
608
+ console.log(`✔ removed worktree ${w.name}`);
609
+ return 0;
610
+ }
611
+
612
+ /** `dt list|get|add|rm worktrees[/<ref>]`. The flags arrive PARSED and already refused by the
613
+ * surface — `worktrees` has no descriptor, so nothing downstream would catch a typo. */
614
+ export function worktreeCommand(ws, verb, target, flags = {}) {
615
+ // A repeated flag arrives as an array (the parser promotes rather than overwrites). Every flag
616
+ // here holds ONE value, so a repeat is a mistake — and a silent last-one-wins would spell
617
+ // `--name a --name b` as the branch `worktree-a,b`.
618
+ const one = (k) => {
619
+ if (Array.isArray(flags[k])) throw new Error(`--${k} was given ${flags[k].length} times and takes ONE value: ${flags[k].map((x) => `--${k} ${x}`).join(' ')}`);
620
+ return typeof flags[k] === 'string' ? flags[k] : undefined;
621
+ };
622
+ const json = !!flags.json;
623
+ // SLICE, never split: `worktrees//abs/path` is one valid id, and a split at '/' mangles it.
624
+ const id = target.startsWith('worktrees/') ? target.slice('worktrees/'.length) : null;
625
+ const needId = () => { if (!id) throw new Error(`dt ${verb} needs a worktree: dt ${verb} worktrees/<name>`); return id; };
626
+ switch (verb) {
627
+ case 'list': {
628
+ const rows = listWorktrees(ws);
629
+ if (json) console.log(JSON.stringify(rows, null, 2));
630
+ else for (const w of rows) {
631
+ console.log(`${w.primary ? '●' : '○'} ${w.name.padEnd(24)} ${(w.branch ?? '(detached)').padEnd(28)} ${w.head} ahead ${w.ahead ?? '—'} dirty records ${w.dirtyRecords} ${w.bootstrapped ? 'installed' : 'NOT installed'} ${w.path}`);
632
+ }
633
+ return 0;
634
+ }
635
+ case 'get': {
636
+ const w = findWorktree(ws, needId());
637
+ if (!w) throw new Error(`no worktree "${id}" — dt list worktrees`);
638
+ console.log(json ? JSON.stringify(w, null, 2) : Object.entries(w).map(([k, v]) => `${k}: ${v}`).join('\n'));
639
+ return 0;
640
+ }
641
+ case 'add': {
642
+ // ⚠ THE HOOK IMPLIES THE PLACEMENT, and it is `.worktrees/`, never `.claude/worktrees/`.
643
+ // `.worktrees/` is already gitignored by every workspace `init` writes, while anything
644
+ // under `.claude` sits inside compile's empty-directory sweep — so the primary's next
645
+ // compile would walk a LIVE worktree and delete its empty folders. Claude's own placement
646
+ // logic is replaced by this hook, so the path printed last is the path it then uses.
647
+ if (!flags.hook) return addWorktree(ws, { name: one('name'), dir: one('path'), base: one('base'), temp: !!flags.temp, json });
648
+ // ⚠ AND IT IS A FORM, so it refuses the other form's vocabulary itself — the same policy
649
+ // `dt install`'s three forms follow, for the same measured reason. The flag table can
650
+ // only say which flags the VERB has; it cannot know that `--temp` is meaningless once
651
+ // the name and the placement both come off stdin. `--hook --temp` cut a PERMANENT branch
652
+ // worktree at exit 0, and `--hook --base nosuchref` cut from HEAD at exit 0: a flag
653
+ // accepted and dropped is a silent wrong answer, not a cosmetic loss.
654
+ const stray = ['temp', 'path', 'base'].filter((f) => flags[f] !== undefined);
655
+ if (stray.length) {
656
+ const named = stray.map((f) => `--${f}`).join(' ');
657
+ throw new Error(`${named} ${stray.length > 1 ? 'are not flags' : 'is not a flag'} of \`dt add worktrees --hook\` — that form takes --hook --json`);
658
+ }
659
+ const input = readHookInput(readStdin());
660
+ if (!input.name) throw new Error(`hook input carries no worktree_name — keys received: ${Object.keys(input.raw).join(', ')}`);
661
+ return addWorktree(ws, { name: input.name, dir: path.join('.worktrees', input.name), json });
662
+ }
663
+ case 'rm': return removeWorktree(ws, needId(), { force: !!flags.force });
664
+ default: throw new Error(`dt ${verb} does not apply to worktrees — they take list · get · add · rm`);
665
+ }
666
+ }