@infinitedusky/indusk-mcp 1.44.0 → 1.44.2

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 (55) 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/middleware-build-manifest.js +3 -3
  13. package/admin/.next/server/pages/500.html +1 -1
  14. package/admin/.next/server/server-reference-manifest.js +1 -1
  15. package/admin/.next/server/server-reference-manifest.json +1 -1
  16. package/admin/.next/trace +1 -1
  17. package/admin/.next/trace-build +1 -1
  18. package/dist/bin/commands/agent.js +3 -3
  19. package/dist/bin/commands/extensions.d.ts +11 -0
  20. package/dist/bin/commands/extensions.js +10 -0
  21. package/dist/bin/commands/run.js +21 -0
  22. package/dist/bin/commands/update.js +18 -14
  23. package/dist/bin/commands/workbench.d.ts +0 -8
  24. package/dist/bin/commands/workbench.js +28 -10
  25. package/dist/bin/commands/worktree.d.ts +10 -0
  26. package/dist/bin/commands/worktree.js +12 -5
  27. package/dist/lib/cleanup/oversized.js +16 -4
  28. package/dist/lib/eval/evaluator-runner.d.ts +3 -2
  29. package/dist/lib/stray-state-audit.js +5 -2
  30. package/dist/lib/trajectory/state-ops.d.ts +0 -6
  31. package/dist/lib/trajectory/state-ops.js +0 -15
  32. package/dist/lib/verify/roots.js +5 -2
  33. package/dist/lib/worktree/layout.js +9 -3
  34. package/dist/lib/worktree/repos.d.ts +10 -1
  35. package/dist/lib/worktree/repos.js +13 -2
  36. package/dist/lib/worktree/shareable.d.ts +3 -1
  37. package/dist/lib/worktree/shareable.js +53 -5
  38. package/extensions/worktree/hooks/on_enable.sh +17 -4
  39. package/extensions/worktree/manifest.json +1 -0
  40. package/extensions/worktree/scripts/lib/workbench-helpers.sh +58 -4
  41. package/extensions/worktree/scripts/preflight.sh +10 -46
  42. package/extensions/worktree/scripts/refresh-worktree.sh +18 -10
  43. package/extensions/worktree/scripts/setup-worktree.sh +11 -3
  44. package/extensions/worktree/templates/worktree-config.template.json +1 -1
  45. package/hooks/_hook-paths.js +129 -17
  46. package/hooks/_impl-phases.js +88 -0
  47. package/hooks/check-gates.js +3 -70
  48. package/hooks/eval-trigger.js +31 -5
  49. package/hooks/gate-reminder.js +92 -187
  50. package/package.json +2 -2
  51. package/skills/cleanup.md +1 -1
  52. package/skills/retrospective.md +1 -0
  53. /package/admin/.next/static/{4Mqj7sc33tNR898aibslD → F21UYqHRn4_drSf3X1wvn}/_buildManifest.js +0 -0
  54. /package/admin/.next/static/{4Mqj7sc33tNR898aibslD → F21UYqHRn4_drSf3X1wvn}/_clientMiddlewareManifest.js +0 -0
  55. /package/admin/.next/static/{4Mqj7sc33tNR898aibslD → F21UYqHRn4_drSf3X1wvn}/_ssgManifest.js +0 -0
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { isUsableRelPath } from "../../lib/path-segment.js";
6
6
  import { isDanglingLink, linkTrunk, listWorkbenchSubdirs, worktreeOwner, } from "../../lib/worktree/layout.js";
7
7
  import { isWorkbench, readReposRoot, readWorkbenchRepos, repoDir, } from "../../lib/worktree/repos.js";
@@ -92,15 +92,19 @@ function refuseIfIgnoreCannotHold(projectRoot, repos, skip) {
92
92
  if (skip)
93
93
  return;
94
94
  // A declared layout names its worktree directories exactly, so no
95
- // deny-by-default rule is required and nothing to refuse.
96
- if (allLocationsDeclared(repos))
97
- return;
95
+ // deny-by-default rule is required and there is nothing to REFUSE. It
96
+ // still needs the machine-local rules, so the top-up runs first — before
97
+ // the early return, which used to strand every declared workbench without
98
+ // them.
99
+ const layoutDeclared = allLocationsDeclared(repos);
98
100
  // InDusk wrote this file, so InDusk may extend it. Every freshly created
99
101
  // workbench lands here — `init` scaffolds a `.gitignore`, so without this
100
102
  // the refusal fires on the product's own output.
101
- if (topUpManagedIgnore(projectRoot)) {
102
- console.info("Added the workbench rules to the InDusk-managed .gitignore.");
103
+ if (topUpManagedIgnore(projectRoot, { layoutDeclared })) {
104
+ console.info("Added the missing InDusk rules to the managed .gitignore.");
103
105
  }
106
+ if (layoutDeclared)
107
+ return;
104
108
  const gaps = missingIgnoreRules(projectRoot);
105
109
  if (gaps.length === 0)
106
110
  return;
@@ -126,6 +130,16 @@ function refuseIfIgnoreCannotHold(projectRoot, repos, skip) {
126
130
  * it. They are two jobs, and the four-way ternary that used to sit inline made
127
131
  * that hard to see.
128
132
  */
133
+ /**
134
+ * Where restore materializes a repo: at its DECLARED location — `path`, else
135
+ * name — which is where health, status, doppler and the update nudge all look.
136
+ * Cloning at the name put a second copy beside a repo declared at a `path` and
137
+ * linked the trunk at the wrong one (workbench-trust-fixes, F4). One function so
138
+ * the clone and the line that reports it cannot name different places.
139
+ */
140
+ function cloneTarget(repo, siblingParent) {
141
+ return join(siblingParent, repoDir(repo));
142
+ }
129
143
  export function restoreLine(repo, status, siblingParent) {
130
144
  // `linkTrunk` deliberately refuses to replace a real directory sitting at
131
145
  // the trunk path, so both `-unlinked` cases are correct behavior that the
@@ -138,10 +152,12 @@ export function restoreLine(repo, status, siblingParent) {
138
152
  return `${repo.name} — present in the workbench at ${repoDir(repo)}/`;
139
153
  case "nested-cloned":
140
154
  return `${repo.name} — cloned into the workbench at ${repoDir(repo)}/`;
155
+ // The path printed is the path used — `restoreOne` clones at the
156
+ // declared location, so this must name it, not the sibling parent.
141
157
  case "cloned":
142
- return `${repo.name} — cloned into ${siblingParent} and linked`;
158
+ return `${repo.name} — cloned into ${cloneTarget(repo, siblingParent)} and linked`;
143
159
  case "cloned-unlinked":
144
- return `${repo.name} — cloned into ${siblingParent}/${repo.name}, but ${unlinked}`;
160
+ return `${repo.name} — cloned into ${cloneTarget(repo, siblingParent)}, but ${unlinked}`;
145
161
  case "present-unlinked":
146
162
  return `${repo.name} — already present, but ${unlinked}`;
147
163
  case "present":
@@ -149,7 +165,7 @@ export function restoreLine(repo, status, siblingParent) {
149
165
  }
150
166
  }
151
167
  function restoreOne(repo, workbenchRoot, siblingParent) {
152
- const target = join(siblingParent, repo.name);
168
+ const target = cloneTarget(repo, siblingParent);
153
169
  // When the repo lives INSIDE the workbench at its trunk path — the nested
154
170
  // layout `repos_root: "."` exists to express — there is no link to make and
155
171
  // nothing wrong. Reporting that as "a real directory occupies …" describes
@@ -180,7 +196,9 @@ function restoreOne(repo, workbenchRoot, siblingParent) {
180
196
  // nowhere is not something anyone loses.
181
197
  if (isDanglingLink(target))
182
198
  rmSync(target);
183
- mkdirSync(siblingParent, { recursive: true });
199
+ // The declared path may have its own parent (`code/alpha`); make it, not
200
+ // just the sibling parent, or the clone fails on a directory nothing made.
201
+ mkdirSync(dirname(target), { recursive: true });
184
202
  const { ok, stderr } = git(["clone", "--quiet", repo.remote, target], siblingParent);
185
203
  if (!ok) {
186
204
  return {
@@ -42,6 +42,16 @@ export declare function resolveCreateTarget(repos: readonly {
42
42
  baseBranch?: string;
43
43
  };
44
44
  export declare function worktreeCreate(args: string[], projectRoot?: string): never;
45
+ /**
46
+ * Read `post_create` commands from the worktree config of THE repo being
47
+ * created — run in each new worktree after create.
48
+ *
49
+ * It read `repos[0]`'s config for every repo, so in a multi-repo workbench
50
+ * `worktree create beta <slug>` ran alpha's install steps in beta's worktree
51
+ * (workbench-trust-fixes, F6 / A15). The caller names the repo; with none
52
+ * named the bash script has already refused, so there is nothing to run.
53
+ */
54
+ export declare function readPostCreate(workbenchRoot: string, repo: string | undefined): string[];
45
55
  export declare function worktreeRefresh(slug: string): never;
46
56
  export declare function worktreePreflight(slug: string, baseBranch?: string): never;
47
57
  /**
@@ -175,7 +175,7 @@ function worktreeCreateResolved(slug, baseBranch, repo) {
175
175
  if (provisionWorktreeEnv(workbenchRoot, worktreeDir)) {
176
176
  console.info(` doppler: auto-provisioned env for ${slug}`);
177
177
  }
178
- for (const cmd of readPostCreate(workbenchRoot)) {
178
+ for (const cmd of readPostCreate(workbenchRoot, repo)) {
179
179
  console.info(` post_create: ${cmd}`);
180
180
  const pc = spawnSync(cmd, { cwd: worktreeDir, stdio: "inherit", shell: true });
181
181
  if (pc.status !== 0) {
@@ -192,9 +192,16 @@ function worktreeCreateResolved(slug, baseBranch, repo) {
192
192
  }
193
193
  process.exit(code);
194
194
  }
195
- /** Read `post_create` commands from the workbench's worktree config — run in each new worktree after create. */
196
- function readPostCreate(workbenchRoot) {
197
- const repo = readWorkbenchRepos(workbenchRoot)[0]?.name;
195
+ /**
196
+ * Read `post_create` commands from the worktree config of THE repo being
197
+ * created — run in each new worktree after create.
198
+ *
199
+ * It read `repos[0]`'s config for every repo, so in a multi-repo workbench
200
+ * `worktree create beta <slug>` ran alpha's install steps in beta's worktree
201
+ * (workbench-trust-fixes, F6 / A15). The caller names the repo; with none
202
+ * named the bash script has already refused, so there is nothing to run.
203
+ */
204
+ export function readPostCreate(workbenchRoot, repo) {
198
205
  if (!repo)
199
206
  return [];
200
207
  const p = join(workbenchRoot, ".indusk", "worktree-configs", `${repo}.json`);
@@ -210,7 +217,7 @@ function readPostCreate(workbenchRoot) {
210
217
  return [];
211
218
  }
212
219
  }
213
- /** Walk up from `start` to the nearest workbench root (`.indusk/config.json` with worktree.shape === "workbench"). */
220
+ /** Walk up from `start` to the nearest workbench root — the one `isWorkbench` rule, so a shapeless `repos[]` config counts too. */
214
221
  function resolveWorkbenchRoot(start) {
215
222
  let dir = resolve(start);
216
223
  for (let i = 0; i < 40; i++) {
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getCleanupConfig, resolveCapForPath } from "../config.js";
5
+ import { isWorkbench, readWorkbenchRepos, repoDir } from "../worktree/repos.js";
5
6
  /** Run git; swallow errors to an empty string (callers tolerate absence).
6
7
  * stderr is ignored — execFileSync forwards child stderr to the parent's
7
8
  * terminal by default, which leaked raw `git diff` usage spam (round-2 F1). */
@@ -86,15 +87,26 @@ function isGeneratedOrVendored(rel) {
86
87
  * history) it falls back to `baseRef` directly.
87
88
  */
88
89
  export function listOversizedChangedFiles(projectRoot, baseRef = "origin/main") {
89
- // Fail loudly on a non-git root. A workbench root (where .indusk/ lives) is
90
- // deliberately NOT a git repo — silently returning [] there would make the
91
- // ritual report "nothing to clean" on every workbench project (round-2 F1,
92
- // the same statePath/gitPath split the eval-rail hit in 1.31.7/1.31.12).
90
+ // Fail loudly on a non-git root — silently returning [] would make the
91
+ // ritual report "nothing to clean" for a tree it never examined (round-2
92
+ // F1, the same statePath/gitPath split the eval rail hit in 1.31.7/1.31.12).
93
93
  if (!git(projectRoot, ["rev-parse", "--show-toplevel"])) {
94
94
  throw new Error(`listOversizedChangedFiles: ${projectRoot} is not a git repo. ` +
95
95
  "In workbench mode pass the wrapped repo/worktree path (where the code lives), " +
96
96
  "not the workbench root (where .indusk/ lives).");
97
97
  }
98
+ // A versioned workbench root IS a git repo, so the guard above passes and
99
+ // the diff it would examine is the plan documents — the code lives in repos
100
+ // the workbench's git ignores. Returning [] there is "checked and clean" for
101
+ // code that was never seen. `verify` had the identical gap and refuses by
102
+ // declaration (`resolveVerifyRoots`); this is cleanup's maintained refusal
103
+ // (workbench-trust-fixes, F3).
104
+ if (isWorkbench(projectRoot)) {
105
+ const dirs = readWorkbenchRepos(projectRoot).map(repoDir);
106
+ throw new Error(`listOversizedChangedFiles: ${projectRoot} is a workbench — its code lives in ${dirs.join(", ") || "repos it does not declare"}, ` +
107
+ "not in this repository, so a diff here cannot contain it. Refusing rather than reporting nothing to clean. " +
108
+ "Run the cleanup scan inside the repository the plan's code lives in.");
109
+ }
98
110
  const mergeBase = resolveMergeBase(projectRoot, baseRef);
99
111
  const names = new Set();
100
112
  const ranges = [[`${mergeBase}..HEAD`], ["--cached"], []];
@@ -10,8 +10,9 @@ export interface EvaluatorRunOptions {
10
10
  /**
11
11
  * Where `.indusk/` lives — the InDusk state root. All state paths
12
12
  * (results.log, evaluator-session.json, config.json, highlights) hang off
13
- * this. In workbench mode this is the workbench root (NOT a git repo);
14
- * in single-repo mode it equals `gitRoot`.
13
+ * this. In workbench mode this is the workbench root (whose own git
14
+ * history is plan documents, never the code); in single-repo mode it
15
+ * equals `gitRoot`.
15
16
  *
16
17
  * CONTRACT: `hooks/eval-trigger.js` MUST pass this key as `projectRoot`.
17
18
  * It passed `statePath` between 1.31.7 and 1.31.11, leaving `projectRoot`
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { existsSync, readdirSync } from "node:fs";
23
23
  import { join } from "node:path";
24
- import { readWorkbenchRepos } from "./worktree/repos.js";
24
+ import { readWorkbenchRepos, repoDir } from "./worktree/repos.js";
25
25
  /**
26
26
  * Find stray `.indusk/` directories in a workbench-shaped project.
27
27
  *
@@ -45,7 +45,10 @@ export function findStrayState(workbenchRoot) {
45
45
  return [];
46
46
  const findings = [];
47
47
  for (const repo of repos) {
48
- const wrappedRepoPath = join(workbenchRoot, repo.name);
48
+ // At the declared location (`path`, else name): by name, a repo declared
49
+ // elsewhere was a directory that did not exist, silently skipped, and
50
+ // the audit reported clean (workbench-trust-fixes, F6 / A12).
51
+ const wrappedRepoPath = join(workbenchRoot, repoDir(repo));
49
52
  if (!existsSync(wrappedRepoPath))
50
53
  continue;
51
54
  const strayInsideWrapped = walkForStrayIndusk(wrappedRepoPath, 2);
@@ -35,12 +35,6 @@ export declare function computePhaseCloseBlockers(body: string, phase: number):
35
35
  row: TrajectoryRow;
36
36
  message: string;
37
37
  }[];
38
- /**
39
- * Convenience: nudge text for phase start. Returns a string listing rows
40
- * whose `Writable at` equals the phase and are not yet written, or null
41
- * if nothing needs authoring.
42
- */
43
- export declare function getPhaseStartNudge(body: string, phase: number): string | null;
44
38
  /**
45
39
  * Convenience: nudge text for near-phase-close. Returns a string listing
46
40
  * rows whose `Passes at` equals the phase but state is not yet `passing`
@@ -100,21 +100,6 @@ export function computePhaseCloseBlockers(body, phase) {
100
100
  message: ` [${row.id}] ${row.asserts} — state: ${row.state} (must be 'passing' or 'skipped' to close Phase ${phase})`,
101
101
  }));
102
102
  }
103
- /**
104
- * Convenience: nudge text for phase start. Returns a string listing rows
105
- * whose `Writable at` equals the phase and are not yet written, or null
106
- * if nothing needs authoring.
107
- */
108
- export function getPhaseStartNudge(body, phase) {
109
- const trajectory = parseTrajectory(body);
110
- if (!trajectory.present)
111
- return null;
112
- const rows = getRowsWritableAt(trajectory, phase);
113
- if (rows.length === 0)
114
- return null;
115
- const lines = rows.map((r) => ` [${r.id}] ${r.asserts}`);
116
- return `Phase ${phase} opens with these tests to author (commit as failing before implementation work):\n${lines.join("\n")}`;
117
- }
118
103
  /**
119
104
  * Convenience: nudge text for near-phase-close. Returns a string listing
120
105
  * rows whose `Passes at` equals the phase but state is not yet `passing`
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { isWorkbench, readWorkbenchRepos } from "../worktree/repos.js";
2
+ import { isWorkbench, readWorkbenchRepos, repoDir, resolveReposRoot } from "../worktree/repos.js";
3
3
  /**
4
4
  * Resolve both roots, or refuse.
5
5
  *
@@ -46,7 +46,10 @@ export function resolveVerifyRoots(planRoot) {
46
46
  error: `${planRoot} is a workbench: its plan documents and its code (${declared}) live in different repositories, ` +
47
47
  "and the verify ledger records a baseline from the plan repo that has no meaning in the code repo. " +
48
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.`,
49
+ // The checkout's real location — `repos_root` + declared `path` — not
50
+ // `<planRoot>/<name>`, which exists only on the flat legacy layout
51
+ // (workbench-trust-fixes, A13).
52
+ `Run verify inside ${join(resolveReposRoot(planRoot), repoDir(repos[0]))} instead; cross-repo verification is a named follow-on.`,
50
53
  };
51
54
  }
52
55
  export function isRefusal(r) {
@@ -16,8 +16,8 @@
16
16
  * one, and a trunk link that was not made reports as one that was.
17
17
  */
18
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";
19
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, } from "node:fs";
20
+ import { dirname, join, relative } from "node:path";
21
21
  /**
22
22
  * Root entries that are never worktrees.
23
23
  *
@@ -120,7 +120,12 @@ export function isDanglingLink(p) {
120
120
  */
121
121
  export function linkTrunk(workbenchRoot, name, target) {
122
122
  const link = join(workbenchRoot, name);
123
- const rel = relative(workbenchRoot, target);
123
+ // A symlink's relative target resolves from the link's OWN directory, not
124
+ // the workbench root — the two differ the moment `name` has more than one
125
+ // segment (`code/alpha`), and a link computed from the root pointed at
126
+ // itself. The parent must also exist before the link can (falsification
127
+ // A22; nothing in a flat layout could reach either).
128
+ const rel = relative(dirname(link), target);
124
129
  if (existsSync(link) || isDanglingLink(link)) {
125
130
  // A correct link needs nothing doing — report it as linked.
126
131
  if (isSymlink(link) && readlinkSync(link) === rel)
@@ -131,6 +136,7 @@ export function linkTrunk(workbenchRoot, name, target) {
131
136
  else
132
137
  return false;
133
138
  }
139
+ mkdirSync(dirname(link), { recursive: true });
134
140
  symlinkSync(rel, link);
135
141
  return true;
136
142
  }
@@ -17,7 +17,16 @@ export type { WorkbenchRepo } from "../config.js";
17
17
  * "not a workbench" exactly as they read a missing `wrapped_repo` today.
18
18
  */
19
19
  export declare function readWorkbenchRepos(root: string): WorkbenchRepo[];
20
- /** Whether this project is workbench-shaped at all. */
20
+ /**
21
+ * Whether this project is workbench-shaped at all.
22
+ *
23
+ * `shape: "workbench"` says so explicitly; declaring repos says so just as
24
+ * loudly. A config with `repos[]` and no `shape` flag used to read as a flat
25
+ * project here while every other reader (`readWorkbenchRepos`, the bash lane,
26
+ * the eval hook) treated it as a workbench — so `verify` would have judged the
27
+ * wrapper repo's plan documents as code (workbench-trust-fixes, F6 / A14). One
28
+ * rule, and every refusal inherits it through this function.
29
+ */
21
30
  export declare function isWorkbench(root: string): boolean;
22
31
  /** The parent directory the sibling clones live in, or null when undeclared. */
23
32
  export declare function readSiblingParent(root: string): string | null;
@@ -64,9 +64,20 @@ export function readWorkbenchRepos(root) {
64
64
  }
65
65
  return repos;
66
66
  }
67
- /** Whether this project is workbench-shaped at all. */
67
+ /**
68
+ * Whether this project is workbench-shaped at all.
69
+ *
70
+ * `shape: "workbench"` says so explicitly; declaring repos says so just as
71
+ * loudly. A config with `repos[]` and no `shape` flag used to read as a flat
72
+ * project here while every other reader (`readWorkbenchRepos`, the bash lane,
73
+ * the eval hook) treated it as a workbench — so `verify` would have judged the
74
+ * wrapper repo's plan documents as code (workbench-trust-fixes, F6 / A14). One
75
+ * rule, and every refusal inherits it through this function.
76
+ */
68
77
  export function isWorkbench(root) {
69
- return readRawConfig(root)?.worktree?.shape === "workbench";
78
+ if (readRawConfig(root)?.worktree?.shape === "workbench")
79
+ return true;
80
+ return readWorkbenchRepos(root).length > 0;
70
81
  }
71
82
  /** The parent directory the sibling clones live in, or null when undeclared. */
72
83
  export function readSiblingParent(root) {
@@ -12,7 +12,9 @@ export declare function allLocationsDeclared(repos: readonly WorkbenchRepo[]): b
12
12
  *
13
13
  * Returns true when it topped up.
14
14
  */
15
- export declare function topUpManagedIgnore(workbenchRoot: string): boolean;
15
+ export declare function topUpManagedIgnore(workbenchRoot: string, opts?: {
16
+ layoutDeclared?: boolean;
17
+ }): boolean;
16
18
  export interface ScaffoldResult {
17
19
  created: string[];
18
20
  kept: string[];
@@ -24,6 +24,14 @@ import { repoDir } from "./repos.js";
24
24
  */
25
25
  const ROOT_DENY_RULE = "/*/";
26
26
  const SECRETS_RULE = ".indusk/extensions/*/.env";
27
+ /**
28
+ * The worktree extension's editor schema, shipped beside the configs by its
29
+ * `on_enable` hook so each config's `$schema` resolves. Its contents track the
30
+ * installed package, so it is machine-local in the same sense as `.indusk/eval/`:
31
+ * real content, true only for this machine. Shared, two teammates on different
32
+ * versions rewrite it at each other on every enable.
33
+ */
34
+ const WORKTREE_SCHEMA_RULE = ".indusk/worktree-configs/config.schema.json";
27
35
  /**
28
36
  * Root-level DIRECTORY whitelist, not a blacklist.
29
37
  *
@@ -64,6 +72,7 @@ env/*.env
64
72
 
65
73
  # Machine-local state — real content, but true only for this machine.
66
74
  .indusk/eval/
75
+ ${WORKTREE_SCHEMA_RULE}
67
76
  .indusk/current.md.lock
68
77
  .indusk/sync-stamp
69
78
  .claude/settings.local.json
@@ -97,6 +106,7 @@ env/*.env
97
106
 
98
107
  # Machine-local state — real content, but true only for this machine.
99
108
  .indusk/eval/
109
+ ${WORKTREE_SCHEMA_RULE}
100
110
  .indusk/current.md.lock
101
111
  .indusk/sync-stamp
102
112
  .claude/settings.local.json
@@ -157,7 +167,29 @@ const GITATTRIBUTES = `# InDusk workbench context repo.
157
167
  * appending deny-by-default to it would invert what they meant.
158
168
  */
159
169
  const INDUSK_MANAGED_MARKER = "# InDusk managed";
160
- /** Rules a flat workbench needs, in the order they should be appended. */
170
+ /**
171
+ * Rules EVERY workbench needs, whatever its layout.
172
+ *
173
+ * Split out of the flat block because they were unreachable from a declared
174
+ * layout: `refuseIfIgnoreCannotHold` returns early there — a declared layout
175
+ * needs no deny-by-default rule — and the top-up sat behind that return, so a
176
+ * declared workbench scaffolded before a rule existed could never receive it
177
+ * and went on offering the file to its shared remote. A guard that returns
178
+ * early for one layout must not carry unrelated work behind it.
179
+ */
180
+ const MACHINE_LOCAL_RULES = `
181
+ # --- InDusk machine-local (generated) ---
182
+ # Real content, true only for this machine: package-owned files that track the
183
+ # installed version, and a lock.
184
+ ${WORKTREE_SCHEMA_RULE}
185
+ .indusk/current.md.lock
186
+ `;
187
+ /**
188
+ * Rules only a FLAT workbench needs, in the order they should be appended.
189
+ *
190
+ * Deny-by-default belongs here and nowhere else. Appending it to a declared
191
+ * layout's ignore file would invert a file this module refuses to rewrite.
192
+ */
161
193
  const FLAT_WORKBENCH_RULES = `
162
194
  # --- InDusk workbench (generated) ---
163
195
  # Worktree directories are created at runtime, so they cannot be named in
@@ -172,7 +204,6 @@ ${ROOT_DENY_RULE}
172
204
  ${SECRETS_RULE}*
173
205
  !${SECRETS_RULE}.example
174
206
  env/*.env
175
- .indusk/current.md.lock
176
207
  `;
177
208
  /**
178
209
  * Add the flat-workbench rules to an ignore file InDusk already owns.
@@ -185,16 +216,29 @@ env/*.env
185
216
  *
186
217
  * Returns true when it topped up.
187
218
  */
188
- export function topUpManagedIgnore(workbenchRoot) {
219
+ export function topUpManagedIgnore(workbenchRoot, opts = {}) {
189
220
  const path = join(workbenchRoot, ".gitignore");
190
221
  if (!existsSync(path))
191
222
  return false;
192
223
  const body = readFileSync(path, "utf-8");
193
224
  if (!body.includes(INDUSK_MANAGED_MARKER))
194
225
  return false; // a human's file — refuse elsewhere
195
- if (body.includes(ROOT_DENY_RULE))
226
+ // Per-RULE, not per-file. An earlier version returned early whenever the
227
+ // root rule was present, so every rule added after that one could never
228
+ // reach a workbench scaffolded before it — the file looked correct and was
229
+ // a release behind. Each rule the flat block carries is topped up on its
230
+ // own, in the block's own order.
231
+ // A declared layout gets the machine-local rules only; the flat block's
232
+ // deny-by-default rule is the one thing that must never reach it.
233
+ const blocks = opts.layoutDeclared
234
+ ? [MACHINE_LOCAL_RULES]
235
+ : [FLAT_WORKBENCH_RULES, MACHINE_LOCAL_RULES];
236
+ const missing = blocks
237
+ .flatMap((block) => block.split("\n"))
238
+ .filter((line) => line.trim() !== "" && !line.startsWith("#") && !body.includes(line));
239
+ if (missing.length === 0)
196
240
  return false; // already correct
197
- appendFileSync(path, FLAT_WORKBENCH_RULES);
241
+ appendFileSync(path, `${missing.join("\n")}\n`);
198
242
  return true;
199
243
  }
200
244
  /**
@@ -268,6 +312,10 @@ export function missingIgnoreRules(workbenchRoot) {
268
312
  "the root is not deny-by-default, so the next worktree directory gets committed",
269
313
  ],
270
314
  [SECRETS_RULE, "extension secrets are not ignored"],
315
+ [
316
+ WORKTREE_SCHEMA_RULE,
317
+ "the worktree config schema is package-owned and would be committed, so teammates on different versions rewrite it at each other",
318
+ ],
271
319
  ];
272
320
  return required.filter(([rule]) => !body.includes(rule)).map(([rule, why]) => `${rule} — ${why}`);
273
321
  }
@@ -9,9 +9,12 @@
9
9
  # `indusk update` re-copies if upstream changes)
10
10
  # 3. Registers `wt`, `wt:pm2`, `preflight` scripts in the workbench's
11
11
  # package.json (merges, doesn't duplicate)
12
- # 4. Materializes a starter `.indusk/worktree-configs/<wrapped_repo>.json`
13
- # from the template if absent — substituting WRAPPED_REPO_NAME with
14
- # the actual repo name from worktree.wrapped_repo config
12
+ # 4. Ships `.indusk/worktree-configs/config.schema.json` (the editor
13
+ # schema the starter config's `$schema` points at), refreshed on every
14
+ # enable; then materializes a starter
15
+ # `.indusk/worktree-configs/<wrapped_repo>.json` from the template if
16
+ # absent — substituting WRAPPED_REPO_NAME with the actual repo name
17
+ # from worktree.wrapped_repo config
15
18
  #
16
19
  # Requires:
17
20
  # - jq installed
@@ -76,8 +79,18 @@ else
76
79
  echo " WARN: no package.json at $PKG_JSON; skipping script registration"
77
80
  fi
78
81
 
79
- # 3. Materialize starter worktree-config if absent.
82
+ # 3. Ship the config schema beside the configs, refreshed on every enable.
83
+ # The starter config's `$schema` points at `./config.schema.json`; enabling
84
+ # an extension copies only its manifest into the project, so nothing else
85
+ # puts the schema anywhere a relative pointer could reach. A config that
86
+ # already exists is left alone below; the schema is always refreshed so a
87
+ # package upgrade carries schema changes into the project.
80
88
  CONFIG_DIR="$WORKBENCH_ROOT/.indusk/worktree-configs"
89
+ mkdir -p "$CONFIG_DIR"
90
+ cp "$EXT_DIR/config.schema.json" "$CONFIG_DIR/config.schema.json"
91
+ echo " schema: $CONFIG_DIR/config.schema.json"
92
+
93
+ # 4. Materialize starter worktree-config if absent.
81
94
  for REPO_NAME in ${DECLARED_REPOS+"${DECLARED_REPOS[@]}"}; do
82
95
  CONFIG_FILE="$CONFIG_DIR/${REPO_NAME}.json"
83
96
  if [[ ! -f "$CONFIG_FILE" ]]; then
@@ -7,6 +7,7 @@
7
7
  },
8
8
  "hooks": {
9
9
  "on_enable": "indusk worktree _on-enable",
10
+ "on_update": "indusk worktree _on-enable",
10
11
  "on_disable": "echo 'worktree extension disabled — scripts in <workbench>/scripts/worktree/ + package.json entries are left in place for manual cleanup'"
11
12
  }
12
13
  }
@@ -268,6 +268,57 @@ _read_workbench_repo_paths() {
268
268
  ' "$config" 2>/dev/null || echo ""
269
269
  }
270
270
 
271
+ # The worktrees directory <repo> DECLARES, relative to the workbench root, or
272
+ # empty when it declares none (worktrees then live at the root). One reader so
273
+ # setup-worktree.sh run directly agrees with the TS wrapper that passes
274
+ # --worktrees-dir (workbench-trust-fixes, F5).
275
+ _wt_declared_worktrees_dir() {
276
+ local repo="$1" name dir
277
+ while IFS=$'\t' read -r name dir; do
278
+ [[ "$name" == "$repo" ]] || continue
279
+ echo "$dir"
280
+ return 0
281
+ done < <(_read_workbench_worktree_dirs)
282
+ echo ""
283
+ }
284
+
285
+ # Every directory that could be a worktree: the workbench root's entries minus
286
+ # the reserved names, plus every entry of every DECLARED worktrees dir. Callers
287
+ # that need "all worktrees" (refresh --all) iterate this rather than scanning
288
+ # the root alone, which could not see a declared worktrees dir at all.
289
+ _wt_list_worktree_dirs() {
290
+ local entry name dir _name path
291
+ # Root entries that are declared LOCATIONS rather than worktrees: a declared
292
+ # worktrees dir itself, and the first segment of a declared repo path
293
+ # (`code` for `code/alpha`). Listing them as candidates produced a SKIP line
294
+ # per layout element on every `refresh --all` (falsification A23).
295
+ local -a declared_dirs=()
296
+ while IFS=$'\t' read -r _name dir; do
297
+ [[ -n "$dir" ]] && declared_dirs+=("${dir%%/*}")
298
+ done < <(_read_workbench_worktree_dirs)
299
+ while IFS=$'\t' read -r _name path; do
300
+ [[ -n "$path" ]] && declared_dirs+=("${path%%/*}")
301
+ done < <(_read_workbench_repo_paths)
302
+ for entry in "$WORKBENCH_ROOT"/*; do
303
+ [[ -d "$entry" ]] || continue
304
+ name="$(basename "$entry")"
305
+ _wt_is_reserved_name "$name" && continue
306
+ local skip=0 d
307
+ for d in "${declared_dirs[@]+"${declared_dirs[@]}"}"; do
308
+ [[ "$name" == "$d" ]] && skip=1
309
+ done
310
+ [[ "$skip" -eq 1 ]] && continue
311
+ echo "$entry"
312
+ done
313
+ while IFS=$'\t' read -r _name dir; do
314
+ [[ -n "$dir" && -d "$WORKBENCH_ROOT/$dir" ]] || continue
315
+ for entry in "$WORKBENCH_ROOT/$dir"/*; do
316
+ [[ -d "$entry" ]] || continue
317
+ echo "$entry"
318
+ done
319
+ done < <(_read_workbench_worktree_dirs)
320
+ }
321
+
271
322
  # Root entries that are never worktrees. ONE definition for the bash lane —
272
323
  # wt.sh and wt-pm2.sh each carried a copy, and they had already drifted from
273
324
  # RESERVED_ROOT_DIRS in src/lib/worktree/layout.ts (`docs` was missing from
@@ -283,7 +334,7 @@ _wt_is_reserved_name() {
283
334
  # 1. The declared workbench-side location: <workbench>/<path-or-name>,
284
335
  # which is the trunk symlink in a linked layout or the real directory in
285
336
  # a nested one (`repos_root: "."`).
286
- # 2. Else <repos_root>/<name> — the checkout itself, for a workbench whose
337
+ # 2. Else <repos_root>/<path-or-name> — the checkout itself, for a workbench whose
287
338
  # trunk link was never made. Routing there is what "the config says where
288
339
  # things are" means; failing because a symlink is missing is scan-luck.
289
340
  _wt_resolve_trunk_dir() {
@@ -301,11 +352,14 @@ _wt_resolve_trunk_dir() {
301
352
  fi
302
353
  local repos_root
303
354
  repos_root="$(_read_repos_root)" || return 1
304
- if [[ -d "$repos_root/$repo" ]]; then
305
- echo "$repos_root/$repo"
355
+ # The checkout itself, at its DECLARED location under repos_root — not by
356
+ # name, which missed every repo declared at a `path` on a sibling layout
357
+ # (workbench-trust-fixes, F5).
358
+ if [[ -d "$repos_root/${declared_path:-$repo}" ]]; then
359
+ echo "$repos_root/${declared_path:-$repo}"
306
360
  return 0
307
361
  fi
308
- echo "Error: trunk for repo '$repo' not found — looked at $side and $repos_root/$repo. Run \`indusk workbench restore\`." >&2
362
+ echo "Error: trunk for repo '$repo' not found — looked at $side and $repos_root/${declared_path:-$repo}. Run \`indusk workbench restore\`." >&2
309
363
  return 1
310
364
  }
311
365