@infinitedusky/indusk-mcp 1.36.2 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/admin/.next/BUILD_ID +1 -1
  2. package/admin/.next/build-manifest.json +3 -3
  3. package/admin/.next/fallback-build-manifest.json +3 -3
  4. package/admin/.next/prerender-manifest.json +3 -3
  5. package/admin/.next/server/app/_global-error.html +1 -1
  6. package/admin/.next/server/app/_global-error.rsc +1 -1
  7. package/admin/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/admin/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/admin/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/admin/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/admin/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/admin/.next/server/chunks/ssr/[root-of-the-server]__0p.51_4._.js.map +1 -1
  13. package/admin/.next/server/middleware-build-manifest.js +3 -3
  14. package/admin/.next/server/pages/500.html +1 -1
  15. package/admin/.next/server/server-reference-manifest.js +1 -1
  16. package/admin/.next/server/server-reference-manifest.json +1 -1
  17. package/admin/.next/trace +1 -1
  18. package/admin/.next/trace-build +1 -1
  19. package/dist/bin/cli.js +42 -4
  20. package/dist/bin/commands/init.js +42 -10
  21. package/dist/bin/commands/telemetry.js +7 -1
  22. package/dist/bin/commands/update.js +104 -2
  23. package/dist/bin/commands/workbench.d.ts +76 -0
  24. package/dist/bin/commands/workbench.js +438 -0
  25. package/dist/bin/commands/worktree.d.ts +22 -6
  26. package/dist/bin/commands/worktree.js +154 -111
  27. package/dist/lib/config.d.ts +59 -0
  28. package/dist/lib/hook-migration.d.ts +42 -0
  29. package/dist/lib/hook-migration.js +94 -0
  30. package/dist/lib/path-segment.d.ts +23 -0
  31. package/dist/lib/path-segment.js +41 -0
  32. package/dist/lib/plan-parser.js +5 -13
  33. package/dist/lib/stray-state-audit.d.ts +3 -2
  34. package/dist/lib/stray-state-audit.js +14 -20
  35. package/dist/lib/telemetry/daemon.d.ts +18 -0
  36. package/dist/lib/telemetry/daemon.js +18 -2
  37. package/dist/lib/verify/git.d.ts +11 -4
  38. package/dist/lib/verify/git.js +11 -4
  39. package/dist/lib/verify/roots.d.ts +40 -0
  40. package/dist/lib/verify/roots.js +54 -0
  41. package/dist/lib/verify/verify.js +17 -2
  42. package/dist/lib/worktree/layout.d.ts +56 -0
  43. package/dist/lib/worktree/layout.js +136 -0
  44. package/dist/lib/worktree/repos.d.ts +46 -0
  45. package/dist/lib/worktree/repos.js +100 -0
  46. package/dist/lib/worktree/shareable.d.ts +47 -0
  47. package/dist/lib/worktree/shareable.js +301 -0
  48. package/dist/lib/worktree/sync.d.ts +62 -0
  49. package/dist/lib/worktree/sync.js +174 -0
  50. package/extensions/worktree/hooks/on_enable.sh +17 -10
  51. package/extensions/worktree/scripts/lib/workbench-helpers.sh +84 -0
  52. package/extensions/worktree/scripts/preflight.sh +4 -8
  53. package/extensions/worktree/scripts/refresh-worktree.sh +4 -8
  54. package/extensions/worktree/scripts/setup-worktree.sh +40 -16
  55. package/extensions/worktree/skill.md +8 -6
  56. package/hooks/_hook-paths.js +78 -15
  57. package/hooks/workbench-sync.js +119 -0
  58. package/package.json +109 -109
  59. package/skills/catchup.md +17 -0
  60. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_buildManifest.js +0 -0
  61. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_clientMiddlewareManifest.js +0 -0
  62. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_ssgManifest.js +0 -0
@@ -0,0 +1,47 @@
1
+ import { type WorkbenchRepo } from "./repos.js";
2
+ /** Every repo places its worktrees somewhere nameable. */
3
+ export declare function allLocationsDeclared(repos: readonly WorkbenchRepo[]): boolean;
4
+ /**
5
+ * Add the flat-workbench rules to an ignore file InDusk already owns.
6
+ *
7
+ * Found by running `indusk setup` and then `workbench sync`: init scaffolds a
8
+ * `.gitignore`, so EVERY freshly created workbench tripped the refusal and
9
+ * could not sync at all. A guard that blocks the product's own output is not a
10
+ * guard, it is a bug — and no fixture caught it because fixtures ship no
11
+ * ignore file.
12
+ *
13
+ * Returns true when it topped up.
14
+ */
15
+ export declare function topUpManagedIgnore(workbenchRoot: string): boolean;
16
+ export interface ScaffoldResult {
17
+ created: string[];
18
+ kept: string[];
19
+ }
20
+ /**
21
+ * Ensure a workbench root carries the files that make it shareable.
22
+ *
23
+ * Idempotent and non-destructive: an existing file is reported as kept, never
24
+ * rewritten.
25
+ */
26
+ export declare function ensureShareableScaffolding(workbenchRoot: string, repos?: readonly WorkbenchRepo[]): ScaffoldResult;
27
+ /**
28
+ * Does this workbench's ignore file actually cover the machine-specific set?
29
+ *
30
+ * Used by the nudge path rather than to rewrite anything — a workbench with a
31
+ * hand-tuned `.gitignore` is the normal case, and silently replacing it would
32
+ * destroy a decision somebody made.
33
+ */
34
+ export declare function missingIgnoreRules(workbenchRoot: string): string[];
35
+ /**
36
+ * Drop from the index anything the ignore rules now cover.
37
+ *
38
+ * Scaffolding `.gitignore` into an EXISTING repo does not untrack what is
39
+ * already tracked — git ignores only untracked paths. A workbench that was
40
+ * git-initialized before these rules existed therefore keeps publishing its
41
+ * trunk symlinks and secrets to the shared remote while the ignore file sits
42
+ * there looking correct. That gap is invisible: `git status` is clean.
43
+ *
44
+ * `--cached` touches the INDEX only. Every file stays on disk, and the change
45
+ * is a commit away from being reverted.
46
+ */
47
+ export declare function untrackNowIgnored(workbenchRoot: string): string[];
@@ -0,0 +1,301 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { repoDir } from "./repos.js";
5
+ /**
6
+ * The two files that make a workbench root safe to share.
7
+ *
8
+ * Derived from the POC's hand-written pair, which had already survived
9
+ * months of real use — including two rules nobody would invent from first
10
+ * principles (see the comments in the generated file). Productizing a working
11
+ * artifact beats authoring a fresh one and discovering its gaps in a client
12
+ * repo.
13
+ *
14
+ * Both are scaffolded ONLY when absent. A workbench that has tuned its own
15
+ * ignore rules must never have them overwritten by an update — the whole point
16
+ * of the whitelist is that a human decided what may travel.
17
+ */
18
+ /**
19
+ * The two rules a FLAT workbench cannot do without, named once.
20
+ *
21
+ * Both the generated file and the check that looks for them read these — a
22
+ * restated copy is where the generator and its checker drift, which has
23
+ * already happened twice in this plan.
24
+ */
25
+ const ROOT_DENY_RULE = "/*/";
26
+ const SECRETS_RULE = ".indusk/extensions/*/.env";
27
+ /**
28
+ * Root-level DIRECTORY whitelist, not a blacklist.
29
+ *
30
+ * Worktree directories appear at the workbench root with names invented at
31
+ * runtime (`indusk worktree create <slug>`), so a deny-list is always one
32
+ * command behind: the next worktree is a whole checkout of someone else's repo
33
+ * committed into your context remote. Deny-by-default is the only shape that
34
+ * is correct for a name nobody has thought of yet.
35
+ */
36
+ const GITIGNORE_HEADER = `# InDusk workbench context repo.
37
+ #
38
+ # ROOT DIRECTORY whitelist. Every root DIRECTORY is ignored unless opted in
39
+ # below — worktree directories are created at runtime by \`indusk worktree
40
+ # create <slug>\`, so a deny-list is always one command behind, and what it
41
+ # misses is a whole checkout of another repo committed into the context repo.
42
+ #
43
+ # Root FILES are deliberately NOT denied. An earlier version used \`/*\` to
44
+ # also catch trunk symlinks (git stores those as blobs, so \`/*/\` misses them)
45
+ # and thereby untracked every root file a real workbench had — .mcp.json,
46
+ # biome.json, instrumentation.ts. Those are shared context. Symlinks are named
47
+ # explicitly instead, in the generated block below.
48
+ /*/
49
+ !/.indusk/
50
+ !/.claude/
51
+ !/env/
52
+ !/scripts/
53
+ !/docs/
54
+
55
+ # Secrets — transferred out-of-band, never committed.
56
+ # NOTE the trailing glob: extensions also emit .env.local / .env.production
57
+ # carrying live keys. A bare \`.env\` pattern does not match those.
58
+ .indusk/extensions/*/.env*
59
+ !.indusk/extensions/*/.env.example
60
+ env/*.env
61
+ .env
62
+ .env.*
63
+ !.env.example
64
+
65
+ # Machine-local state — real content, but true only for this machine.
66
+ .indusk/eval/
67
+ .indusk/current.md.lock
68
+ .indusk/sync-stamp
69
+ .claude/settings.local.json
70
+ node_modules/
71
+
72
+ # Docs build artifacts
73
+ docs/src/.vitepress/cache/
74
+ docs/src/.vitepress/dist/
75
+ `;
76
+ /**
77
+ * The ignore file for a workbench that DECLARES where its worktrees go.
78
+ *
79
+ * No deny-by-default rule, because none is needed: every worktree lives inside
80
+ * a directory the config names, so each gets one precise line. That precision
81
+ * is what makes this appendable to a `.gitignore` somebody else wrote — it
82
+ * adds rules rather than inverting the file's meaning.
83
+ */
84
+ const GITIGNORE_DECLARED_HEADER = `# InDusk workbench context repo.
85
+ #
86
+ # This workbench DECLARES where each repo's worktrees live, so the trunks and
87
+ # worktree directories are named exactly below. No deny-by-default rule is
88
+ # needed, and none is imposed.
89
+
90
+ # Secrets — transferred out-of-band, never committed.
91
+ .indusk/extensions/*/.env*
92
+ !.indusk/extensions/*/.env.example
93
+ env/*.env
94
+ .env
95
+ .env.*
96
+ !.env.example
97
+
98
+ # Machine-local state — real content, but true only for this machine.
99
+ .indusk/eval/
100
+ .indusk/current.md.lock
101
+ .indusk/sync-stamp
102
+ .claude/settings.local.json
103
+ node_modules/
104
+ `;
105
+ /** Marker for the generated trunk-symlink block, so it can be extended later. */
106
+ const TRUNK_BLOCK_MARKER = "# --- InDusk trunk symlinks (generated) ---";
107
+ /**
108
+ * The trunk symlinks, named one per declared repo.
109
+ *
110
+ * Git stores a symlink as a blob, so the directory-only rule above (slash-star-slash)
111
+ * cannot see them. Naming them is more precise than broadening the rule — and precision
112
+ * is what keeps real root files tracked.
113
+ */
114
+ function trunkBlock(repos) {
115
+ if (repos.length === 0)
116
+ return "";
117
+ const lines = [TRUNK_BLOCK_MARKER];
118
+ for (const repo of repos) {
119
+ lines.push(`/${repoDir(repo)}`);
120
+ // A DECLARED worktrees directory can be named exactly — which is the
121
+ // whole reason declaring it removes the need to deny the root by
122
+ // default. Worktree names are invented at runtime and can never be
123
+ // listed in advance; the directory containing them is knowable.
124
+ if (repo.worktrees)
125
+ lines.push(`/${repo.worktrees}/`);
126
+ }
127
+ lines.push("");
128
+ return lines.join("\n");
129
+ }
130
+ /** Every repo places its worktrees somewhere nameable. */
131
+ export function allLocationsDeclared(repos) {
132
+ return repos.length > 0 && repos.every((r) => typeof r.worktrees === "string");
133
+ }
134
+ /**
135
+ * `merge=union` on the append-shaped coordination files.
136
+ *
137
+ * These are logs, not documents: two machines appending different lines both
138
+ * mean it, and a conflict marker in the middle of `current.md` blocks every
139
+ * agent on both sides. Union keeps both. It is deliberately NOT applied to
140
+ * plan documents, where a blind union would interleave prose.
141
+ */
142
+ const GITATTRIBUTES = `# InDusk workbench context repo.
143
+ #
144
+ # Append-shaped coordination files: two machines appending different lines both
145
+ # mean it, so keep both rather than raising a conflict nobody can resolve
146
+ # usefully. Deliberately NOT applied to plan documents — a blind union there
147
+ # would interleave prose.
148
+ .indusk/current.md merge=union
149
+ .indusk/highlights.jsonl merge=union
150
+ .indusk/highlights-processed.jsonl merge=union
151
+ `;
152
+ /**
153
+ * Marker `indusk init` writes into the ignore files it owns.
154
+ *
155
+ * The line between "extend this" and "refuse to touch this". InDusk wrote the
156
+ * file, so InDusk may add rules to it; a file a human wrote is a decision, and
157
+ * appending deny-by-default to it would invert what they meant.
158
+ */
159
+ const INDUSK_MANAGED_MARKER = "# InDusk managed";
160
+ /** Rules a flat workbench needs, in the order they should be appended. */
161
+ const FLAT_WORKBENCH_RULES = `
162
+ # --- InDusk workbench (generated) ---
163
+ # Worktree directories are created at runtime, so they cannot be named in
164
+ # advance — the root is deny-by-default and shared directories are opted back
165
+ # in below. Root FILES are untouched.
166
+ ${ROOT_DENY_RULE}
167
+ !/.indusk/
168
+ !/.claude/
169
+ !/env/
170
+ !/scripts/
171
+ !/docs/
172
+ ${SECRETS_RULE}*
173
+ !${SECRETS_RULE}.example
174
+ env/*.env
175
+ .indusk/current.md.lock
176
+ `;
177
+ /**
178
+ * Add the flat-workbench rules to an ignore file InDusk already owns.
179
+ *
180
+ * Found by running `indusk setup` and then `workbench sync`: init scaffolds a
181
+ * `.gitignore`, so EVERY freshly created workbench tripped the refusal and
182
+ * could not sync at all. A guard that blocks the product's own output is not a
183
+ * guard, it is a bug — and no fixture caught it because fixtures ship no
184
+ * ignore file.
185
+ *
186
+ * Returns true when it topped up.
187
+ */
188
+ export function topUpManagedIgnore(workbenchRoot) {
189
+ const path = join(workbenchRoot, ".gitignore");
190
+ if (!existsSync(path))
191
+ return false;
192
+ const body = readFileSync(path, "utf-8");
193
+ if (!body.includes(INDUSK_MANAGED_MARKER))
194
+ return false; // a human's file — refuse elsewhere
195
+ if (body.includes(ROOT_DENY_RULE))
196
+ return false; // already correct
197
+ appendFileSync(path, FLAT_WORKBENCH_RULES);
198
+ return true;
199
+ }
200
+ /**
201
+ * Ensure a workbench root carries the files that make it shareable.
202
+ *
203
+ * Idempotent and non-destructive: an existing file is reported as kept, never
204
+ * rewritten.
205
+ */
206
+ export function ensureShareableScaffolding(workbenchRoot, repos = []) {
207
+ const created = [];
208
+ const kept = [];
209
+ const ignorePath = join(workbenchRoot, ".gitignore");
210
+ if (!existsSync(ignorePath)) {
211
+ // Deny-by-default only where it is NEEDED. A workbench that declares
212
+ // where its worktrees go can name them precisely, and a whole-root denial
213
+ // would be an opinion it does not require.
214
+ writeFileSync(ignorePath, allLocationsDeclared(repos)
215
+ ? `${GITIGNORE_DECLARED_HEADER}\n${trunkBlock(repos)}`
216
+ : `${GITIGNORE_HEADER}\n${trunkBlock(repos)}`);
217
+ created.push(".gitignore");
218
+ }
219
+ else {
220
+ // TOP UP rather than rewrite. An existing ignore file is a decision
221
+ // somebody made; a repo declared later still needs its symlink named, so
222
+ // only the missing lines are appended.
223
+ const body = readFileSync(ignorePath, "utf-8");
224
+ const missing = repos.filter((r) => !new RegExp(`^/${repoDir(r)}(/)?$`, "m").test(body));
225
+ if (missing.length > 0) {
226
+ const block = body.includes(TRUNK_BLOCK_MARKER)
227
+ ? `${trunkBlock(missing).split("\n").slice(1).join("\n")}`
228
+ : `\n${trunkBlock(missing)}`;
229
+ appendFileSync(ignorePath, block);
230
+ kept.push(`.gitignore (+${missing.length} trunk rule${missing.length === 1 ? "" : "s"})`);
231
+ }
232
+ else {
233
+ kept.push(".gitignore");
234
+ }
235
+ }
236
+ const attrPath = join(workbenchRoot, ".gitattributes");
237
+ if (existsSync(attrPath)) {
238
+ kept.push(".gitattributes");
239
+ }
240
+ else {
241
+ writeFileSync(attrPath, GITATTRIBUTES);
242
+ created.push(".gitattributes");
243
+ }
244
+ return { created, kept };
245
+ }
246
+ /**
247
+ * Does this workbench's ignore file actually cover the machine-specific set?
248
+ *
249
+ * Used by the nudge path rather than to rewrite anything — a workbench with a
250
+ * hand-tuned `.gitignore` is the normal case, and silently replacing it would
251
+ * destroy a decision somebody made.
252
+ */
253
+ export function missingIgnoreRules(workbenchRoot) {
254
+ const path = join(workbenchRoot, ".gitignore");
255
+ if (!existsSync(path))
256
+ return [".gitignore is absent entirely"];
257
+ const body = readFileSync(path, "utf-8");
258
+ // DERIVED from what the generator actually emits, never restated.
259
+ //
260
+ // These drifted twice: the check asked for one spelling of the root rule
261
+ // while the header wrote another, so a correctly-scaffolded workbench
262
+ // reported a gap and a real gap could have gone unreported. Both times the
263
+ // "fix" was a second copy of a string. Reading the rule out of the constant
264
+ // that writes it is the only version that cannot drift.
265
+ const required = [
266
+ [
267
+ ROOT_DENY_RULE,
268
+ "the root is not deny-by-default, so the next worktree directory gets committed",
269
+ ],
270
+ [SECRETS_RULE, "extension secrets are not ignored"],
271
+ ];
272
+ return required.filter(([rule]) => !body.includes(rule)).map(([rule, why]) => `${rule} — ${why}`);
273
+ }
274
+ /**
275
+ * Drop from the index anything the ignore rules now cover.
276
+ *
277
+ * Scaffolding `.gitignore` into an EXISTING repo does not untrack what is
278
+ * already tracked — git ignores only untracked paths. A workbench that was
279
+ * git-initialized before these rules existed therefore keeps publishing its
280
+ * trunk symlinks and secrets to the shared remote while the ignore file sits
281
+ * there looking correct. That gap is invisible: `git status` is clean.
282
+ *
283
+ * `--cached` touches the INDEX only. Every file stays on disk, and the change
284
+ * is a commit away from being reverted.
285
+ */
286
+ export function untrackNowIgnored(workbenchRoot) {
287
+ const listed = spawnSync("git", ["ls-files", "-i", "-c", "--exclude-standard"], {
288
+ cwd: workbenchRoot,
289
+ encoding: "utf-8",
290
+ });
291
+ if (listed.status !== 0)
292
+ return [];
293
+ const paths = (listed.stdout ?? "").trim().split("\n").filter(Boolean);
294
+ if (paths.length === 0)
295
+ return [];
296
+ const removed = spawnSync("git", ["rm", "-r", "--cached", "--quiet", "--", ...paths], {
297
+ cwd: workbenchRoot,
298
+ encoding: "utf-8",
299
+ });
300
+ return removed.status === 0 ? paths : [];
301
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The workbench sync mechanism: commit → pull → push, blindly resolved.
3
+ *
4
+ * ONE function, called by every trigger. The ADR left the trigger open
5
+ * (watcher vs. mutation chokepoints); separating mechanism from trigger means
6
+ * that choice can change later without rewriting any of this. Today it is
7
+ * driven by the `PostToolUse` hook and by `/catchup`; a `--watch` daemon can
8
+ * call the same entry point when the gap is felt.
9
+ *
10
+ * **Commit BEFORE pull**, despite the brief's "pull before everything".
11
+ * The safety property the brief actually names is *both sides are always
12
+ * committed before any merge* — that is what makes blind resolution
13
+ * recoverable, because nothing that was lost is unreachable from `git log`.
14
+ * Pulling into a dirty tree would either refuse (blocking an agent) or stash
15
+ * (inventing a third state nobody can see). Committing first satisfies the
16
+ * real invariant and keeps the tree clean for the merge.
17
+ *
18
+ * Nothing here ever blocks. An unreachable remote leaves the work committed
19
+ * locally and exits 0 — someone else's outage must never become your inability
20
+ * to work.
21
+ */
22
+ export interface SyncResult {
23
+ committed: boolean;
24
+ pulled: "ok" | "skipped" | "failed";
25
+ pushed: "ok" | "skipped" | "failed";
26
+ /** Human-facing lines, in order. */
27
+ notes: string[];
28
+ /** True when a remote is configured at all. */
29
+ hasRemote: boolean;
30
+ }
31
+ export declare function isGitRepo(root: string): boolean;
32
+ /** Initialize the context repo. Local commits work with no remote at all. */
33
+ export declare function ensureContextRepo(root: string): boolean;
34
+ export declare function syncWorkbench(root: string, at?: Date): SyncResult;
35
+ /**
36
+ * Per-repo publish state — the two-clock skew, made visible.
37
+ *
38
+ * A workbench's plan documents auto-sync in seconds while the code they
39
+ * describe pushes when its author decides. A developer who pulls a phase
40
+ * marked complete has no way to tell whether the commits behind it exist
41
+ * anywhere but the author's laptop. "Done" and "present" are two questions on
42
+ * two clocks, and the plan is the faster one.
43
+ */
44
+ export interface RepoPublishState {
45
+ name: string;
46
+ present: boolean;
47
+ ahead: number;
48
+ hasRemote: boolean;
49
+ /**
50
+ * Whether this branch exists on the remote at all.
51
+ *
52
+ * Distinct from `ahead === 0`. A branch that was never pushed has no
53
+ * remote-tracking ref, so no count is meaningful — and calling that "in
54
+ * sync" is the inversion this field exists to prevent.
55
+ *
56
+ * REQUIRED, not optional: every return path sets it, so an optional type
57
+ * would describe a state the code cannot produce while forcing readers into
58
+ * `=== false` comparisons to stay correct about it.
59
+ */
60
+ published: boolean;
61
+ }
62
+ export declare function repoPublishState(siblingParent: string, name: string): RepoPublishState;
@@ -0,0 +1,174 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ function git(cwd, args) {
5
+ const r = spawnSync("git", args, {
6
+ cwd,
7
+ encoding: "utf-8",
8
+ env: {
9
+ ...process.env,
10
+ // The loop authors commits, not a person. Without this a machine with
11
+ // no git identity fails to commit and the work is silently uncommitted.
12
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "indusk",
13
+ GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "indusk@localhost",
14
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "indusk",
15
+ GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? "indusk@localhost",
16
+ },
17
+ });
18
+ return { ok: r.status === 0, out: r.stdout ?? "", err: r.stderr ?? "" };
19
+ }
20
+ export function isGitRepo(root) {
21
+ return existsSync(join(root, ".git"));
22
+ }
23
+ /** Initialize the context repo. Local commits work with no remote at all. */
24
+ export function ensureContextRepo(root) {
25
+ if (isGitRepo(root))
26
+ return false;
27
+ git(root, ["init", "--quiet", "--initial-branch=main"]);
28
+ return true;
29
+ }
30
+ function currentBranch(root) {
31
+ const r = git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
32
+ const name = r.out.trim();
33
+ return name && name !== "HEAD" ? name : "main";
34
+ }
35
+ function remoteName(root) {
36
+ const r = git(root, ["remote"]);
37
+ const first = r.out.trim().split("\n").filter(Boolean)[0];
38
+ return first ?? null;
39
+ }
40
+ /**
41
+ * A timestamp, not a narrative.
42
+ *
43
+ * The workbench history is a sync log — the *content* is the record, and the
44
+ * message only has to be recognizable and unprompted. Anything that tried to
45
+ * summarize would be a lie invented by a loop that does not know why the file
46
+ * changed.
47
+ */
48
+ function syncMessage(at) {
49
+ return `sync ${at.toISOString()}`;
50
+ }
51
+ export function syncWorkbench(root, at = new Date()) {
52
+ const notes = [];
53
+ if (ensureContextRepo(root))
54
+ notes.push("Initialized the workbench context repo (no remote yet).");
55
+ const branch = currentBranch(root);
56
+ const remote = remoteName(root);
57
+ const result = {
58
+ committed: false,
59
+ pulled: "skipped",
60
+ pushed: "skipped",
61
+ notes,
62
+ hasRemote: remote !== null,
63
+ };
64
+ // --- 1. COMMIT. Always first, always local, never blocked. ---
65
+ const dirty = git(root, ["status", "--porcelain"]).out.trim() !== "";
66
+ if (dirty) {
67
+ git(root, ["add", "-A"]);
68
+ const c = git(root, ["commit", "--quiet", "-m", syncMessage(at)]);
69
+ result.committed = c.ok;
70
+ if (!c.ok) {
71
+ // STOP. The entire safety argument for the blind merge below is
72
+ // "both sides are committed before any merge" — that is what makes a
73
+ // lost hunk recoverable from `git log`. When the commit fails the
74
+ // argument is void, and pulling would merge someone else's history
75
+ // over work that exists nowhere but this working tree.
76
+ //
77
+ // A failed commit is ordinary, not exotic: a pre-commit hook, a
78
+ // missing git identity, a full disk, or an `index.lock` held by a
79
+ // concurrent sync all produce it.
80
+ notes.push(`Commit failed, so nothing was pulled or pushed: ${calmly(c.err)}`);
81
+ notes.push("Your working tree is untouched. Fix the commit, then sync again.");
82
+ return result;
83
+ }
84
+ notes.push(`Committed local changes (${syncMessage(at)}).`);
85
+ }
86
+ if (!remote) {
87
+ notes.push("No remote configured — commits are local only. `git remote add origin <url>` to share.");
88
+ return result;
89
+ }
90
+ // --- 2. PULL, resolved blindly. ---
91
+ // `-X theirs` decides hunks that genuinely conflict; `merge=union` in
92
+ // .gitattributes overrides it for the append-shaped files, where BOTH sides
93
+ // are meant. Losing a hunk here is recoverable — step 1 committed it.
94
+ const pull = git(root, [
95
+ "-c",
96
+ "pull.rebase=false",
97
+ "pull",
98
+ "--no-edit",
99
+ "--quiet",
100
+ "-X",
101
+ "theirs",
102
+ remote,
103
+ branch,
104
+ ]);
105
+ if (pull.ok) {
106
+ result.pulled = "ok";
107
+ }
108
+ else {
109
+ // An empty remote (nothing pushed yet) is not a failure.
110
+ const benign = /couldn't find remote ref|no such ref|Couldn't find remote ref/i.test(pull.err);
111
+ result.pulled = benign ? "skipped" : "failed";
112
+ if (!benign)
113
+ notes.push(`Pull skipped — remote unreachable (${calmly(pull.err)}). Continuing with local state.`);
114
+ }
115
+ // --- 3. PUSH, best-effort, one retry through a re-pull on rejection. ---
116
+ let push = git(root, ["push", "--quiet", remote, branch]);
117
+ if (!push.ok) {
118
+ git(root, [
119
+ "-c",
120
+ "pull.rebase=false",
121
+ "pull",
122
+ "--no-edit",
123
+ "--quiet",
124
+ "-X",
125
+ "theirs",
126
+ remote,
127
+ branch,
128
+ ]);
129
+ push = git(root, ["push", "--quiet", remote, branch]);
130
+ }
131
+ result.pushed = push.ok ? "ok" : "failed";
132
+ if (!push.ok) {
133
+ notes.push(`Push deferred — remote unreachable (${calmly(push.err)}). Work is committed locally and goes out on the next sync.`);
134
+ }
135
+ return result;
136
+ }
137
+ /**
138
+ * Git's own wording, made safe to show for an expected condition.
139
+ *
140
+ * An unreachable remote is NORMAL here — the whole point of committing first
141
+ * is that working offline is not an error. Echoing git's raw `fatal: ...`
142
+ * presents a routine outage as a hard failure, which is exactly the shape A6
143
+ * forbids: someone else's downtime must not read as your problem to fix.
144
+ * The cause is kept, the alarm is not.
145
+ */
146
+ function calmly(s) {
147
+ const line = s
148
+ .trim()
149
+ .split("\n")
150
+ .find((l) => l.trim() !== "") ?? "";
151
+ return line
152
+ .replace(/^fatal:\s*/i, "")
153
+ .replace(/^error:\s*/i, "")
154
+ .trim();
155
+ }
156
+ export function repoPublishState(siblingParent, name) {
157
+ const path = join(siblingParent, name);
158
+ if (!existsSync(join(path, ".git"))) {
159
+ return { name, present: false, ahead: 0, hasRemote: false, published: false };
160
+ }
161
+ const remote = remoteName(path);
162
+ if (!remote)
163
+ return { name, present: true, ahead: 0, hasRemote: false, published: false };
164
+ const branch = currentBranch(path);
165
+ // Does a remote-tracking ref exist at all? `rev-list A..B` ERRORS when it
166
+ // does not, and reading that error as "0 commits ahead" reports the worst
167
+ // case — nothing has ever left this machine — as the most reassuring one.
168
+ const tracked = git(path, ["rev-parse", "--verify", "--quiet", `${remote}/${branch}`]).ok;
169
+ if (!tracked)
170
+ return { name, present: true, ahead: 0, hasRemote: true, published: false };
171
+ const counted = git(path, ["rev-list", "--count", `${remote}/${branch}..HEAD`]);
172
+ const ahead = counted.ok ? Number.parseInt(counted.out.trim(), 10) || 0 : 0;
173
+ return { name, present: true, ahead, hasRemote: true, published: true };
174
+ }
@@ -27,11 +27,18 @@ source "$EXT_DIR/scripts/lib/workbench-helpers.sh"
27
27
  WORKBENCH_ROOT="$(_resolve_workbench_root)"
28
28
  export WORKBENCH_ROOT
29
29
 
30
- WRAPPED_REPO="$(_read_workbench_field wrapped_repo)"
31
- if [[ -z "$WRAPPED_REPO" ]]; then
32
- echo "Warning: .indusk/config.json missing worktree.wrapped_repo." >&2
33
- echo "Worktree extension is enabled but won't function until that field is set." >&2
34
- echo "Run 'indusk init --workbench --wrapped-repo <name>' (Phase 6) or hand-edit the config." >&2
30
+ # Every declared repo, not just the first: a workbench that wraps N repos needs
31
+ # N starter configs, and scaffolding only one leaves the others silently
32
+ # unconfigured until someone tries to make a worktree in them.
33
+ DECLARED_REPOS=()
34
+ while IFS= read -r line; do
35
+ [[ -n "$line" ]] && DECLARED_REPOS+=("$line")
36
+ done < <(_read_workbench_repos 2>/dev/null || true)
37
+
38
+ if [[ ${#DECLARED_REPOS[@]} -eq 0 ]]; then
39
+ echo "Warning: .indusk/config.json declares no repos (worktree.repos[] or worktree.wrapped_repo)." >&2
40
+ echo "Worktree extension is enabled but won't function until one is set." >&2
41
+ echo "Run 'indusk init --workbench --wrapped-repo <name>' or hand-edit the config." >&2
35
42
  # Don't fail — the extension is enabled, just inert until configured.
36
43
  fi
37
44
 
@@ -70,17 +77,17 @@ else
70
77
  fi
71
78
 
72
79
  # 3. Materialize starter worktree-config if absent.
73
- if [[ -n "$WRAPPED_REPO" ]]; then
74
- CONFIG_DIR="$WORKBENCH_ROOT/.indusk/worktree-configs"
75
- CONFIG_FILE="$CONFIG_DIR/${WRAPPED_REPO}.json"
80
+ CONFIG_DIR="$WORKBENCH_ROOT/.indusk/worktree-configs"
81
+ for REPO_NAME in ${DECLARED_REPOS+"${DECLARED_REPOS[@]}"}; do
82
+ CONFIG_FILE="$CONFIG_DIR/${REPO_NAME}.json"
76
83
  if [[ ! -f "$CONFIG_FILE" ]]; then
77
84
  mkdir -p "$CONFIG_DIR"
78
- sed "s/WRAPPED_REPO_NAME/${WRAPPED_REPO}/g" \
85
+ sed "s/WRAPPED_REPO_NAME/${REPO_NAME}/g" \
79
86
  "$EXT_DIR/templates/worktree-config.template.json" > "$CONFIG_FILE"
80
87
  echo " starter config: $CONFIG_FILE"
81
88
  else
82
89
  echo " starter config: $CONFIG_FILE already exists, leaving in place"
83
90
  fi
84
- fi
91
+ done
85
92
 
86
93
  echo "Worktree extension scaffolding complete."