dreamteamer 0.30.0 → 0.31.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/src/api.js ADDED
@@ -0,0 +1,70 @@
1
+ // The PUBLIC API — `import … from 'dreamteamer'`. Everything a surface (the VS Code extension, the
2
+ // mobile app) or an extension (http, workflows, notebooklm) may call, and nothing else: package.json
3
+ // `exports` hides `src/*`, so an internal file can be split, renamed or deleted without a cross-repo
4
+ // break. That break used to be the rule — the extension imported fifteen internal files by path, and
5
+ // deleting one took `activate()` down before the tree view existed.
6
+ //
7
+ // Grouped by task. Each name is the ONE implementation of its operation; nothing here wraps or
8
+ // re-implements. `api.d.ts` beside this file is the typed contract, and a test pins the two together.
9
+ //
10
+ // Importing this module prints nothing, writes nothing, binds no port and touches no network.
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import * as self from './api.js';
14
+ import { findWorkspace } from './workspace.js';
15
+ import { loadExtensions } from './extensions.js';
16
+ import { KINDS } from './compile.js';
17
+ import { DERIVED_KINDS } from './runtime.js';
18
+ import { KNOWN_HARNESSES } from './harnesses.js';
19
+ import { CORE_VERBS } from './cli.js';
20
+ import { installCommand as installCheckout } from './checkout.js';
21
+
22
+ // Everything the record half exports (the browser-safe entry, `dreamteamer/records`), then the rest.
23
+ export * from './records-api.js';
24
+
25
+ // ---- the workspace ------------------------------------------------------------------------------
26
+
27
+ /**
28
+ * The workspace at (or above) `start`, with its declared extensions ACTIVATED against this engine.
29
+ * The handle every other call takes: `{ root, pkg, extensions }`. Performs no compile, install,
30
+ * network call or write, and never changes the process cwd.
31
+ */
32
+ export async function openWorkspace(start = process.cwd()) {
33
+ const ws = findWorkspace(start);
34
+ ws.extensions = await loadExtensions(ws, self, { verbs: CORE_VERBS, kinds: [...KINDS, ...DERIVED_KINDS], harnesses: KNOWN_HARNESSES });
35
+ return ws;
36
+ }
37
+ export { findWorkspace };
38
+ export { EXTENSION_API, declaredExtensions } from './extensions.js';
39
+
40
+ /** The engine's own CLI entry — what a tool spawns to run `dt` in another checkout. */
41
+ export const engineBin = fileURLToPath(new URL('../bin/dreamteamer.js', import.meta.url));
42
+ export const engineRoot = path.dirname(path.dirname(engineBin));
43
+
44
+ // ---- values the workspace half adds ---------------------------------------------------------------
45
+ export { envContext, renderTemplate, parseEnvValues } from './env-vars.js';
46
+ export { satisfies } from './semver.js';
47
+
48
+ // ---- the compiler, schema and module operations ---------------------------------------------------
49
+ export { compile, staleness, warnIfStale, discoverModules, CompileError, KINDS } from './compile.js';
50
+ export { MANAGED_BLOCKS } from './harnesses.js';
51
+ export {
52
+ createCollection, removeCollection, renameCollection, moveCollection, setCollectionScalars,
53
+ addField, updateField, removeField, removeFieldPlan, renameField, renameFieldPlan, fieldDef, statedKeywords,
54
+ saveUiView, removeUiView,
55
+ createModule, setModule, renameModule, removeModule,
56
+ createSkill, refuseHandAuthored, removeEntity, renameEntity, setEntityFrontmatter,
57
+ } from './schema-ops.js';
58
+ export { init, ensureRepo, ensureAllRepos, listRepos, installClone } from './init.js';
59
+
60
+ // ---- the checkout: which one this is, and how it becomes ready ---------------------------------
61
+ export { describeCheckout, resolveNpm, childEnv, readHookInput, readStdin } from './checkout.js';
62
+
63
+ /** `dt install` on the checkout `ws`, as the CLI runs it. The compile step reopens the workspace with
64
+ * `opts.open` — `openWorkspace` unless a caller injects another — so the extensions npm just
65
+ * installed take part in that compile. The one name here that supplies a default rather than
66
+ * re-exporting: the checkout layer cannot import the opener that activates extensions. */
67
+ export const installCommand = (ws, argv, opts = {}) => installCheckout(ws, argv, { open: openWorkspace, ...opts });
68
+
69
+ // ---- CLI helpers an extension command reuses -----------------------------------------------------
70
+ export { emit, parseArgs } from './collections-cli.js';
package/src/checkout.js CHANGED
@@ -6,7 +6,6 @@ import { execFileSync, spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { staleness, compile, discoverModules } from './compile.js';
8
8
  import { install as restoreGitModules } from './init.js';
9
- import { findWorkspace } from './workspace.js';
10
9
 
11
10
  export const defaultGit = (args, cwd) => {
12
11
  try {
@@ -55,9 +54,13 @@ export function describeCheckout(rootArg, git = defaultGit) {
55
54
  export function planInstall(state, opts = {}) {
56
55
  const { checkout: c } = state;
57
56
  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' });
57
+ // ⚠ EVERY declared direct dependency, not just the engine. A worktree whose engine is a mirrored dev
58
+ // LINK used to read as ready while an installed extension (@dreamteamer/workflows) was missing — so
59
+ // npm never ran, and compile then refused the extension's source folder as an unknown kind.
60
+ const missing = state.missingDeps ?? [];
61
+ steps.push(missing.length
62
+ ? { id: 'dependencies', label: `dependencies: ${missing.join(', ')} missing — npm ci --prefer-offline (package-lock.json) or npm install; a linked one is kept`, state: 'todo' }
63
+ : { id: 'dependencies', label: 'dependencies: every declared package present', state: 'already' });
61
64
  if (c.kind === 'primary') steps.push({ id: 'env', label: '.env: primary checkout — nothing to link', state: 'skip' });
62
65
  else if (state.hasEnv && !state.envIsLink) steps.push({ id: 'env', label: '.env: this worktree carries its own .env — left alone', state: 'already' });
63
66
  else if (state.hasEnv && state.envIsLink) steps.push({ id: 'env', label: '.env: linked to the primary', state: 'already' });
@@ -129,7 +132,7 @@ export function observeState(ws) {
129
132
  const s = staleness(ws.root);
130
133
  return {
131
134
  checkout,
132
- hasEngine: resolves(here('node_modules/dreamteamer')),
135
+ missingDeps: declaredDeps(ws).filter((d) => !resolves(here(path.join('node_modules', d)))),
133
136
  hasEnv: resolves(here('.env')), envIsLink: isLink(here('.env')), primaryHasEnv: resolves(there('.env')),
134
137
  localAssets: declaredLocalAssets(ws).map((a) => ({ ...a, presentHere: resolves(here(a.rel)), isLinkHere: isLink(here(a.rel)), presentInPrimary: resolves(there(a.rel)) })),
135
138
  // ⚠ THE MISSING clones, not every declared one. A settled worktree would otherwise print
@@ -141,6 +144,31 @@ export function observeState(ws) {
141
144
  };
142
145
  }
143
146
 
147
+ /** Every top-level package in node_modules that is a SYMLINK (scoped ones included), as
148
+ * `[path, raw link target]` — declared or not: the engine a checkout was cut to test is often an
149
+ * undeclared link, and it is exactly the one npm's pruning would delete. */
150
+ function linkedPackages(root) {
151
+ const nm = path.join(root, 'node_modules');
152
+ const out = [];
153
+ const scan = (dir) => {
154
+ let names = [];
155
+ try { names = fs.readdirSync(dir); } catch { return; }
156
+ for (const n of names) {
157
+ if (n.startsWith('.')) continue;
158
+ const p = path.join(dir, n);
159
+ let st;
160
+ try { st = fs.lstatSync(p); } catch { continue; }
161
+ if (st.isSymbolicLink()) out.push([p, fs.readlinkSync(p)]);
162
+ else if (n.startsWith('@') && dir === nm && st.isDirectory()) scan(p);
163
+ }
164
+ };
165
+ scan(nm);
166
+ return out;
167
+ }
168
+
169
+ /** The workspace's direct dependencies, sorted — what `npm install` is responsible for. */
170
+ const declaredDeps = (ws) => Object.keys({ ...ws.pkg?.dependencies, ...ws.pkg?.devDependencies }).sort();
171
+
144
172
  /** Place a symlink, replacing a dangling one. A step only reaches here because the observer read
145
173
  * the path as absent, which a broken link is — so the leftover has to be cleared, never trusted. */
146
174
  function placeLink(target, at) {
@@ -172,7 +200,7 @@ export function resolveNpm(execPath = process.execPath, env = process.env) {
172
200
  // ⚠ EXECUTABLE, not merely present. `resolves` is `existsSync`, which is true of a DIRECTORY
173
201
  // called `npm` and of a file nobody may run — and this path is then spawned, so the difference
174
202
  // 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.
203
+ // and an EACCES nobody planned for. A proof runner's `requires: { bin: … }` learned the same.
176
204
  const runnable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
177
205
  const beside = path.join(path.dirname(execPath), bin);
178
206
  if (runnable(beside)) return beside;
@@ -200,32 +228,66 @@ const RUN = {
200
228
  // ⚠ `npm` ARRIVES AS AN ARGUMENT rather than being resolved here, and that is what makes the
201
229
  // refusal below testable at all: with the resolution inlined, deleting the guard left the suite
202
230
  // green, because no fixture can make npm unresolvable beside the node running the test.
203
- engine: guard('engine', (ws, st, rel, stdio, npm) => {
231
+ dependencies: guard('dependencies', (ws, st, rel, stdio, npm) => {
204
232
  // ⚠ THE HONEST BOARD LINE, not a crash. `npm` is missing far more often than `node` is —
205
233
  // 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.
234
+ // WHICH of the two it could not find. `✖ dependencies:` is prepended by the guard.
207
235
  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;
236
+ // ⚠ A DELIBERATE DEVELOPMENT LINK SURVIVES. `npm ci` deletes node_modules and `npm install`
237
+ // re-points a linked package at the registry, so a dev engine (or a linked extension) would be
238
+ // silently replaced by the published copy — a checkout running a different engine than the one
239
+ // it was cut to test. Every linked direct dependency is recorded here and put back after npm.
240
+ // ⚠ WORKING links only. A DANGLING one (a moved checkout, a deleted vendor folder) is exactly the
241
+ // broken entry this step exists to repair — restoring it undid npm's repair and failed the install.
242
+ const links = linkedPackages(ws.root).filter(([p]) => resolves(p));
243
+ const status = 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(), timeout: 600_000 }).status ?? 1;
244
+ for (const [p, target] of links) {
245
+ let same = false;
246
+ try { same = fs.lstatSync(p).isSymbolicLink() && fs.readlinkSync(p) === target; } catch { /* gone */ }
247
+ if (same) continue;
248
+ fs.rmSync(p, { recursive: true, force: true });
249
+ fs.mkdirSync(path.dirname(p), { recursive: true });
250
+ fs.symlinkSync(target, p);
251
+ console.error(`… kept the development link ${path.relative(ws.root, p)} → ${target}`);
252
+ }
253
+ if (status !== 0) return status;
254
+ // ⚠ npm's exit code is not the answer: a `file:` dependency whose folder does not exist is
255
+ // "added" as a DANGLING link at exit 0. Ready means every declared package now resolves.
256
+ const still = declaredDeps(ws).filter((d) => !resolves(path.join(ws.root, 'node_modules', d)));
257
+ if (still.length) throw new Error(`npm exited 0, but ${still.join(', ')} still do${still.length === 1 ? 'es' : ''} not resolve in node_modules`);
258
+ return 0;
209
259
  }),
210
260
  env: guard('.env', (ws, st) => placeLink(path.join(st.checkout.primary, '.env'), path.join(ws.root, '.env'))),
211
261
  asset: guard('asset', (ws, st, rel) => placeLink(path.join(st.checkout.primary, rel), path.join(ws.root, rel))),
212
262
  'git-modules': guard('git modules', (ws) => restoreGitModules(ws)),
213
- compile: guard('compile', (ws) => compile(ws)),
263
+ compile: guard('compile', (ws) => compile(ws)), // `ws` is REOPENED first — see applyInstall
214
264
  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
265
  };
216
266
 
217
267
  /** Print the board and run the todo steps in order. `dryRun` prints and runs nothing. Returns 1 if
218
268
  * any step errored — one failure never abandons the rest, because a checkout half-made-ready with
219
269
  * 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;
270
+ export async function applyInstall(ws, state, steps, { dryRun = false, log = console.log, stdio = 'inherit', npm = resolveNpm(), open = null } = {}) {
271
+ let failed = 0, installed = false;
222
272
  for (const s of steps) {
273
+ // ⚠ NEW PACKAGES ARE NEW SOURCES. The plan judged the runtime fresh BEFORE npm put a module (or an
274
+ // extension) into node_modules, so a compiled workspace that just gained a dependency kept the
275
+ // old runtime at exit 0. Once dependencies were installed, the compile runs whatever the plan said.
276
+ if (s.id === 'compile' && installed && s.state === 'already') Object.assign(s, { label: 'compile: dependencies were just installed', state: 'todo' });
223
277
  const glyph = s.state === 'todo' ? '▶' : s.state === 'already' ? '✔' : '—';
224
278
  log(`${glyph} ${s.label}${s.why ? `\n ${s.why}` : ''}`);
225
279
  if (s.state !== 'todo' || dryRun) continue;
226
280
  const [kind, rel] = s.id.split(/:(.+)/);
281
+ // ⚠ COMPILE WITH WHAT WAS JUST INSTALLED. The handle was opened before npm ran, so its
282
+ // extension list predates the packages npm just put in node_modules: a first install compiled an
283
+ // extension's collection as an ordinary one (`storage.base: workspace`) and left the manifest
284
+ // without the provider, and only a second, manual compile repaired it. Reopened here, once.
285
+ if (kind === 'compile' && open) {
286
+ try { ws = await open(ws.root); } catch (e) { failed++; log(`✖ compile: the workspace would not reopen after installing — ${e.message.split('\n')[0]}`); continue; }
287
+ }
227
288
  const code = RUN[kind](ws, state, rel, stdio, npm);
228
289
  if (code !== 0) { failed++; log(`✖ ${s.id} failed (exit ${code})`); }
290
+ else if (kind === 'dependencies') installed = true;
229
291
  }
230
292
  return failed ? 1 : 0;
231
293
  }
@@ -250,7 +312,8 @@ export function readStdin(isTTY = process.stdin.isTTY) {
250
312
  try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
251
313
  }
252
314
 
253
- /** What the harness said, as `{ cwd, name, raw }`.
315
+ /** What the harness said, as `{ cwd, name, raw }`. Exported through the public API because an
316
+ * extension's own hook form (`worktree add --hook`) parses the same payload.
254
317
  *
255
318
  * ⚠ THE FIELD NAMES ARE THE HARNESS'S. Claude Code's hooks reference documents every event as
256
319
  * carrying `cwd`, and the two worktree events as additionally carrying `worktree_name` and
@@ -271,15 +334,12 @@ export function readHookInput(stdinText) {
271
334
  return { cwd, name, raw };
272
335
  }
273
336
 
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
- };
337
+ // The hook events and the verb each one runs. Core owns ONE — making the checkout a session opens in
338
+ // ready — and an installed extension adds its own (`hooks:` in its contribution; the worktree
339
+ // lifecycle lives in @dreamteamer/workflows). NO MATCHER on any of them (spec §13.9): bootstrap is
340
+ // idempotent precisely so the session-start hook may fire on every event — `startup` alone would
341
+ // silence it on resume, clear, compact and fork, which is most of what a long session actually does.
342
+ const CLAUDE_HOOKS = { SessionStart: 'install --hook' };
283
343
 
284
344
  /** Print the harness snippets for this workspace's declared harnesses.
285
345
  *
@@ -293,7 +353,7 @@ const CLAUDE_HOOKS = {
293
353
  * reviewed like any config change; a fifth harness channel writing into a user-owned file is a
294
354
  * decision this engine has not made (spec §13.7). So this verb PRINTS — snippets on stdout, so the
295
355
  * 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'] } = {}) {
356
+ export function printAdapters(ws, { harnesses = ws.pkg.dreamteamer?.harnesses ?? ['claude-code'], extensions = ws.extensions ?? [] } = {}) {
297
357
  // ⚠ AN EMPTY RENDER IS A REFUSAL, NOT A SUCCESS. The whole point of this verb is that its stdout
298
358
  // is redirected into a settings file — so printing nothing at exit 0 writes an EMPTY hooks.json
299
359
  // over whatever was there, silently, on the one workspace that never declared the harness.
@@ -306,11 +366,18 @@ export function printAdapters(ws, { harnesses = ws.pkg.dreamteamer?.harnesses ??
306
366
  // package.json carries. The design doc's shorter `claude` names no harness this engine
307
367
  // compiles for, so accepting it would only ever mask a misspelling.
308
368
  if (h !== 'claude-code') {
309
- console.error(`${h}: adapter not yet shipped (decision 315) — see using-dreamteamer › references/worktrees.md`);
369
+ console.error(`${h}: adapter not yet shipped (decision 315)`);
310
370
  continue;
311
371
  }
312
372
  const hooks = {};
313
- for (const [event, verb] of Object.entries(CLAUDE_HOOKS)) {
373
+ const events = { ...CLAUDE_HOOKS };
374
+ for (const e of extensions) {
375
+ for (const [event, verb] of Object.entries(e.hooks ?? {})) {
376
+ if (events[event]) throw new Error(`extension ${e.name} and ${Object.keys(CLAUDE_HOOKS).includes(event) ? 'the engine' : 'another extension'} both hook ${event} — uninstall or disable one`);
377
+ events[event] = verb;
378
+ }
379
+ }
380
+ for (const [event, verb] of Object.entries(events)) {
314
381
  hooks[event] = [{ hooks: [{ type: 'command', command: `sh "$CLAUDE_PROJECT_DIR/node_modules/dreamteamer/bin/dt-hook.sh" ${verb}`, timeout: 600 }] }];
315
382
  }
316
383
  console.error('# merge into .claude/settings.json — writing it is the operator\'s act, never the engine\'s');
@@ -328,7 +395,11 @@ export function printAdapters(ws, { harnesses = ws.pkg.dreamteamer?.harnesses ??
328
395
  * NOTHING else: the board goes to stderr (a human watching a piped run still wants it),
329
396
  * console.log is pointed at stderr for the duration, and each subprocess is handed stderr for its
330
397
  * own stdout. A `--json` that only parses on an already-settled checkout is not an interface. */
331
- export function installCommand(ws, rest) {
398
+ export async function installCommand(ws, rest, { open } = {}) {
399
+ // ⚠ THE OPENER IS REQUIRED, and it must ACTIVATE extensions: the compile step reopens with it (see
400
+ // applyInstall), so a raw `findWorkspace` default compiled an extension's kind as an unknown folder.
401
+ // The public export (api.js) supplies `openWorkspace`; this layer cannot import it.
402
+ if (typeof open !== 'function') throw new Error('installCommand: opts.open (an extension-activating opener, e.g. openWorkspace) is required');
332
403
  const flags = new Set(rest.filter((a) => a.startsWith('--')));
333
404
  if (flags.has('--print-adapters')) return printAdapters(ws, {});
334
405
  const hook = flags.has('--hook');
@@ -339,7 +410,9 @@ export function installCommand(ws, rest) {
339
410
  if (hook) {
340
411
  const input = readHookInput(readStdin());
341
412
  if (!input.cwd) throw new Error(`hook input carries no cwd — keys received: ${Object.keys(input.raw).join(', ')}`);
342
- ws = findWorkspace(input.cwd);
413
+ // the checkout the payload names, opened the way the caller opens one (the CLI passes
414
+ // `openWorkspace`, so ITS extensions load — their hooks and kinds are part of that checkout)
415
+ ws = await open(input.cwd);
343
416
  }
344
417
  const json = flags.has('--json');
345
418
  const state = observeState(ws);
@@ -353,314 +426,16 @@ export function installCommand(ws, rest) {
353
426
  if (json) console.log = console.error; // compile() and the git-modules restore report through it
354
427
  let code;
355
428
  try {
356
- code = applyInstall(ws, state, steps, { dryRun: flags.has('--dry-run'), log, stdio: json ? ['ignore', 2, 2] : 'inherit' });
429
+ code = await applyInstall(ws, state, steps, { dryRun: flags.has('--dry-run'), log, stdio: json ? ['ignore', 2, 2] : 'inherit', open });
357
430
  } finally {
358
431
  console.log = stdout;
359
432
  }
360
433
  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.
434
+ // ⚠ THE BOARD IS THE SESSION'S CONTEXT when a session-start hook runs it, so its LAST line says
435
+ // the one thing a session in a linked worktree cannot work out for itself: its records are
436
+ // invisible from the primary until they are committed here.
364
437
  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}.`);
438
+ log(`\nthis is linked worktree ${path.basename(ws.root)} of ${state.checkout.primary}; before you finish, dt commit your records here — they are invisible from the primary until you do.`);
369
439
  }
370
440
  return code;
371
441
  }
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
- }