@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
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { listWorkbenchSubdirs, worktreeOwner } from "../../lib/worktree/layout.js";
6
+ import { isWorkbench, NOT_A_WORKBENCH, readWorkbenchRepos, repoDir, } from "../../lib/worktree/repos.js";
5
7
  import { validateWorktreeConfig } from "../../lib/worktree/validate-config.js";
6
8
  import { provisionWorktreeEnv } from "./doppler.js";
7
9
  /**
@@ -88,14 +90,46 @@ function runWorktreeScript(scriptName, args) {
88
90
  });
89
91
  process.exit(r.status ?? 1);
90
92
  }
91
- export function worktreeCreate(slug, baseBranch) {
93
+ /**
94
+ * `indusk worktree create [repo] <slug> [base-branch]`.
95
+ *
96
+ * The repo argument returns from the pre-v1 multi-repo design. Disambiguated
97
+ * against the DECLARED SET rather than by arity: a leading token is a repo
98
+ * only if the workbench actually declares it, so `create my-feature` in a
99
+ * one-repo workbench still means what it always meant, and `create alpha
100
+ * my-feature` means alpha. Guessing by position would silently reinterpret
101
+ * every existing single-repo invocation whose slug happened to arrive first.
102
+ *
103
+ * When the repo cannot be determined here the args pass through untouched and
104
+ * `_resolve_workbench_repo` in the shell refuses, naming the candidates —
105
+ * one refusal, in one place, rather than two that can disagree.
106
+ */
107
+ export function worktreeCreate(args, projectRoot) {
108
+ const declared = new Set(readWorkbenchRepos(projectRoot ?? process.cwd()).map((r) => r.name));
109
+ const named = args.length >= 2 && declared.has(args[0]);
110
+ const repo = named ? args[0] : undefined;
111
+ const [slug, baseBranch] = named ? args.slice(1) : args;
112
+ return worktreeCreateResolved(slug, baseBranch, repo);
113
+ }
114
+ function worktreeCreateResolved(slug, baseBranch, repo) {
92
115
  const pkgRoot = indusKMcpPackageRoot();
93
116
  const script = resolve(pkgRoot, "extensions/worktree/scripts/setup-worktree.sh");
94
117
  if (!existsSync(script)) {
95
118
  console.error(`Error: setup-worktree.sh not found at ${script}`);
96
119
  process.exit(1);
97
120
  }
98
- const r = spawnSync("bash", [script, ...(baseBranch ? [slug, baseBranch] : [slug])], {
121
+ // The shell script places the worktree; tell it where when a location is
122
+ // declared. Absent, it keeps putting worktrees at the workbench root.
123
+ const declared = repo
124
+ ? readWorkbenchRepos(process.cwd()).find((r) => r.name === repo)
125
+ : undefined;
126
+ const scriptArgs = [
127
+ ...(declared?.worktrees ? ["--worktrees-dir", declared.worktrees] : []),
128
+ ...(repo ? ["--repo", repo] : []),
129
+ slug,
130
+ ...(baseBranch ? [baseBranch] : []),
131
+ ];
132
+ const r = spawnSync("bash", [script, ...scriptArgs], {
99
133
  cwd: process.cwd(),
100
134
  stdio: "inherit",
101
135
  });
@@ -130,7 +164,7 @@ export function worktreeCreate(slug, baseBranch) {
130
164
  }
131
165
  /** Read `post_create` commands from the workbench's worktree config — run in each new worktree after create. */
132
166
  function readPostCreate(workbenchRoot) {
133
- const repo = readWorkbenchConfig(workbenchRoot)?.worktree?.wrapped_repo;
167
+ const repo = readWorkbenchRepos(workbenchRoot)[0]?.name;
134
168
  if (!repo)
135
169
  return [];
136
170
  const p = join(workbenchRoot, ".indusk", "worktree-configs", `${repo}.json`);
@@ -150,7 +184,7 @@ function readPostCreate(workbenchRoot) {
150
184
  function resolveWorkbenchRoot(start) {
151
185
  let dir = resolve(start);
152
186
  for (let i = 0; i < 40; i++) {
153
- if (readWorkbenchConfig(dir)?.worktree?.shape === "workbench")
187
+ if (isWorkbench(dir))
154
188
  return dir;
155
189
  const parent = dirname(dir);
156
190
  if (parent === dir)
@@ -165,129 +199,138 @@ export function worktreeRefresh(slug) {
165
199
  export function worktreePreflight(slug, baseBranch) {
166
200
  runWorktreeScript("preflight", baseBranch ? [slug, baseBranch] : [slug]);
167
201
  }
168
- function readWorkbenchConfig(workbenchRoot) {
169
- const cfg = join(workbenchRoot, ".indusk", "config.json");
170
- if (!existsSync(cfg))
171
- return null;
202
+ // ---- list (TS-implemented; uses the Phase 2 validator) ----------------------
203
+ /**
204
+ * `indusk worktree list` — print the workbench's current state:
205
+ * - wrapped repo + trunk symlink path + resolves status
206
+ * - worktrees as siblings of the trunk
207
+ * - worktree config status badge: (config valid) / (config missing) /
208
+ * (config invalid: <reason>) / (no worktrees)
209
+ */
210
+ /** Trunk symlink status for one declared repo. */
211
+ function trunkStatusFor(trunkPath) {
212
+ if (!existsSync(trunkPath)) {
213
+ return "missing — run `indusk workbench restore` to materialize it";
214
+ }
172
215
  try {
173
- return JSON.parse(readFileSync(cfg, "utf-8"));
216
+ const st = lstatSync(trunkPath);
217
+ if (!st.isSymbolicLink())
218
+ return "directory (not a symlink — unusual for a workbench trunk)";
219
+ const target = readlinkSync(trunkPath);
220
+ return existsSync(trunkPath)
221
+ ? `→ ${target} (resolves)`
222
+ : `symlink broken (target ${target} not found)`;
174
223
  }
175
- catch {
176
- return null;
224
+ catch (err) {
225
+ return `error: ${err.message}`;
177
226
  }
178
227
  }
179
- function listSubdirs(workbenchRoot) {
180
- const reserved = new Set([
181
- ".indusk",
182
- ".claude",
183
- ".vscode",
184
- ".cursor",
185
- "node_modules",
186
- "dist",
187
- "build",
188
- ".git",
189
- ".next",
190
- "scripts",
191
- "env",
192
- ]);
193
- const entries = [];
194
- for (const name of readdirSync(workbenchRoot)) {
195
- if (reserved.has(name))
196
- continue;
197
- const full = join(workbenchRoot, name);
198
- try {
199
- const st = lstatSync(full);
200
- if (st.isDirectory() || st.isSymbolicLink())
201
- entries.push(name);
202
- }
203
- catch {
204
- // fall through
205
- }
228
+ /** Per-repo worktree config badge. */
229
+ function configStatusFor(projectRoot, repo) {
230
+ const configPath = join(projectRoot, ".indusk", "worktree-configs", `${repo}.json`);
231
+ if (!existsSync(configPath))
232
+ return { path: configPath, status: "(config missing)" };
233
+ try {
234
+ const result = validateWorktreeConfig(JSON.parse(readFileSync(configPath, "utf-8")));
235
+ if (result.valid)
236
+ return { path: configPath, status: "(config valid)" };
237
+ const first = result.errors[0];
238
+ return {
239
+ path: configPath,
240
+ status: first ? `(config invalid: ${first.field} — ${first.message})` : "(config invalid)",
241
+ };
242
+ }
243
+ catch (err) {
244
+ return {
245
+ path: configPath,
246
+ status: `(config invalid: parse error — ${err.message})`,
247
+ };
206
248
  }
207
- return entries.sort();
208
249
  }
209
250
  /**
210
- * `indusk worktree list` — print the workbench's current state:
211
- * - wrapped repo + trunk symlink path + resolves status
212
- * - worktrees as siblings of the trunk
213
- * - worktree config status badge: (config valid) / (config missing) /
214
- * (config invalid: <reason>) / (no worktrees)
251
+ * `indusk worktree list` — the workbench's current state, one block per
252
+ * declared repo.
253
+ *
254
+ * N repos rather than one wrapped repo: each declared repo gets its own trunk
255
+ * line, its own config badge, and its own worktrees. Attribution matters more
256
+ * than layout here — a reader has to be able to tell "beta has no worktrees"
257
+ * from "beta's worktrees are listed under alpha".
215
258
  */
216
259
  export function worktreeList(projectRoot) {
217
- const config = readWorkbenchConfig(projectRoot);
218
- const wrappedRepo = config?.worktree?.wrapped_repo;
219
- const shape = config?.worktree?.shape;
220
- if (shape !== "workbench" || !wrappedRepo) {
221
- console.error('Error: this project is not a workbench (set worktree.shape="workbench" and worktree.wrapped_repo in .indusk/config.json, or run `indusk init --workbench`).');
260
+ const repos = readWorkbenchRepos(projectRoot);
261
+ if (!isWorkbench(projectRoot) || repos.length === 0) {
262
+ console.error(`Error: ${NOT_A_WORKBENCH}`);
222
263
  process.exit(1);
223
264
  }
224
- // Trunk: symlink at workbench/<wrapped_repo>
225
- const trunkPath = join(projectRoot, wrappedRepo);
226
- let trunkStatus = "";
227
- let trunkTarget = "";
228
- if (!existsSync(trunkPath)) {
229
- trunkStatus = "missing — run `indusk init --workbench` to create it";
230
- }
231
- else {
232
- try {
233
- const st = lstatSync(trunkPath);
234
- if (st.isSymbolicLink()) {
235
- trunkTarget = readlinkSync(trunkPath);
236
- // Resolve to verify the target exists.
237
- if (!existsSync(trunkPath)) {
238
- trunkStatus = `symlink broken (target ${trunkTarget} not found)`;
239
- }
240
- else {
241
- trunkStatus = `→ ${trunkTarget} (resolves)`;
242
- }
243
- }
244
- else {
245
- trunkStatus = "directory (not a symlink — unusual for a workbench trunk)";
246
- }
247
- }
248
- catch (err) {
249
- trunkStatus = `error: ${err.message}`;
250
- }
265
+ // Trunks are found at their DECLARED path; `name` is an identifier, not a
266
+ // location. A repo whose directory was renamed still resolves, because
267
+ // nothing here derives a path from a name.
268
+ const repoPaths = new Map(repos.map((r) => [r.name, join(projectRoot, repoDir(r))]));
269
+ const occupied = new Set([
270
+ ...repos.map((r) => repoDir(r)),
271
+ ...repos.map((r) => r.worktrees).filter((w) => typeof w === "string"),
272
+ ]);
273
+ const slugs = listWorkbenchSubdirs(projectRoot).filter((name) => !occupied.has(name));
274
+ const byRepo = new Map(repos.map((r) => [r.name, []]));
275
+ const unattributed = [];
276
+ // STRUCTURAL first: a repo that declares a worktrees directory owns what is
277
+ // inside it, by construction. No git call, no inference — the containing
278
+ // directory IS the answer, which is the whole point of declaring it.
279
+ for (const repo of repos) {
280
+ if (!repo.worktrees)
281
+ continue;
282
+ const dir = join(projectRoot, repo.worktrees);
283
+ if (!existsSync(dir))
284
+ continue;
285
+ byRepo.get(repo.name)?.push(...listWorkbenchSubdirs(dir));
251
286
  }
252
- // Per-repo config: .indusk/worktree-configs/<wrapped_repo>.json
253
- const configPath = join(projectRoot, ".indusk", "worktree-configs", `${wrappedRepo}.json`);
254
- let configStatus = "";
255
- if (!existsSync(configPath)) {
256
- configStatus = "(config missing)";
287
+ // Then whatever is loose at the root — the flat layout, and anything a
288
+ // declaration does not place. Attribution falls back to asking git.
289
+ //
290
+ // Discovery stays on DISK. Declarations add structure and can never
291
+ // subtract: a directory renamed without updating config must still appear,
292
+ // as unattributed, rather than vanish from the listing. A declaration that
293
+ // silently removes work is worse than one that admits it cannot place it.
294
+ for (const slug of slugs) {
295
+ const owner = worktreeOwner(join(projectRoot, slug), repoPaths);
296
+ if (owner)
297
+ byRepo.get(owner)?.push(slug);
298
+ else
299
+ unattributed.push(slug);
257
300
  }
258
- else {
259
- try {
260
- const raw = JSON.parse(readFileSync(configPath, "utf-8"));
261
- const result = validateWorktreeConfig(raw);
262
- if (result.valid) {
263
- configStatus = "(config valid)";
264
- }
265
- else {
266
- const firstErr = result.errors[0];
267
- configStatus = firstErr
268
- ? `(config invalid: ${firstErr.field} — ${firstErr.message})`
269
- : "(config invalid)";
270
- }
301
+ console.info(`Workbench: ${projectRoot}`);
302
+ console.info(`Repos (${repos.length}): ${repos.map((r) => r.name).join(", ")}`);
303
+ for (const repo of repos) {
304
+ const cfg = configStatusFor(projectRoot, repo.name);
305
+ const mine = byRepo.get(repo.name) ?? [];
306
+ console.info("");
307
+ console.info(`${repo.name}`);
308
+ console.info(` Trunk: ${repoDir(repo)} ${trunkStatusFor(join(projectRoot, repoDir(repo)))}`);
309
+ console.info(` Remote: ${repo.remote ?? "(none declared — `workbench restore` cannot clone it)"}`);
310
+ console.info(` Config: ${cfg.path} ${cfg.status}`);
311
+ if (mine.length === 0) {
312
+ console.info(` Worktrees: (none) — \`indusk worktree create ${repo.name} <slug>\` to add one`);
271
313
  }
272
- catch (err) {
273
- configStatus = `(config invalid: parse error — ${err.message})`;
314
+ else {
315
+ console.info(` Worktrees (${mine.length}):`);
316
+ for (const slug of mine)
317
+ console.info(` ${slug}`);
274
318
  }
275
319
  }
276
- // Worktrees: subdirs at workbench root excluding the trunk + reserved.
277
- const allSubdirs = listSubdirs(projectRoot);
278
- const worktreeSlugs = allSubdirs.filter((name) => name !== wrappedRepo);
279
- console.info(`Workbench: ${projectRoot}`);
280
- console.info(`Wrapped repo: ${wrappedRepo}`);
281
- console.info(`Trunk: ${wrappedRepo} ${trunkStatus}`);
282
- console.info(`Config: ${configPath} ${configStatus}`);
283
- console.info("");
284
- if (worktreeSlugs.length === 0) {
285
- console.info("Worktrees: (no worktrees) — `indusk worktree create <slug>` to add one");
286
- }
287
- else {
288
- console.info(`Worktrees (${worktreeSlugs.length}):`);
289
- for (const slug of worktreeSlugs) {
320
+ if (unattributed.length > 0) {
321
+ console.info("");
322
+ console.info(`Unattributed (${unattributed.length}) — present on disk, but not inside any declared worktrees location and not resolvable to a declared repo:`);
323
+ for (const slug of unattributed) {
290
324
  console.info(` ${slug}`);
325
+ // Name what is INSIDE it, one level down. A renamed worktrees
326
+ // directory is the common way to land here, and reporting only the
327
+ // container is its own kind of subtraction: the reader sees a folder
328
+ // and cannot tell it holds work. Naming them is the difference
329
+ // between "not dropped" and "actually visible".
330
+ for (const inner of listWorkbenchSubdirs(join(projectRoot, slug))) {
331
+ const owner = worktreeOwner(join(projectRoot, slug, inner), repoPaths);
332
+ console.info(` ${inner}${owner ? ` (a worktree of ${owner})` : ""}`);
333
+ }
291
334
  }
292
335
  }
293
336
  }
@@ -138,6 +138,65 @@ export interface InduskConfig {
138
138
  planning?: {
139
139
  dead_draft_days?: number;
140
140
  };
141
+ /**
142
+ * Workbench topology (versioned-workbench). A workbench wraps N >= 1 repos.
143
+ *
144
+ * `repos[]` is the single source of truth for which repos a workbench is
145
+ * made of; the older singular `wrapped_repo` reduces to a one-element list,
146
+ * so no existing workbench needs a config edit. Both are read through
147
+ * `readWorkbenchRepos` in `lib/worktree/repos.ts` — never destructured
148
+ * directly, because the reduction lives in that one function and a caller
149
+ * that reads the raw field skips it.
150
+ *
151
+ * Typed here rather than in the resolver so the shape has one home:
152
+ * `worktree.ts`, `stray-state-audit.ts` and `hooks/_hook-paths.js` each
153
+ * hand-rolled a private copy of it before this existed.
154
+ */
155
+ worktree?: WorktreeConfig;
156
+ }
157
+ /** One repo a workbench wraps. */
158
+ export interface WorkbenchRepo {
159
+ name: string;
160
+ /**
161
+ * Where to clone from, for `indusk workbench restore`. Optional: a repo
162
+ * declared without one is declared-but-unrestorable, which restore reports
163
+ * by name rather than skipping silently. Absent is a real state, not a
164
+ * malformed one — the singular `wrapped_repo` shape never carried a remote.
165
+ */
166
+ remote?: string;
167
+ /**
168
+ * The directory holding this repo's checkout, relative to the workbench.
169
+ * Absent ⇒ `name`.
170
+ *
171
+ * Declaring it is what lets a directory be renamed without breaking
172
+ * anything: `name` becomes an identifier rather than a location. Nothing
173
+ * derives a path from a name — the same rule the listing already follows
174
+ * when it asks git who owns a worktree rather than guessing from a prefix.
175
+ */
176
+ path?: string;
177
+ /**
178
+ * The directory this repo's worktrees live in, relative to the workbench.
179
+ * Absent ⇒ the workbench root, which is today's flat layout.
180
+ *
181
+ * **Absence meaning flat is the whole migration story.** An existing
182
+ * workbench declares nothing and behaves exactly as it does now; the nested
183
+ * layout is opt-in per repo. Same reduction shape that made `wrapped_repo`
184
+ * → `repos[]` cost nothing.
185
+ *
186
+ * Declaring it also makes the ignore rule precise: worktree names are
187
+ * invented at runtime and cannot be listed in advance, but the directory
188
+ * containing them can be named exactly.
189
+ */
190
+ worktrees?: string;
191
+ }
192
+ export interface WorktreeConfig {
193
+ shape?: "workbench";
194
+ /** The repos this workbench wraps. Supersedes `wrapped_repo`. */
195
+ repos?: WorkbenchRepo[];
196
+ /** Legacy singular form. Reduces to `repos: [{ name }]`. */
197
+ wrapped_repo?: string;
198
+ /** Parent directory the sibling clones live in. */
199
+ sibling_parent?: string;
141
200
  }
142
201
  /** Default sweep TTL: 7 days. Distinct from the 60-minute display TTL. */
143
202
  export declare const DEFAULT_SWEEP_TTL_MINUTES: number;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Migration surface for retired hooks — the twin of `mcp-migration.ts`.
3
+ *
4
+ * Deleting a hook from the package source does nothing to consumers. The file
5
+ * stays on disk in every `.claude/hooks/` that ever installed it, *and* stays
6
+ * registered in `.claude/settings.json`, so it keeps being invoked forever —
7
+ * or, once the file is finally removed by hand, the registration keeps
8
+ * invoking something that no longer exists. `check-plan-order.js` was the live
9
+ * instance: deleted from the package in the context-beam cleanup, still on disk
10
+ * and still registered months later.
11
+ *
12
+ * Hooks discovery is `globSync` on both install sides, which means new hooks
13
+ * arrive automatically and removed hooks never leave. This closes the other
14
+ * half: `init` and `update` both call it, and future retirements extend
15
+ * `LEGACY_HOOKS` rather than hand-rolling a removal loop in a command file.
16
+ */
17
+ /** Hooks retired from the package. Extend on future retirements. */
18
+ export declare const LEGACY_HOOKS: readonly string[];
19
+ export interface RemoveLegacyHooksResult {
20
+ /** Hook files deleted from `.claude/hooks/`. */
21
+ filesRemoved: string[];
22
+ /** Hooks whose `.claude/settings.json` registration was removed. */
23
+ registrationsRemoved: string[];
24
+ }
25
+ export interface RemoveLegacyHooksOptions {
26
+ /** Override the legacy list (tests). */
27
+ hooks?: readonly string[];
28
+ }
29
+ /**
30
+ * Remove every retired hook's file and its settings registration.
31
+ *
32
+ * Both halves matter and they fail differently: an orphaned *file* is dead
33
+ * weight that still runs, an orphaned *registration* makes Claude Code invoke a
34
+ * missing script on every matching tool call. Absent state is not an error —
35
+ * a missing hooks dir, missing settings file, or unparseable settings means
36
+ * nothing to do, never a throw, because this runs inside `init`/`update` where
37
+ * a migration must not be able to fail the command.
38
+ *
39
+ * A matcher entry whose `hooks` array empties out is dropped entirely rather
40
+ * than left as an empty shell that matches tool calls and runs nothing.
41
+ */
42
+ export declare function removeLegacyHooks(projectRoot: string, opts?: RemoveLegacyHooksOptions): RemoveLegacyHooksResult;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Migration surface for retired hooks — the twin of `mcp-migration.ts`.
3
+ *
4
+ * Deleting a hook from the package source does nothing to consumers. The file
5
+ * stays on disk in every `.claude/hooks/` that ever installed it, *and* stays
6
+ * registered in `.claude/settings.json`, so it keeps being invoked forever —
7
+ * or, once the file is finally removed by hand, the registration keeps
8
+ * invoking something that no longer exists. `check-plan-order.js` was the live
9
+ * instance: deleted from the package in the context-beam cleanup, still on disk
10
+ * and still registered months later.
11
+ *
12
+ * Hooks discovery is `globSync` on both install sides, which means new hooks
13
+ * arrive automatically and removed hooks never leave. This closes the other
14
+ * half: `init` and `update` both call it, and future retirements extend
15
+ * `LEGACY_HOOKS` rather than hand-rolling a removal loop in a command file.
16
+ */
17
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ /** Hooks retired from the package. Extend on future retirements. */
20
+ export const LEGACY_HOOKS = ["check-plan-order.js"];
21
+ /**
22
+ * Remove every retired hook's file and its settings registration.
23
+ *
24
+ * Both halves matter and they fail differently: an orphaned *file* is dead
25
+ * weight that still runs, an orphaned *registration* makes Claude Code invoke a
26
+ * missing script on every matching tool call. Absent state is not an error —
27
+ * a missing hooks dir, missing settings file, or unparseable settings means
28
+ * nothing to do, never a throw, because this runs inside `init`/`update` where
29
+ * a migration must not be able to fail the command.
30
+ *
31
+ * A matcher entry whose `hooks` array empties out is dropped entirely rather
32
+ * than left as an empty shell that matches tool calls and runs nothing.
33
+ */
34
+ export function removeLegacyHooks(projectRoot, opts = {}) {
35
+ const legacy = opts.hooks ?? LEGACY_HOOKS;
36
+ const result = { filesRemoved: [], registrationsRemoved: [] };
37
+ for (const name of legacy) {
38
+ const hookPath = join(projectRoot, ".claude/hooks", name);
39
+ if (existsSync(hookPath)) {
40
+ try {
41
+ rmSync(hookPath, { force: true });
42
+ result.filesRemoved.push(name);
43
+ }
44
+ catch {
45
+ // Leave it rather than fail the migration; the registration removal
46
+ // below still stops it being invoked.
47
+ }
48
+ }
49
+ }
50
+ const settingsPath = join(projectRoot, ".claude/settings.json");
51
+ if (!existsSync(settingsPath))
52
+ return result;
53
+ let settings;
54
+ try {
55
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
56
+ }
57
+ catch {
58
+ return result;
59
+ }
60
+ if (!settings.hooks)
61
+ return result;
62
+ let changed = false;
63
+ for (const [event, entries] of Object.entries(settings.hooks)) {
64
+ if (!Array.isArray(entries))
65
+ continue;
66
+ const keptEntries = [];
67
+ for (const entry of entries) {
68
+ const commands = Array.isArray(entry.hooks) ? entry.hooks : [];
69
+ const kept = commands.filter((c) => {
70
+ const cmd = String(c.command ?? "");
71
+ const hit = legacy.find((name) => cmd.includes(name));
72
+ if (hit) {
73
+ if (!result.registrationsRemoved.includes(hit))
74
+ result.registrationsRemoved.push(hit);
75
+ changed = true;
76
+ return false;
77
+ }
78
+ return true;
79
+ });
80
+ // An entry that had commands and now has none would match tool calls and
81
+ // run nothing — drop it instead.
82
+ if (commands.length > 0 && kept.length === 0)
83
+ continue;
84
+ keptEntries.push(kept.length === commands.length ? entry : { ...entry, hooks: kept });
85
+ }
86
+ if (keptEntries.length === 0)
87
+ delete settings.hooks[event];
88
+ else
89
+ settings.hooks[event] = keptEntries;
90
+ }
91
+ if (changed)
92
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, "\t")}\n`);
93
+ return result;
94
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The one definition of "this name is safe to join into a path".
3
+ *
4
+ * Lived inside `plan-parser.ts` while plan declarations were the only
5
+ * caller. Workbench repo names are the second caller, and the standing rule
6
+ * here is that a primitive kept in a domain folder gets COPIED by the next
7
+ * domain rather than imported — which is how `git()` and the phase-block scan
8
+ * each ended up with two definitions. Moving it out on the second caller
9
+ * rather than the third is the cheap moment.
10
+ *
11
+ * Both callers read a name out of a file a human edits and then join it into
12
+ * a filesystem path. Same question, same answer — unlike `isMachineState` vs
13
+ * `isNotCode`, which look alike and are deliberately not shared because their
14
+ * answers genuinely differ.
15
+ */
16
+ /**
17
+ * A name must be a single clean path segment to be joined into a path or
18
+ * rendered verbatim. Anything else is dropped at the boundary: degrade to
19
+ * structure-loss, never a path traversal or a raw render.
20
+ */
21
+ export declare function isCleanSegment(name: string): boolean;
22
+ /** A clean segment that does not collide with machine-owned state. */
23
+ export declare function isUsableSegment(name: string): boolean;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one definition of "this name is safe to join into a path".
3
+ *
4
+ * Lived inside `plan-parser.ts` while plan declarations were the only
5
+ * caller. Workbench repo names are the second caller, and the standing rule
6
+ * here is that a primitive kept in a domain folder gets COPIED by the next
7
+ * domain rather than imported — which is how `git()` and the phase-block scan
8
+ * each ended up with two definitions. Moving it out on the second caller
9
+ * rather than the third is the cheap moment.
10
+ *
11
+ * Both callers read a name out of a file a human edits and then join it into
12
+ * a filesystem path. Same question, same answer — unlike `isMachineState` vs
13
+ * `isNotCode`, which look alike and are deliberately not shared because their
14
+ * answers genuinely differ.
15
+ */
16
+ /**
17
+ * A name must be a single clean path segment to be joined into a path or
18
+ * rendered verbatim. Anything else is dropped at the boundary: degrade to
19
+ * structure-loss, never a path traversal or a raw render.
20
+ */
21
+ export function isCleanSegment(name) {
22
+ return (name.trim() !== "" &&
23
+ name !== "." &&
24
+ name !== ".." &&
25
+ !name.includes("/") &&
26
+ !name.includes("\\"));
27
+ }
28
+ /**
29
+ * Directories a declared path must never resolve onto.
30
+ *
31
+ * `isCleanSegment` answers "could this traverse?" — it cannot answer "does
32
+ * this collide?". `.git`, `.indusk` and `.claude` are all single clean
33
+ * segments, so a config declaring `worktrees: ".git"` would place worktrees
34
+ * inside the workbench's own git directory. Collision is a different question
35
+ * from traversal and needs its own answer.
36
+ */
37
+ const RESERVED_SEGMENTS = new Set([".git", ".indusk", ".claude"]);
38
+ /** A clean segment that does not collide with machine-owned state. */
39
+ export function isUsableSegment(name) {
40
+ return isCleanSegment(name) && !RESERVED_SEGMENTS.has(name);
41
+ }
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import { getPlanningDir } from "./config.js";
5
+ import { isCleanSegment } from "./path-segment.js";
5
6
  const STAGE_ORDER = [
6
7
  "research",
7
8
  "brief",
@@ -101,19 +102,10 @@ export function parsePlan(planDir) {
101
102
  ...(parseError && { parseError }),
102
103
  };
103
104
  }
104
- /**
105
- * A declaration name must be a single clean path segment — it gets joined
106
- * into filesystem paths (`join(planningDir, name, "master.md")`) and rendered
107
- * verbatim in the sidebar. Anything else is dropped at this boundary: degrade
108
- * to structure-loss, never a path traversal or a raw render.
109
- */
110
- function isCleanSegment(name) {
111
- return (name.trim() !== "" &&
112
- name !== "." &&
113
- name !== ".." &&
114
- !name.includes("/") &&
115
- !name.includes("\\"));
116
- }
105
+ // A declaration name must be a single clean path segment — it gets joined into
106
+ // filesystem paths (`join(planningDir, name, "master.md")`) and rendered
107
+ // verbatim in the sidebar. The guard now lives in `lib/path-segment.ts`,
108
+ // shared with workbench repo names, which are the same question.
117
109
  /**
118
110
  * Read a frontmatter key as a string array; anything else yields [].
119
111
  * Non-string entries, non-segment names, and duplicates are dropped —
@@ -10,7 +10,8 @@
10
10
  * operator can manually `rm -rf` after confirming nothing important is in them.
11
11
  *
12
12
  * Workbench mode is detected by reading `.indusk/config.json` at the
13
- * `workbenchRoot` and checking for a `worktree.wrapped_repo` field. Single-
13
+ * `workbenchRoot` and resolving its declared repo set via `readWorkbenchRepos`
14
+ * (which reduces the legacy `wrapped_repo` into a one-element list). Single-
14
15
  * repo mode skips the audit entirely (no false positives).
15
16
  *
16
17
  * Worktrees that live at the workbench-root level (sibling to the wrapped
@@ -31,7 +32,7 @@ export interface StrayStateFinding {
31
32
  * Find stray `.indusk/` directories in a workbench-shaped project.
32
33
  *
33
34
  * Returns an empty array if:
34
- * - `workbenchRoot` is not a workbench-mode project (no `worktree.wrapped_repo`
35
+ * - `workbenchRoot` is not a workbench-mode project (no declared repos
35
36
  * in config) — single-repo projects are not audited
36
37
  * - the wrapped repo doesn't exist (nothing to audit)
37
38
  * - no stray state is found (clean workbench)