@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.
- package/admin/.next/BUILD_ID +1 -1
- package/admin/.next/build-manifest.json +3 -3
- package/admin/.next/fallback-build-manifest.json +3 -3
- package/admin/.next/prerender-manifest.json +3 -3
- package/admin/.next/server/app/_global-error.html +1 -1
- package/admin/.next/server/app/_global-error.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/admin/.next/server/chunks/ssr/[root-of-the-server]__0p.51_4._.js.map +1 -1
- package/admin/.next/server/middleware-build-manifest.js +3 -3
- package/admin/.next/server/pages/500.html +1 -1
- package/admin/.next/server/server-reference-manifest.js +1 -1
- package/admin/.next/server/server-reference-manifest.json +1 -1
- package/admin/.next/trace +1 -1
- package/admin/.next/trace-build +1 -1
- package/dist/bin/cli.js +42 -4
- package/dist/bin/commands/init.js +42 -10
- package/dist/bin/commands/telemetry.js +7 -1
- package/dist/bin/commands/update.js +104 -2
- package/dist/bin/commands/workbench.d.ts +76 -0
- package/dist/bin/commands/workbench.js +438 -0
- package/dist/bin/commands/worktree.d.ts +22 -6
- package/dist/bin/commands/worktree.js +154 -111
- package/dist/lib/config.d.ts +59 -0
- package/dist/lib/hook-migration.d.ts +42 -0
- package/dist/lib/hook-migration.js +94 -0
- package/dist/lib/path-segment.d.ts +23 -0
- package/dist/lib/path-segment.js +41 -0
- package/dist/lib/plan-parser.js +5 -13
- package/dist/lib/stray-state-audit.d.ts +3 -2
- package/dist/lib/stray-state-audit.js +14 -20
- package/dist/lib/telemetry/daemon.d.ts +18 -0
- package/dist/lib/telemetry/daemon.js +18 -2
- package/dist/lib/verify/git.d.ts +11 -4
- package/dist/lib/verify/git.js +11 -4
- package/dist/lib/verify/roots.d.ts +40 -0
- package/dist/lib/verify/roots.js +54 -0
- package/dist/lib/verify/verify.js +17 -2
- package/dist/lib/worktree/layout.d.ts +56 -0
- package/dist/lib/worktree/layout.js +136 -0
- package/dist/lib/worktree/repos.d.ts +46 -0
- package/dist/lib/worktree/repos.js +100 -0
- package/dist/lib/worktree/shareable.d.ts +47 -0
- package/dist/lib/worktree/shareable.js +301 -0
- package/dist/lib/worktree/sync.d.ts +62 -0
- package/dist/lib/worktree/sync.js +174 -0
- package/extensions/worktree/hooks/on_enable.sh +17 -10
- package/extensions/worktree/scripts/lib/workbench-helpers.sh +84 -0
- package/extensions/worktree/scripts/preflight.sh +4 -8
- package/extensions/worktree/scripts/refresh-worktree.sh +4 -8
- package/extensions/worktree/scripts/setup-worktree.sh +40 -16
- package/extensions/worktree/skill.md +8 -6
- package/hooks/_hook-paths.js +78 -15
- package/hooks/workbench-sync.js +119 -0
- package/package.json +109 -109
- package/skills/catchup.md +17 -0
- /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_buildManifest.js +0 -0
- /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_clientMiddlewareManifest.js +0 -0
- /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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
224
|
+
catch (err) {
|
|
225
|
+
return `error: ${err.message}`;
|
|
177
226
|
}
|
|
178
227
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
"
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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` —
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
273
|
-
|
|
314
|
+
else {
|
|
315
|
+
console.info(` Worktrees (${mine.length}):`);
|
|
316
|
+
for (const slug of mine)
|
|
317
|
+
console.info(` ${slug}`);
|
|
274
318
|
}
|
|
275
319
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/plan-parser.js
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
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)
|