@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
@@ -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
@@ -18,24 +19,14 @@
18
19
  * and are NOT flagged as stray. Only `.indusk/` inside the explicit wrapped
19
20
  * repo path is flagged.
20
21
  */
21
- import { existsSync, readdirSync, readFileSync } from "node:fs";
22
+ import { existsSync, readdirSync } from "node:fs";
22
23
  import { join } from "node:path";
23
- function readWorkbenchConfig(workbenchRoot) {
24
- const configPath = join(workbenchRoot, ".indusk/config.json");
25
- if (!existsSync(configPath))
26
- return null;
27
- try {
28
- return JSON.parse(readFileSync(configPath, "utf-8"));
29
- }
30
- catch {
31
- return null;
32
- }
33
- }
24
+ import { readWorkbenchRepos } from "./worktree/repos.js";
34
25
  /**
35
26
  * Find stray `.indusk/` directories in a workbench-shaped project.
36
27
  *
37
28
  * Returns an empty array if:
38
- * - `workbenchRoot` is not a workbench-mode project (no `worktree.wrapped_repo`
29
+ * - `workbenchRoot` is not a workbench-mode project (no declared repos
39
30
  * in config) — single-repo projects are not audited
40
31
  * - the wrapped repo doesn't exist (nothing to audit)
41
32
  * - no stray state is found (clean workbench)
@@ -45,15 +36,18 @@ function readWorkbenchConfig(workbenchRoot) {
45
36
  * to the wrapped repo) are permitted to have their own `.indusk/` for scratch.
46
37
  */
47
38
  export function findStrayState(workbenchRoot) {
48
- const config = readWorkbenchConfig(workbenchRoot);
49
- const wrappedRepoName = config?.worktree?.wrapped_repo;
39
+ // Every declared repo is audited, not just the first. A workbench that
40
+ // wraps N repos can accumulate stray state in any of them, and auditing
41
+ // only one produces a clean report about a dirty workbench.
42
+ const repos = readWorkbenchRepos(workbenchRoot);
50
43
  // Not a workbench project — skip audit.
51
- if (!wrappedRepoName)
44
+ if (repos.length === 0)
52
45
  return [];
53
46
  const findings = [];
54
- // Check the wrapped repo for a stray `.indusk/`.
55
- const wrappedRepoPath = join(workbenchRoot, wrappedRepoName);
56
- if (existsSync(wrappedRepoPath)) {
47
+ for (const repo of repos) {
48
+ const wrappedRepoPath = join(workbenchRoot, repo.name);
49
+ if (!existsSync(wrappedRepoPath))
50
+ continue;
57
51
  const strayInsideWrapped = walkForStrayIndusk(wrappedRepoPath, 2);
58
52
  for (const strayPath of strayInsideWrapped) {
59
53
  findings.push({
@@ -47,6 +47,24 @@ export interface DaemonStopResult {
47
47
  export declare function resolveBinary(name: "jaeger" | "otelcol"): string;
48
48
  export declare function isPortListening(port: number): Promise<boolean>;
49
49
  export declare function findFreePort(requested: number): Promise<number>;
50
+ /**
51
+ * Thrown when a requested port is already served by a process we do not own.
52
+ *
53
+ * Typed so callers can tell it apart from a real start failure: an explicit
54
+ * `telemetry start` should surface it and stop, but the opportunistic auto-start
55
+ * on extension enable must NOT fail the whole command over it — a daemon is
56
+ * evidently already serving those ports, which is the state auto-start wanted.
57
+ */
58
+ export declare class PortsOccupiedError extends Error {
59
+ readonly ports: {
60
+ name: string;
61
+ port: number;
62
+ }[];
63
+ constructor(message: string, ports: {
64
+ name: string;
65
+ port: number;
66
+ }[]);
67
+ }
50
68
  /**
51
69
  * Refuse to start when a requested port is already served by something we do
52
70
  * not own.
@@ -287,6 +287,22 @@ service:
287
287
  `;
288
288
  }
289
289
  // ---- daemonStart / daemonStop / daemonStatus / daemonRestart ---------------
290
+ /**
291
+ * Thrown when a requested port is already served by a process we do not own.
292
+ *
293
+ * Typed so callers can tell it apart from a real start failure: an explicit
294
+ * `telemetry start` should surface it and stop, but the opportunistic auto-start
295
+ * on extension enable must NOT fail the whole command over it — a daemon is
296
+ * evidently already serving those ports, which is the state auto-start wanted.
297
+ */
298
+ export class PortsOccupiedError extends Error {
299
+ ports;
300
+ constructor(message, ports) {
301
+ super(message);
302
+ this.name = "PortsOccupiedError";
303
+ this.ports = ports;
304
+ }
305
+ }
290
306
  /**
291
307
  * Refuse to start when a requested port is already served by something we do
292
308
  * not own.
@@ -321,12 +337,12 @@ export async function assertRequestedPortsFree(opts) {
321
337
  if (occupied.length === 0)
322
338
  return;
323
339
  const detail = occupied.map((p) => `${p.name} :${p.port}`).join(", ");
324
- throw new Error(`Refusing to start: ${detail} already in use by a process this daemon does not own.\n` +
340
+ throw new PortsOccupiedError(`Refusing to start: ${detail} already in use by a process this daemon does not own.\n` +
325
341
  `Starting anyway would bind a random port instead, and anything configured for the ` +
326
342
  `requested port would keep talking to whatever is there — silently.\n` +
327
343
  ` If it is a leaked telemetry process: indusk telemetry reap\n` +
328
344
  ` If it is another tool you want to keep: indusk telemetry start --allow-port-bump\n` +
329
- ` Or choose explicitly: indusk telemetry start --otlp-port <n> --ui-port <n>`);
345
+ ` Or choose explicitly: indusk telemetry start --otlp-port <n> --ui-port <n>`, occupied);
330
346
  }
331
347
  export async function daemonStart(opts = {}) {
332
348
  ensureHome();
@@ -3,10 +3,17 @@ export { git };
3
3
  /**
4
4
  * Assert the root is inside a git work tree, failing LOUD when it is not.
5
5
  *
6
- * A workbench root is deliberately not a git repo, and the cleanup library's
7
- * silent-`[]` on that shape made its ritual vacuous there. The same silence in
8
- * a verifier would report "clean" for a phase it never examined — the worst
9
- * shape a verification bug can take.
6
+ * The cleanup library's silent-`[]` on a non-git root made its ritual vacuous
7
+ * there. The same silence in a verifier would report "clean" for a phase it
8
+ * never examined — the worst shape a verification bug can take.
9
+ *
10
+ * NOTE (versioned-workbench): this guard used to be free. A workbench root was
11
+ * deliberately not a git repo, so it refused there by a property nobody
12
+ * maintained. Making the root a git repo removes that accident, and the
13
+ * refusal is now MAINTAINED — by `resolveVerifyRoots`, which separates the
14
+ * repo holding `impl.md` from the repo holding the code and refuses when they
15
+ * differ. Changing this function is no longer the whole story; see
16
+ * `verify/roots.ts`.
10
17
  */
11
18
  export declare function assertGitRepo(root: string): Promise<void>;
12
19
  export declare function headSha(root: string): Promise<string>;
@@ -19,10 +19,17 @@ export { git };
19
19
  /**
20
20
  * Assert the root is inside a git work tree, failing LOUD when it is not.
21
21
  *
22
- * A workbench root is deliberately not a git repo, and the cleanup library's
23
- * silent-`[]` on that shape made its ritual vacuous there. The same silence in
24
- * a verifier would report "clean" for a phase it never examined — the worst
25
- * shape a verification bug can take.
22
+ * The cleanup library's silent-`[]` on a non-git root made its ritual vacuous
23
+ * there. The same silence in a verifier would report "clean" for a phase it
24
+ * never examined — the worst shape a verification bug can take.
25
+ *
26
+ * NOTE (versioned-workbench): this guard used to be free. A workbench root was
27
+ * deliberately not a git repo, so it refused there by a property nobody
28
+ * maintained. Making the root a git repo removes that accident, and the
29
+ * refusal is now MAINTAINED — by `resolveVerifyRoots`, which separates the
30
+ * repo holding `impl.md` from the repo holding the code and refuses when they
31
+ * differ. Changing this function is no longer the whole story; see
32
+ * `verify/roots.ts`.
26
33
  */
27
34
  export async function assertGitRepo(root) {
28
35
  try {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Where the plan lives, and where its code lives.
3
+ *
4
+ * These were one value for as long as every project kept both in the same
5
+ * repository. A workbench separates them: `impl.md` sits in the workbench's
6
+ * `.indusk/planning/`, while the code it describes sits across a trunk symlink
7
+ * in a wrapped repo with its own history.
8
+ *
9
+ * Until versioned-workbench a workbench root was not a git repo, so
10
+ * `assertGitRepo` refused and no detector ever ran there. That refusal was
11
+ * correct and it was FREE — it held because of a property nobody maintained.
12
+ * Making the root a git repo removes it by accident, and what is left is a
13
+ * repository whose diff *structurally cannot contain* the code being judged:
14
+ * phantom detection fires when nothing outside `impl.md` changed, which in a
15
+ * workbench is always. Every honest checkoff would be reported as phantom, and
16
+ * a detector that cries wolf gets switched off — taking its real catches with
17
+ * it.
18
+ *
19
+ * So the refusal is now maintained on purpose, here.
20
+ */
21
+ export interface VerifyRoots {
22
+ /** Repository holding `impl.md`, the ledger, and the plan's documents. */
23
+ planRoot: string;
24
+ /** Repository holding the code those documents describe. */
25
+ codeRoot: string;
26
+ /** True when the two differ — a workbench. */
27
+ split: boolean;
28
+ }
29
+ export interface VerifyRootsRefusal {
30
+ error: string;
31
+ }
32
+ /**
33
+ * Resolve both roots, or refuse.
34
+ *
35
+ * **Never falls back to the plan root.** A diff of plan documents is not
36
+ * evidence about code, and reporting it as such is precisely the "could not
37
+ * check" reported as "checked" failure this module exists to prevent.
38
+ */
39
+ export declare function resolveVerifyRoots(planRoot: string): VerifyRoots | VerifyRootsRefusal;
40
+ export declare function isRefusal(r: VerifyRoots | VerifyRootsRefusal): r is VerifyRootsRefusal;
@@ -0,0 +1,54 @@
1
+ import { join } from "node:path";
2
+ import { isWorkbench, readWorkbenchRepos } from "../worktree/repos.js";
3
+ /**
4
+ * Resolve both roots, or refuse.
5
+ *
6
+ * **Never falls back to the plan root.** A diff of plan documents is not
7
+ * evidence about code, and reporting it as such is precisely the "could not
8
+ * check" reported as "checked" failure this module exists to prevent.
9
+ */
10
+ export function resolveVerifyRoots(planRoot) {
11
+ // Normal-mode project: `.indusk/` ships inside the code repo, so the two
12
+ // roots genuinely are the same directory. Unchanged from every release
13
+ // before this one — dusk itself takes this path.
14
+ if (!isWorkbench(planRoot)) {
15
+ return { planRoot, codeRoot: planRoot, split: false };
16
+ }
17
+ const repos = readWorkbenchRepos(planRoot);
18
+ const declared = repos.map((r) => r.name).join(", ");
19
+ // EVERY workbench refuses, and the reason differs by shape.
20
+ //
21
+ // This is the floor the ADR scopes to this plan, not the finished feature.
22
+ // Even with a single declared repo, verify cannot honestly judge a phase
23
+ // here: the baseline sha comes from the plan repo's ledger and does not
24
+ // exist in the code repo's history, so there is nothing to diff the code
25
+ // against. Producing a verdict anyway would mean judging code by a diff
26
+ // that cannot contain it.
27
+ //
28
+ // Refusing costs nothing that worked before — a workbench root was not a
29
+ // git repo, so `assertGitRepo` already refused every run. What changes is
30
+ // that the refusal is now maintained deliberately and says why, instead of
31
+ // holding by an accident this plan removes.
32
+ if (repos.length === 0) {
33
+ return {
34
+ error: `${planRoot} is workbench-shaped but declares no repos, so there is no code root to verify against. ` +
35
+ "Refusing rather than judging a phase against the plan documents alone.",
36
+ };
37
+ }
38
+ if (repos.length > 1) {
39
+ return {
40
+ error: `${planRoot} is a workbench wrapping ${repos.length} repos (${declared}), and nothing in the plan says which holds its code. ` +
41
+ "Refusing rather than picking one — a verdict against the wrong repository is indistinguishable from a correct one. " +
42
+ "Run verify inside the repo the phase's code lives in.",
43
+ };
44
+ }
45
+ return {
46
+ error: `${planRoot} is a workbench: its plan documents and its code (${declared}) live in different repositories, ` +
47
+ "and the verify ledger records a baseline from the plan repo that has no meaning in the code repo. " +
48
+ "Refusing rather than judging code against a diff that cannot contain it. " +
49
+ `Run verify inside ${join(planRoot, declared)} instead; cross-repo verification is a named follow-on.`,
50
+ };
51
+ }
52
+ export function isRefusal(r) {
53
+ return "error" in r;
54
+ }
@@ -8,6 +8,7 @@ import { assertGitRepo, headSha, resolveBootstrapBaseline } from "./git.js";
8
8
  import { appendVerifyRecord, findBaselineRecord, hashTrajectory, readLedger } from "./ledger.js";
9
9
  import { detectPhantomWork } from "./phantom.js";
10
10
  import { detectRedTests } from "./red-tests.js";
11
+ import { isRefusal, resolveVerifyRoots } from "./roots.js";
11
12
  /** A rejected verdict must fail a calling script; a clean one must not. */
12
13
  export function exitCodeForReport(report) {
13
14
  return report.verdict === "rejected" ? 1 : 0;
@@ -16,6 +17,15 @@ export async function runVerify(options) {
16
17
  const root = resolve(options.root);
17
18
  // LOUD before anything else: a non-git root must never yield a clean report.
18
19
  await assertGitRepo(root);
20
+ // Where the plan lives is not always where its code lives. In a workbench
21
+ // they are different repositories, and judging a phase against the plan
22
+ // repo's diff would report every honest checkoff as phantom.
23
+ const roots = resolveVerifyRoots(root);
24
+ if (isRefusal(roots))
25
+ throw new Error(roots.error);
26
+ const codeRoot = roots.codeRoot;
27
+ if (roots.split)
28
+ await assertGitRepo(codeRoot);
19
29
  const implPath = resolveImplPath(root, options.plan);
20
30
  if (implPath === null) {
21
31
  throw new Error(`Plan "${options.plan}" not found — expected an impl.md path, a directory containing one, or a plan under .indusk/planning/.`);
@@ -56,8 +66,13 @@ export async function runVerify(options) {
56
66
  });
57
67
  findings.push(...redTests.findings);
58
68
  const unverifiedRows = redTests.unverifiedRows;
59
- findings.push(...(await detectPhantomWork({
60
- root,
69
+ findings.push(
70
+ // Phantom asks "did anything but the plan file change?", which is a
71
+ // question about the CODE repo. `resolveVerifyRoots` has already
72
+ // guaranteed the two are the same repository here — a workbench refuses
73
+ // before reaching this point rather than diffing the wrong history.
74
+ ...(await detectPhantomWork({
75
+ root: codeRoot,
61
76
  baselineSha: baseline.sha,
62
77
  implRepoRelPath,
63
78
  currentContent: content,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Facts about a workbench's on-disk layout, defined once.
3
+ *
4
+ * Three things every workbench command needs: which root directories are not
5
+ * worktrees, which declared repo owns a worktree, and how a trunk symlink is
6
+ * made. Each of them lived in two places before this module — `worktree.ts`
7
+ * and `workbench.ts` — because the second command group re-authored what the
8
+ * first already knew.
9
+ *
10
+ * They are here, rather than in either command, for the standing reason a
11
+ * primitive kept inside a domain folder gets COPIED by the next domain instead
12
+ * of imported: it is how `git()` and the phase-block scan each ended up with
13
+ * two definitions, and it is exactly what happened here. Every divergence in
14
+ * this file's subject matter is silent — a missing reserved name renders a
15
+ * machine directory as a worktree, a drifted attribution reads like a correct
16
+ * one, and a trunk link that was not made reports as one that was.
17
+ */
18
+ /**
19
+ * Root entries that are never worktrees.
20
+ *
21
+ * `docs` is D7's reserved workbench-root internal-docs directory. Absent from
22
+ * this set it renders as a worktree, which is how the POC's `docs/` looked
23
+ * before anyone noticed — the reason it is listed, kept with the listing.
24
+ */
25
+ export declare const RESERVED_ROOT_DIRS: ReadonlySet<string>;
26
+ /**
27
+ * Root entries that could be worktrees — directories and symlinks, sorted.
28
+ *
29
+ * Symlinks are included because a trunk is one, and callers filter the trunks
30
+ * out by their DECLARED paths; excluding symlinks here would hide a trunk from
31
+ * the collision checks that need to see it.
32
+ */
33
+ export declare function listWorkbenchSubdirs(root: string): string[];
34
+ /**
35
+ * Which declared repo does this worktree belong to?
36
+ *
37
+ * Asked of git rather than inferred from the slug: `--git-common-dir` resolves
38
+ * to the OWNING repo's `.git`, so the answer survives any naming convention a
39
+ * developer invents. A name-prefix heuristic would attribute `alpha-feature`
40
+ * to `alpha` by luck and `experiment` to nothing at all — and a wrong
41
+ * attribution reads exactly like a right one.
42
+ *
43
+ * Null means "could not tell", which renders as unattributed rather than being
44
+ * quietly assigned to the first repo.
45
+ */
46
+ export declare function worktreeOwner(worktreePath: string, repoPaths: Map<string, string>): string | null;
47
+ /**
48
+ * Point `<workbenchRoot>/<name>` at `target`, and say whether it did.
49
+ *
50
+ * Relative target, so the workbench stays portable. Returns false in exactly
51
+ * one case: a real directory occupies the path. That is not ours to remove,
52
+ * and reporting it as linked would be a claim about something that never
53
+ * happened — the falsification A30 fixed in `restore`, and the reason `init`
54
+ * routes through here rather than keeping its own subset.
55
+ */
56
+ export declare function linkTrunk(workbenchRoot: string, name: string, target: string): boolean;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Facts about a workbench's on-disk layout, defined once.
3
+ *
4
+ * Three things every workbench command needs: which root directories are not
5
+ * worktrees, which declared repo owns a worktree, and how a trunk symlink is
6
+ * made. Each of them lived in two places before this module — `worktree.ts`
7
+ * and `workbench.ts` — because the second command group re-authored what the
8
+ * first already knew.
9
+ *
10
+ * They are here, rather than in either command, for the standing reason a
11
+ * primitive kept inside a domain folder gets COPIED by the next domain instead
12
+ * of imported: it is how `git()` and the phase-block scan each ended up with
13
+ * two definitions, and it is exactly what happened here. Every divergence in
14
+ * this file's subject matter is silent — a missing reserved name renders a
15
+ * machine directory as a worktree, a drifted attribution reads like a correct
16
+ * one, and a trunk link that was not made reports as one that was.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import { existsSync, lstatSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, } from "node:fs";
20
+ import { join, relative } from "node:path";
21
+ /**
22
+ * Root entries that are never worktrees.
23
+ *
24
+ * `docs` is D7's reserved workbench-root internal-docs directory. Absent from
25
+ * this set it renders as a worktree, which is how the POC's `docs/` looked
26
+ * before anyone noticed — the reason it is listed, kept with the listing.
27
+ */
28
+ export const RESERVED_ROOT_DIRS = new Set([
29
+ ".indusk",
30
+ ".claude",
31
+ ".git",
32
+ ".vscode",
33
+ ".cursor",
34
+ "node_modules",
35
+ "dist",
36
+ "build",
37
+ ".next",
38
+ "scripts",
39
+ "env",
40
+ "docs",
41
+ ]);
42
+ /**
43
+ * Root entries that could be worktrees — directories and symlinks, sorted.
44
+ *
45
+ * Symlinks are included because a trunk is one, and callers filter the trunks
46
+ * out by their DECLARED paths; excluding symlinks here would hide a trunk from
47
+ * the collision checks that need to see it.
48
+ */
49
+ export function listWorkbenchSubdirs(root) {
50
+ const entries = [];
51
+ for (const name of readdirSync(root)) {
52
+ if (RESERVED_ROOT_DIRS.has(name))
53
+ continue;
54
+ try {
55
+ const st = lstatSync(join(root, name));
56
+ if (st.isDirectory() || st.isSymbolicLink())
57
+ entries.push(name);
58
+ }
59
+ catch {
60
+ // Unreadable entry — skip rather than fail the whole listing.
61
+ }
62
+ }
63
+ return entries.sort();
64
+ }
65
+ /**
66
+ * Which declared repo does this worktree belong to?
67
+ *
68
+ * Asked of git rather than inferred from the slug: `--git-common-dir` resolves
69
+ * to the OWNING repo's `.git`, so the answer survives any naming convention a
70
+ * developer invents. A name-prefix heuristic would attribute `alpha-feature`
71
+ * to `alpha` by luck and `experiment` to nothing at all — and a wrong
72
+ * attribution reads exactly like a right one.
73
+ *
74
+ * Null means "could not tell", which renders as unattributed rather than being
75
+ * quietly assigned to the first repo.
76
+ */
77
+ export function worktreeOwner(worktreePath, repoPaths) {
78
+ const r = spawnSync("git", ["-C", worktreePath, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf-8" });
79
+ if (r.status !== 0 || !r.stdout)
80
+ return null;
81
+ const commonDir = r.stdout.trim();
82
+ for (const [name, repoPath] of repoPaths) {
83
+ try {
84
+ if (realpathSync(commonDir).startsWith(realpathSync(repoPath)))
85
+ return name;
86
+ }
87
+ catch {
88
+ // unresolvable path — treat as no match rather than guessing
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ /** Whether `p` is a symlink, dangling or not. Private: `linkTrunk` is the surface. */
94
+ function isSymlink(p) {
95
+ try {
96
+ return lstatSync(p).isSymbolicLink();
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /**
103
+ * A symlink whose target no longer exists.
104
+ *
105
+ * `existsSync` follows the link and so reports FALSE for these — which is why
106
+ * a naive "create it if it does not exist" throws EEXIST on a workbench whose
107
+ * sibling parent moved.
108
+ */
109
+ function isDanglingLink(p) {
110
+ return isSymlink(p) && !existsSync(p);
111
+ }
112
+ /**
113
+ * Point `<workbenchRoot>/<name>` at `target`, and say whether it did.
114
+ *
115
+ * Relative target, so the workbench stays portable. Returns false in exactly
116
+ * one case: a real directory occupies the path. That is not ours to remove,
117
+ * and reporting it as linked would be a claim about something that never
118
+ * happened — the falsification A30 fixed in `restore`, and the reason `init`
119
+ * routes through here rather than keeping its own subset.
120
+ */
121
+ export function linkTrunk(workbenchRoot, name, target) {
122
+ const link = join(workbenchRoot, name);
123
+ const rel = relative(workbenchRoot, target);
124
+ if (existsSync(link) || isDanglingLink(link)) {
125
+ // A correct link needs nothing doing — report it as linked.
126
+ if (isSymlink(link) && readlinkSync(link) === rel)
127
+ return true;
128
+ // Repair a link that points somewhere else, dangling included.
129
+ if (isSymlink(link))
130
+ rmSync(link);
131
+ else
132
+ return false;
133
+ }
134
+ symlinkSync(rel, link);
135
+ return true;
136
+ }
@@ -0,0 +1,46 @@
1
+ import type { WorkbenchRepo } from "../config.js";
2
+ export type { WorkbenchRepo } from "../config.js";
3
+ /**
4
+ * Read the declared repo set, newest shape first, singular reduced into it.
5
+ *
6
+ * **The reduction IS the backward-compatibility guarantee**, not a claim about
7
+ * one: `wrapped_repo: "numero"` reads as `[{ name: "numero" }]`, so every
8
+ * workbench written before this plan keeps working with no config edit and no
9
+ * migration step. The same shape as `phaseOrdinal` reducing to the phase
10
+ * number when a document has no test phase.
11
+ *
12
+ * Names are boundary values — sanitized before any caller can join them into a
13
+ * path, and first-occurrence-deduped so declared order survives. A malformed
14
+ * declaration degrades to fewer repos, never to a traversal.
15
+ *
16
+ * Returns `[]` for a project that declares nothing, which callers read as
17
+ * "not a workbench" exactly as they read a missing `wrapped_repo` today.
18
+ */
19
+ export declare function readWorkbenchRepos(root: string): WorkbenchRepo[];
20
+ /** Whether this project is workbench-shaped at all. */
21
+ export declare function isWorkbench(root: string): boolean;
22
+ /** The parent directory the sibling clones live in, or null when undeclared. */
23
+ export declare function readSiblingParent(root: string): string | null;
24
+ /**
25
+ * The refusal a command owes a project that is not a workbench.
26
+ *
27
+ * One string, because the *rule* it belongs to lives in bash. `worktree
28
+ * create`/`refresh`/`preflight` all resolve which repo the user meant inside
29
+ * `_resolve_workbench_repo`, and `worktreeCreate` deliberately passes `--repo`
30
+ * straight through rather than deciding first — so there is exactly one
31
+ * implementation of pick-one and it is the shell one.
32
+ *
33
+ * A TypeScript `resolveRepo` stood here with no caller, its docblock claiming
34
+ * to be "the rule the CLI surface needs in one place" while the CLI surface
35
+ * read the shell. Dead code that describes itself as authoritative is worse
36
+ * than dead code; what the TS lane actually shares with bash is this message.
37
+ */
38
+ export declare const NOT_A_WORKBENCH = "this project is not a workbench (set worktree.shape=\"workbench\" and worktree.repos[] in .indusk/config.json, or run `indusk init --workbench`).";
39
+ /**
40
+ * Where this repo's checkout lives, relative to the workbench root.
41
+ *
42
+ * The declared `path`, or the name when nothing is declared. One function so
43
+ * no caller re-derives it — a second copy would be the place the two answers
44
+ * quietly diverge.
45
+ */
46
+ export declare function repoDir(repo: WorkbenchRepo): string;
@@ -0,0 +1,100 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isCleanSegment, isUsableSegment } from "../path-segment.js";
4
+ function readRawConfig(root) {
5
+ const configPath = join(root, ".indusk", "config.json");
6
+ if (!existsSync(configPath))
7
+ return null;
8
+ try {
9
+ return JSON.parse(readFileSync(configPath, "utf-8"));
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ /**
16
+ * Read the declared repo set, newest shape first, singular reduced into it.
17
+ *
18
+ * **The reduction IS the backward-compatibility guarantee**, not a claim about
19
+ * one: `wrapped_repo: "numero"` reads as `[{ name: "numero" }]`, so every
20
+ * workbench written before this plan keeps working with no config edit and no
21
+ * migration step. The same shape as `phaseOrdinal` reducing to the phase
22
+ * number when a document has no test phase.
23
+ *
24
+ * Names are boundary values — sanitized before any caller can join them into a
25
+ * path, and first-occurrence-deduped so declared order survives. A malformed
26
+ * declaration degrades to fewer repos, never to a traversal.
27
+ *
28
+ * Returns `[]` for a project that declares nothing, which callers read as
29
+ * "not a workbench" exactly as they read a missing `wrapped_repo` today.
30
+ */
31
+ export function readWorkbenchRepos(root) {
32
+ const worktree = readRawConfig(root)?.worktree;
33
+ if (!worktree)
34
+ return [];
35
+ const declared = Array.isArray(worktree.repos)
36
+ ? worktree.repos
37
+ : typeof worktree.wrapped_repo === "string"
38
+ ? [{ name: worktree.wrapped_repo }]
39
+ : [];
40
+ const seen = new Set();
41
+ const repos = [];
42
+ for (const entry of declared) {
43
+ if (typeof entry !== "object" || entry === null)
44
+ continue;
45
+ const { name, remote } = entry;
46
+ if (typeof name !== "string" || !isCleanSegment(name))
47
+ continue;
48
+ if (seen.has(name))
49
+ continue;
50
+ seen.add(name);
51
+ // `path` and `worktrees` are joined into filesystem paths, so they are
52
+ // boundary values exactly as `name` is. A declared value that is not a
53
+ // clean segment is DROPPED, degrading to the default rather than
54
+ // resolving to a traversal.
55
+ const { path, worktrees } = entry;
56
+ const repo = { name };
57
+ if (typeof remote === "string" && remote.trim() !== "")
58
+ repo.remote = remote;
59
+ if (typeof path === "string" && isUsableSegment(path))
60
+ repo.path = path;
61
+ if (typeof worktrees === "string" && isUsableSegment(worktrees))
62
+ repo.worktrees = worktrees;
63
+ repos.push(repo);
64
+ }
65
+ return repos;
66
+ }
67
+ /** Whether this project is workbench-shaped at all. */
68
+ export function isWorkbench(root) {
69
+ return readRawConfig(root)?.worktree?.shape === "workbench";
70
+ }
71
+ /** The parent directory the sibling clones live in, or null when undeclared. */
72
+ export function readSiblingParent(root) {
73
+ const value = readRawConfig(root)?.worktree?.sibling_parent;
74
+ return typeof value === "string" && value.trim() !== "" ? value : null;
75
+ }
76
+ /**
77
+ * The refusal a command owes a project that is not a workbench.
78
+ *
79
+ * One string, because the *rule* it belongs to lives in bash. `worktree
80
+ * create`/`refresh`/`preflight` all resolve which repo the user meant inside
81
+ * `_resolve_workbench_repo`, and `worktreeCreate` deliberately passes `--repo`
82
+ * straight through rather than deciding first — so there is exactly one
83
+ * implementation of pick-one and it is the shell one.
84
+ *
85
+ * A TypeScript `resolveRepo` stood here with no caller, its docblock claiming
86
+ * to be "the rule the CLI surface needs in one place" while the CLI surface
87
+ * read the shell. Dead code that describes itself as authoritative is worse
88
+ * than dead code; what the TS lane actually shares with bash is this message.
89
+ */
90
+ export const NOT_A_WORKBENCH = 'this project is not a workbench (set worktree.shape="workbench" and worktree.repos[] in .indusk/config.json, or run `indusk init --workbench`).';
91
+ /**
92
+ * Where this repo's checkout lives, relative to the workbench root.
93
+ *
94
+ * The declared `path`, or the name when nothing is declared. One function so
95
+ * no caller re-derives it — a second copy would be the place the two answers
96
+ * quietly diverge.
97
+ */
98
+ export function repoDir(repo) {
99
+ return repo.path ?? repo.name;
100
+ }