@infinitedusky/indusk-mcp 1.36.2 → 1.37.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.
Files changed (62) hide show
  1. package/admin/.next/BUILD_ID +1 -1
  2. package/admin/.next/build-manifest.json +3 -3
  3. package/admin/.next/fallback-build-manifest.json +3 -3
  4. package/admin/.next/prerender-manifest.json +3 -3
  5. package/admin/.next/server/app/_global-error.html +1 -1
  6. package/admin/.next/server/app/_global-error.rsc +1 -1
  7. package/admin/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/admin/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/admin/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/admin/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/admin/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/admin/.next/server/chunks/ssr/[root-of-the-server]__0p.51_4._.js.map +1 -1
  13. package/admin/.next/server/middleware-build-manifest.js +3 -3
  14. package/admin/.next/server/pages/500.html +1 -1
  15. package/admin/.next/server/server-reference-manifest.js +1 -1
  16. package/admin/.next/server/server-reference-manifest.json +1 -1
  17. package/admin/.next/trace +1 -1
  18. package/admin/.next/trace-build +1 -1
  19. package/dist/bin/cli.js +42 -4
  20. package/dist/bin/commands/init.js +42 -10
  21. package/dist/bin/commands/telemetry.js +7 -1
  22. package/dist/bin/commands/update.js +104 -2
  23. package/dist/bin/commands/workbench.d.ts +76 -0
  24. package/dist/bin/commands/workbench.js +438 -0
  25. package/dist/bin/commands/worktree.d.ts +22 -6
  26. package/dist/bin/commands/worktree.js +154 -111
  27. package/dist/lib/config.d.ts +59 -0
  28. package/dist/lib/hook-migration.d.ts +42 -0
  29. package/dist/lib/hook-migration.js +94 -0
  30. package/dist/lib/path-segment.d.ts +23 -0
  31. package/dist/lib/path-segment.js +41 -0
  32. package/dist/lib/plan-parser.js +5 -13
  33. package/dist/lib/stray-state-audit.d.ts +3 -2
  34. package/dist/lib/stray-state-audit.js +14 -20
  35. package/dist/lib/telemetry/daemon.d.ts +18 -0
  36. package/dist/lib/telemetry/daemon.js +18 -2
  37. package/dist/lib/verify/git.d.ts +11 -4
  38. package/dist/lib/verify/git.js +11 -4
  39. package/dist/lib/verify/roots.d.ts +40 -0
  40. package/dist/lib/verify/roots.js +54 -0
  41. package/dist/lib/verify/verify.js +17 -2
  42. package/dist/lib/worktree/layout.d.ts +56 -0
  43. package/dist/lib/worktree/layout.js +136 -0
  44. package/dist/lib/worktree/repos.d.ts +46 -0
  45. package/dist/lib/worktree/repos.js +100 -0
  46. package/dist/lib/worktree/shareable.d.ts +47 -0
  47. package/dist/lib/worktree/shareable.js +301 -0
  48. package/dist/lib/worktree/sync.d.ts +62 -0
  49. package/dist/lib/worktree/sync.js +174 -0
  50. package/extensions/worktree/hooks/on_enable.sh +17 -10
  51. package/extensions/worktree/scripts/lib/workbench-helpers.sh +84 -0
  52. package/extensions/worktree/scripts/preflight.sh +4 -8
  53. package/extensions/worktree/scripts/refresh-worktree.sh +4 -8
  54. package/extensions/worktree/scripts/setup-worktree.sh +40 -16
  55. package/extensions/worktree/skill.md +8 -6
  56. package/hooks/_hook-paths.js +78 -15
  57. package/hooks/workbench-sync.js +119 -0
  58. package/package.json +109 -109
  59. package/skills/catchup.md +17 -0
  60. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_buildManifest.js +0 -0
  61. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_clientMiddlewareManifest.js +0 -0
  62. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_ssgManifest.js +0 -0
@@ -0,0 +1,438 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { linkTrunk, listWorkbenchSubdirs, worktreeOwner } from "../../lib/worktree/layout.js";
6
+ import { isWorkbench, readSiblingParent, readWorkbenchRepos, repoDir, } from "../../lib/worktree/repos.js";
7
+ import { allLocationsDeclared, ensureShareableScaffolding, missingIgnoreRules, topUpManagedIgnore, untrackNowIgnored, } from "../../lib/worktree/shareable.js";
8
+ import { ensureContextRepo, repoPublishState, syncWorkbench } from "../../lib/worktree/sync.js";
9
+ /** Files a restore can never supply. Printed every run, not only on failure. */
10
+ const OUT_OF_BAND = [
11
+ "env/*.env and per-app .env.<profile> pulls",
12
+ ".indusk/extensions/doppler/.env (the service token)",
13
+ "any repo-local config (e.g. a database config.sh)",
14
+ "SSH host aliases your remotes depend on (e.g. `github-<org>` in ~/.ssh/config)",
15
+ ];
16
+ function git(args, cwd) {
17
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
18
+ return { ok: r.status === 0, stderr: r.stderr ?? "" };
19
+ }
20
+ function expandHome(p) {
21
+ return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
22
+ }
23
+ /**
24
+ * Where the sibling clones live.
25
+ *
26
+ * `sibling_parent` is an ABSOLUTE path committed to a SHARED context repo,
27
+ * which is a contradiction this plan has to absorb rather than inherit. One
28
+ * real workbench's committed value was another machine's home directory, so
29
+ * on this machine it named a directory that did not exist and every repo read
30
+ * as missing. No fixture catches this — a fixture writes its own tmpdir there.
31
+ *
32
+ * The fallback is the workbench's PARENT, which is not a guess: `indusk setup`
33
+ * builds exactly `<parent>/<repo>` beside `<parent>/<repo>-workbench`, so the
34
+ * parent IS the sibling directory. A declared path that exists still wins, so
35
+ * nothing changes for a workbench sitting on the machine that wrote it.
36
+ */
37
+ function resolveSiblingParent(projectRoot) {
38
+ const parent = resolve(projectRoot, "..");
39
+ const declared = readSiblingParent(projectRoot);
40
+ if (!declared)
41
+ return { siblingParent: parent };
42
+ const expanded = resolve(expandHome(declared));
43
+ if (existsSync(expanded))
44
+ return { siblingParent: expanded };
45
+ return {
46
+ siblingParent: parent,
47
+ note: `Note: worktree.sibling_parent points at ${expanded}, which does not exist here — ` +
48
+ `it is an absolute path from whichever machine wrote it.\n` +
49
+ ` Using this workbench's parent instead: ${parent}`,
50
+ };
51
+ }
52
+ /**
53
+ * Refuse when a workbench's own ignore file cannot carry the sharing contract.
54
+ *
55
+ * Found by pointing the tool at a real pre-existing workbench: scaffolding only
56
+ * TOPS UP an existing `.gitignore`, so one written before this plan never
57
+ * receives the deny-by-default rule — and `sync` then commits worktree
58
+ * contents. On a real workbench that is dozens of checkouts of someone else's
59
+ * repo entering a shared context repo.
60
+ *
61
+ * **Refuse rather than rewrite.** Appending `/*` plus an allow-list to a file
62
+ * somebody else wrote inverts its meaning: every root entry becomes denied
63
+ * unless listed. Given what is at stake, a loud stop beats an opinionated
64
+ * rewrite of a human's decision.
65
+ *
66
+ * `missingIgnoreRules` has existed and been correct since Phase 4 — it was
67
+ * simply never consulted at the moment it mattered.
68
+ */
69
+ function refuseIfIgnoreCannotHold(projectRoot, repos, skip) {
70
+ if (skip)
71
+ return;
72
+ // A declared layout names its worktree directories exactly, so no
73
+ // deny-by-default rule is required and nothing to refuse.
74
+ if (allLocationsDeclared(repos))
75
+ return;
76
+ // InDusk wrote this file, so InDusk may extend it. Every freshly created
77
+ // workbench lands here — `init` scaffolds a `.gitignore`, so without this
78
+ // the refusal fires on the product's own output.
79
+ if (topUpManagedIgnore(projectRoot)) {
80
+ console.info("Added the workbench rules to the InDusk-managed .gitignore.");
81
+ }
82
+ const gaps = missingIgnoreRules(projectRoot);
83
+ if (gaps.length === 0)
84
+ return;
85
+ console.error("Error: this workbench's .gitignore cannot keep worktrees out of the shared remote.");
86
+ console.error("");
87
+ for (const gap of gaps)
88
+ console.error(` missing: ${gap}`);
89
+ console.error("");
90
+ console.error("Worktree directories are created at runtime, so they cannot be named in advance —");
91
+ console.error("a flat workbench needs the deny-by-default rule to keep them out.");
92
+ console.error("");
93
+ console.error("Fix it one of these ways:");
94
+ console.error(" - declare where worktrees live (worktree.repos[].worktrees), which names them precisely");
95
+ console.error(" - add the missing rule(s) to .gitignore yourself");
96
+ console.error(" - re-run with --no-ignore-check to proceed anyway");
97
+ process.exit(1);
98
+ }
99
+ /**
100
+ * Phrase one repo's outcome.
101
+ *
102
+ * Separated from the loop that prints it so the four cases can be read — and
103
+ * tested — without spawning the CLI. The loop decides what happened; this says
104
+ * it. They are two jobs, and the four-way ternary that used to sit inline made
105
+ * that hard to see.
106
+ */
107
+ export function restoreLine(repo, status, siblingParent) {
108
+ // `linkTrunk` deliberately refuses to replace a real directory sitting at
109
+ // the trunk path, so both `-unlinked` cases are correct behavior that the
110
+ // message must not describe as a link.
111
+ const unlinked = `a real directory occupies ${repoDir(repo)}/ in the workbench, so no trunk symlink was made — left as is`;
112
+ switch (status) {
113
+ case "cloned":
114
+ return `${repo.name} — cloned into ${siblingParent} and linked`;
115
+ case "cloned-unlinked":
116
+ return `${repo.name} — cloned into ${siblingParent}/${repo.name}, but ${unlinked}`;
117
+ case "present-unlinked":
118
+ return `${repo.name} — already present, but ${unlinked}`;
119
+ case "present":
120
+ return `${repo.name} — already present, trunk linked`;
121
+ }
122
+ }
123
+ function restoreOne(repo, workbenchRoot, siblingParent) {
124
+ const target = join(siblingParent, repo.name);
125
+ if (existsSync(join(target, ".git"))) {
126
+ const linked = linkTrunk(workbenchRoot, repoDir(repo), target);
127
+ return { status: linked ? "present" : "present-unlinked" };
128
+ }
129
+ // Declared but unrestorable. Reported by name — a repo silently absent from
130
+ // a "successful" restore is the thing A12 exists to forbid.
131
+ if (!repo.remote) {
132
+ return {
133
+ status: "present",
134
+ failure: {
135
+ repo: repo.name,
136
+ reason: "declared with no `remote`, and not present on disk — add a remote to .indusk/config.json or clone it yourself",
137
+ },
138
+ };
139
+ }
140
+ mkdirSync(siblingParent, { recursive: true });
141
+ const { ok, stderr } = git(["clone", "--quiet", repo.remote, target], siblingParent);
142
+ if (!ok) {
143
+ return {
144
+ status: "present",
145
+ failure: {
146
+ repo: repo.name,
147
+ reason: `clone failed from ${repo.remote}${stderr.trim() ? ` — ${stderr.trim().split("\n")[0]}` : ""}`,
148
+ },
149
+ };
150
+ }
151
+ // By declared path, and reporting what the link attempt actually did —
152
+ // the clone branch had BOTH bugs the present branch had: it resolved by
153
+ // name rather than declared path, and it discarded the result.
154
+ const linked = linkTrunk(workbenchRoot, repoDir(repo), target);
155
+ return { status: linked ? "cloned" : "cloned-unlinked" };
156
+ }
157
+ export function workbenchRestore(projectRoot, opts = {}) {
158
+ const repos = readWorkbenchRepos(projectRoot);
159
+ if (!isWorkbench(projectRoot) || repos.length === 0) {
160
+ console.error('Error: this project is not a workbench (needs worktree.shape="workbench" and worktree.repos[] in .indusk/config.json).');
161
+ process.exit(1);
162
+ }
163
+ const { siblingParent, note } = resolveSiblingParent(projectRoot);
164
+ if (note)
165
+ console.info(note);
166
+ console.info(`Workbench: ${projectRoot}`);
167
+ console.info(`Repos (${repos.length}): ${repos.map((r) => r.name).join(", ")}`);
168
+ // Scaffolded here rather than at git-init time so a workbench that was
169
+ // made shareable by hand (the POC's path) still gets the rules, and so the
170
+ // ignore file exists BEFORE anything can be committed. Never overwrites.
171
+ const scaffold = ensureShareableScaffolding(projectRoot, repos);
172
+ if (scaffold.created.length > 0) {
173
+ console.info(`Scaffolded: ${scaffold.created.join(", ")}`);
174
+ }
175
+ // The context repo is created lazily, here and in `sync`, rather than
176
+ // demanded up front — a remote is a decision the developer makes, and
177
+ // local commits are useful before one exists. `git init` with no remote is
178
+ // a complete, working state, not a half-configured one.
179
+ if (ensureContextRepo(projectRoot)) {
180
+ console.info("Initialized the workbench context repo (add a remote to share it).");
181
+ }
182
+ refuseIfIgnoreCannotHold(projectRoot, repos, opts.noIgnoreCheck === true);
183
+ // Ignoring a path does not untrack it. A workbench git-initialized before
184
+ // these rules existed keeps publishing its symlinks and secrets while
185
+ // `git status` looks clean.
186
+ // NAMED, never counted. This drops files out of a SHARED repo's index —
187
+ // a workbench's own rules decide which, and "untracked 3 paths" gives a
188
+ // reader no way to notice that one of them was something they wanted.
189
+ // (Found on a real workbench: `.mcp.json` was both tracked AND ignored,
190
+ // so restore untracked it. Correct per their rule, surprising in silence.)
191
+ const untracked = untrackNowIgnored(projectRoot);
192
+ if (untracked.length > 0) {
193
+ console.info(`Untracked ${untracked.length} now-ignored path(s) — index only, files kept on disk:`);
194
+ for (const path of untracked)
195
+ console.info(` - ${path}`);
196
+ }
197
+ console.info("");
198
+ // Every repo is attempted. A first failure must not decide the fate of the
199
+ // rest, or restoring an N-repo workbench becomes a lottery decided by
200
+ // declaration order.
201
+ const failures = [];
202
+ for (const repo of repos) {
203
+ const { status, failure } = restoreOne(repo, projectRoot, siblingParent);
204
+ if (failure) {
205
+ failures.push(failure);
206
+ console.error(` ✗ ${repo.name} — ${failure.reason}`);
207
+ continue;
208
+ }
209
+ console.info(` ✓ ${restoreLine(repo, status, siblingParent)}`);
210
+ }
211
+ if (opts.worktrees) {
212
+ console.info("");
213
+ console.info("Worktrees: none declared (a worktrees[] manifest is not read yet).");
214
+ }
215
+ console.info("");
216
+ console.info("Supply these out-of-band — restore cannot and never will:");
217
+ for (const item of OUT_OF_BAND)
218
+ console.info(` - ${item}`);
219
+ if (failures.length > 0) {
220
+ console.error("");
221
+ console.error(`Restore incomplete — ${failures.length} of ${repos.length} repo(s) unresolved:`);
222
+ for (const f of failures)
223
+ console.error(` - ${f.repo}: ${f.reason}`);
224
+ console.error("Fix the above and re-run `indusk workbench restore` — it is idempotent.");
225
+ process.exit(1);
226
+ }
227
+ console.info("");
228
+ console.info("Workbench restored. Next: supply the out-of-band set, then `indusk update`.");
229
+ process.exit(0);
230
+ }
231
+ /**
232
+ * `indusk workbench sync` — one sync point: commit, pull, push, blindly resolved.
233
+ *
234
+ * Exits 0 even when the remote is unreachable. The work is committed locally
235
+ * and goes out on the next sync; someone else's outage must never become your
236
+ * inability to work.
237
+ */
238
+ export function workbenchSyncCommand(projectRoot, opts = {}) {
239
+ const repos = readWorkbenchRepos(projectRoot);
240
+ if (!isWorkbench(projectRoot) && repos.length === 0) {
241
+ console.error('Error: this project is not a workbench (needs worktree.shape="workbench" in .indusk/config.json).');
242
+ process.exit(1);
243
+ }
244
+ // The ignore rules must exist before anything is committed, or the first
245
+ // sync publishes exactly what they were written to keep out.
246
+ const scaffold = ensureShareableScaffolding(projectRoot, repos);
247
+ if (scaffold.created.length > 0)
248
+ console.info(`Scaffolded: ${scaffold.created.join(", ")}`);
249
+ refuseIfIgnoreCannotHold(projectRoot, repos, opts.noIgnoreCheck === true);
250
+ // Named here too, not only in `restore`. Sync runs unattended on a hook, so
251
+ // a silent index change is the one most likely to go unnoticed.
252
+ const dropped = untrackNowIgnored(projectRoot);
253
+ if (dropped.length > 0) {
254
+ console.info(`Untracked ${dropped.length} now-ignored path(s) — index only, files kept on disk:`);
255
+ for (const path of dropped)
256
+ console.info(` - ${path}`);
257
+ }
258
+ const result = syncWorkbench(projectRoot);
259
+ for (const note of result.notes)
260
+ console.info(note);
261
+ if (!result.committed && result.notes.length === 0)
262
+ console.info("Nothing to sync — tree is clean.");
263
+ console.info(`sync: committed=${result.committed} pulled=${result.pulled} pushed=${result.pushed}`);
264
+ process.exit(0);
265
+ }
266
+ /**
267
+ * `indusk workbench status` — is each declared repo materialized, and has its
268
+ * work actually left this machine?
269
+ */
270
+ export function workbenchStatusCommand(projectRoot) {
271
+ const repos = readWorkbenchRepos(projectRoot);
272
+ if (repos.length === 0) {
273
+ console.error("Error: this project declares no repos (worktree.repos[] in .indusk/config.json).");
274
+ process.exit(1);
275
+ }
276
+ const { siblingParent } = resolveSiblingParent(projectRoot);
277
+ console.info(`Workbench: ${projectRoot}`);
278
+ console.info("");
279
+ for (const repo of repos) {
280
+ // By DECLARED path, like every other consumer. This was the one place
281
+ // still deriving a location from a name.
282
+ const st = repoPublishState(siblingParent, repoDir(repo));
283
+ if (!st.present) {
284
+ console.info(` ${repo.name}: not materialized — run \`indusk workbench restore\``);
285
+ continue;
286
+ }
287
+ if (!st.hasRemote) {
288
+ console.info(` ${repo.name}: present, no remote configured (nothing to publish to)`);
289
+ continue;
290
+ }
291
+ if (!st.published) {
292
+ console.info(` ${repo.name}: has a remote, but this branch has NEVER BEEN PUSHED — none of its work is visible to anyone else`);
293
+ continue;
294
+ }
295
+ console.info(st.ahead > 0
296
+ ? ` ${repo.name}: ${st.ahead} commit(s) ahead of its remote — NOT PUSHED, so a teammate pulling this workbench cannot see that work yet`
297
+ : ` ${repo.name}: in sync with its remote`);
298
+ }
299
+ process.exit(0);
300
+ }
301
+ /**
302
+ * `indusk workbench migrate-layout` — move a flat workbench into declared layout.
303
+ *
304
+ * DRY-RUN BY DEFAULT. A command that relocates directories shows its plan
305
+ * before doing anything; `--apply` performs it.
306
+ *
307
+ * Uses `git worktree move`, never a manual rename. A git worktree is two
308
+ * cross-references — the worktree's `.git` file and the repo's
309
+ * `.git/worktrees/<name>/gitdir` pointing back — and moving the directory
310
+ * without repairing both leaves something that looks right and is broken. That
311
+ * is the only failure of this command that would matter, so it is handed to
312
+ * the tool that knows.
313
+ *
314
+ * Wrapped repos are never committed to or modified beyond the worktree
315
+ * bookkeeping: this moves worktrees, not product code.
316
+ */
317
+ export function workbenchMigrateLayout(projectRoot, opts = {}) {
318
+ const repos = readWorkbenchRepos(projectRoot);
319
+ if (repos.length === 0) {
320
+ console.error("Error: this project declares no repos (worktree.repos[] in .indusk/config.json).");
321
+ process.exit(1);
322
+ }
323
+ const alreadyDeclared = repos.filter((r) => r.worktrees);
324
+ if (alreadyDeclared.length === repos.length) {
325
+ console.info("Every repo already declares a worktrees location — nothing to migrate.");
326
+ process.exit(0);
327
+ }
328
+ const repoPaths = new Map(repos.map((r) => [r.name, join(projectRoot, repoDir(r))]));
329
+ const occupied = new Set(repos.map((r) => repoDir(r)));
330
+ const loose = listWorkbenchSubdirs(projectRoot).filter((n) => !occupied.has(n));
331
+ const moves = [];
332
+ const unplaceable = [];
333
+ const unmovable = [];
334
+ for (const slug of loose) {
335
+ const owner = worktreeOwner(join(projectRoot, slug), repoPaths);
336
+ if (!owner) {
337
+ unplaceable.push(slug);
338
+ continue;
339
+ }
340
+ const dest = `${owner}-worktrees`;
341
+ // A worktree already NAMED `<repo>-worktrees` would be moved inside
342
+ // itself. git refuses that, but only at execution — so the plan would
343
+ // show an impossible move and then fail on `Invalid argument`, which
344
+ // tells the reader nothing. Catch it while planning.
345
+ if (slug === dest) {
346
+ unmovable.push(`${slug}: cannot move — the destination is inside itself`);
347
+ continue;
348
+ }
349
+ moves.push({
350
+ repo: owner,
351
+ slug,
352
+ from: join(projectRoot, slug),
353
+ to: join(projectRoot, dest, slug),
354
+ });
355
+ }
356
+ console.info(`Workbench: ${projectRoot}`);
357
+ console.info(opts.apply ? "Applying layout migration:" : "Dry run — would move:");
358
+ console.info("");
359
+ for (const m of moves) {
360
+ console.info(` ${m.slug} -> ${m.repo}-worktrees/${m.slug}`);
361
+ }
362
+ if (moves.length === 0)
363
+ console.info(" (no worktrees to move)");
364
+ if (unplaceable.length > 0) {
365
+ console.info("");
366
+ console.info("Left where they are — not resolvable to any declared repo:");
367
+ for (const slug of unplaceable)
368
+ console.info(` ${slug}`);
369
+ }
370
+ if (unmovable.length > 0) {
371
+ console.info("");
372
+ console.info("Skipped — cannot be moved:");
373
+ for (const why of unmovable)
374
+ console.info(` ${why}`);
375
+ }
376
+ if (!opts.apply) {
377
+ console.info("");
378
+ console.info("Nothing changed. Re-run with --apply to perform the migration.");
379
+ process.exit(0);
380
+ }
381
+ // Perform. Every failure is collected and named; a partial migration that
382
+ // exits 0 is the shape this plan has refused throughout.
383
+ const failures = [];
384
+ const migrated = new Set();
385
+ for (const m of moves) {
386
+ mkdirSync(join(projectRoot, `${m.repo}-worktrees`), { recursive: true });
387
+ const repoPath = repoPaths.get(m.repo);
388
+ const r = spawnSync("git", ["-C", repoPath ?? projectRoot, "worktree", "move", m.from, m.to], {
389
+ encoding: "utf-8",
390
+ });
391
+ if (r.status === 0) {
392
+ console.info(` ✓ ${m.slug}`);
393
+ migrated.add(m.repo);
394
+ }
395
+ else {
396
+ const why = (r.stderr ?? "").trim().split("\n")[0] ?? "unknown error";
397
+ console.error(` ✗ ${m.slug} — ${why}`);
398
+ failures.push(`${m.slug}: ${why}`);
399
+ }
400
+ }
401
+ // Declare the layout for every repo whose worktrees actually moved, plus
402
+ // any repo that had none — the declaration is what makes the ignore rule
403
+ // precise, and a repo with no worktrees still benefits.
404
+ const declared = declareWorktreeLocations(projectRoot, repos.filter((r) => !r.worktrees && (migrated.has(r.name) || !moves.some((m) => m.repo === r.name))));
405
+ if (declared.length > 0) {
406
+ console.info("");
407
+ console.info(`Declared worktrees location for: ${declared.join(", ")}`);
408
+ }
409
+ if (failures.length > 0) {
410
+ console.error("");
411
+ console.error(`Migration incomplete — ${failures.length} worktree(s) could not be moved:`);
412
+ for (const f of failures)
413
+ console.error(` - ${f}`);
414
+ console.error("Fix those and re-run — the command is safe to repeat.");
415
+ process.exit(1);
416
+ }
417
+ console.info("");
418
+ console.info("Layout migrated. `indusk worktree list` now groups by repo.");
419
+ process.exit(0);
420
+ }
421
+ /** Write `worktrees` for the given repos. Returns the names it declared. */
422
+ function declareWorktreeLocations(projectRoot, repos) {
423
+ if (repos.length === 0)
424
+ return [];
425
+ const cfgPath = join(projectRoot, ".indusk", "config.json");
426
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
427
+ const names = new Set(repos.map((r) => r.name));
428
+ const done = [];
429
+ for (const entry of cfg.worktree?.repos ?? []) {
430
+ if (names.has(entry.name) && !entry.worktrees) {
431
+ entry.worktrees = `${entry.name}-worktrees`;
432
+ done.push(entry.name);
433
+ }
434
+ }
435
+ if (done.length > 0)
436
+ writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
437
+ return done;
438
+ }
@@ -5,14 +5,30 @@
5
5
  * from the user's invocation dir).
6
6
  */
7
7
  export declare function worktreeOnEnable(): void;
8
- export declare function worktreeCreate(slug: string, baseBranch?: string): never;
8
+ /**
9
+ * `indusk worktree create [repo] <slug> [base-branch]`.
10
+ *
11
+ * The repo argument returns from the pre-v1 multi-repo design. Disambiguated
12
+ * against the DECLARED SET rather than by arity: a leading token is a repo
13
+ * only if the workbench actually declares it, so `create my-feature` in a
14
+ * one-repo workbench still means what it always meant, and `create alpha
15
+ * my-feature` means alpha. Guessing by position would silently reinterpret
16
+ * every existing single-repo invocation whose slug happened to arrive first.
17
+ *
18
+ * When the repo cannot be determined here the args pass through untouched and
19
+ * `_resolve_workbench_repo` in the shell refuses, naming the candidates —
20
+ * one refusal, in one place, rather than two that can disagree.
21
+ */
22
+ export declare function worktreeCreate(args: string[], projectRoot?: string): never;
9
23
  export declare function worktreeRefresh(slug: string): never;
10
24
  export declare function worktreePreflight(slug: string, baseBranch?: string): never;
11
25
  /**
12
- * `indusk worktree list` — print the workbench's current state:
13
- * - wrapped repo + trunk symlink path + resolves status
14
- * - worktrees as siblings of the trunk
15
- * - worktree config status badge: (config valid) / (config missing) /
16
- * (config invalid: <reason>) / (no worktrees)
26
+ * `indusk worktree list` — the workbench's current state, one block per
27
+ * declared repo.
28
+ *
29
+ * N repos rather than one wrapped repo: each declared repo gets its own trunk
30
+ * line, its own config badge, and its own worktrees. Attribution matters more
31
+ * than layout here — a reader has to be able to tell "beta has no worktrees"
32
+ * from "beta's worktrees are listed under alpha".
17
33
  */
18
34
  export declare function worktreeList(projectRoot: string): void;