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.
- package/bin/dt-hook.sh +54 -0
- package/collections/collections.collection.yaml +9 -0
- package/collections/command-bindings.collection.yaml +1 -1
- package/collections/proofs.collection.yaml +96 -0
- package/collections/repos.collection.yaml +4 -3
- package/package.json +2 -2
- package/skills/using-dreamteamer/SKILL.md +16 -7
- package/skills/using-dreamteamer/references/before-you-build.md +16 -2
- package/skills/using-dreamteamer/references/collections.md +50 -9
- package/skills/using-dreamteamer/references/commands.md +16 -3
- package/skills/using-dreamteamer/references/data-modeling.md +2 -2
- package/skills/using-dreamteamer/references/exporting.md +53 -0
- package/skills/using-dreamteamer/references/proofs.md +435 -0
- package/skills/using-dreamteamer/references/skills.md +8 -0
- package/skills/using-dreamteamer/references/worktrees.md +235 -0
- package/src/checkout.js +666 -0
- package/src/cli.js +301 -40
- package/src/collections-cli.js +237 -63
- package/src/commit.js +1 -1
- package/src/compile.js +190 -15
- package/src/export-notebooklm.js +502 -0
- package/src/filter.js +4 -1
- package/src/harnesses.js +25 -10
- package/src/init.js +2 -0
- package/src/land.js +736 -0
- package/src/presentation.js +28 -1
- package/src/prove.js +1922 -0
- package/src/runtime.js +26 -0
- package/src/schema-ops.js +48 -25
package/src/checkout.js
ADDED
|
@@ -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
|
+
}
|