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/README.md +47 -153
- package/collections/collections.collection.yaml +0 -9
- package/package.json +14 -2
- package/skills/using-dreamteamer/SKILL.md +10 -8
- package/skills/using-dreamteamer/references/before-you-build.md +4 -3
- package/skills/using-dreamteamer/references/commands.md +3 -3
- package/skills/using-dreamteamer/references/extensions.md +60 -0
- package/skills/using-dreamteamer/references/sessions.md +4 -5
- package/skills/using-dreamteamer/references/skills.md +3 -5
- package/src/api.d.ts +109 -0
- package/src/api.js +70 -0
- package/src/checkout.js +105 -330
- package/src/cli.js +60 -273
- package/src/collections-cli.js +16 -165
- package/src/compile.js +110 -156
- package/src/extensions.js +135 -0
- package/src/filter.js +1 -1
- package/src/harnesses.js +82 -135
- package/src/init.js +9 -3
- package/src/records-api.d.ts +95 -0
- package/src/records-api.js +40 -0
- package/src/runtime.js +2 -2
- package/src/schema-ops.js +16 -7
- package/collections/containers.collection.yaml +0 -81
- package/collections/images.collection.yaml +0 -46
- package/collections/proofs.collection.yaml +0 -96
- package/skills/using-dreamteamer/references/exporting.md +0 -53
- package/skills/using-dreamteamer/references/proofs.md +0 -435
- package/skills/using-dreamteamer/references/worktrees.md +0 -235
- package/src/container-archive.js +0 -356
- package/src/containers.js +0 -635
- package/src/export-notebooklm.js +0 -502
- package/src/land.js +0 -743
- package/src/prove.js +0 -1922
- package/src/server.js +0 -481
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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. `✖
|
|
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
|
-
|
|
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
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
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)
|
|
369
|
+
console.error(`${h}: adapter not yet shipped (decision 315)`);
|
|
310
370
|
continue;
|
|
311
371
|
}
|
|
312
372
|
const hooks = {};
|
|
313
|
-
|
|
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
|
-
|
|
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
|
|
362
|
-
// the
|
|
363
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|