@infinitedusky/indusk-mcp 1.36.2 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/.next/BUILD_ID +1 -1
- package/admin/.next/build-manifest.json +3 -3
- package/admin/.next/fallback-build-manifest.json +3 -3
- package/admin/.next/prerender-manifest.json +3 -3
- package/admin/.next/server/app/_global-error.html +1 -1
- package/admin/.next/server/app/_global-error.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/admin/.next/server/chunks/ssr/[root-of-the-server]__0p.51_4._.js.map +1 -1
- package/admin/.next/server/middleware-build-manifest.js +3 -3
- package/admin/.next/server/pages/500.html +1 -1
- package/admin/.next/server/server-reference-manifest.js +1 -1
- package/admin/.next/server/server-reference-manifest.json +1 -1
- package/admin/.next/trace +1 -1
- package/admin/.next/trace-build +1 -1
- package/dist/bin/cli.js +42 -4
- package/dist/bin/commands/init.js +42 -10
- package/dist/bin/commands/telemetry.js +7 -1
- package/dist/bin/commands/update.js +104 -2
- package/dist/bin/commands/workbench.d.ts +76 -0
- package/dist/bin/commands/workbench.js +438 -0
- package/dist/bin/commands/worktree.d.ts +22 -6
- package/dist/bin/commands/worktree.js +154 -111
- package/dist/lib/config.d.ts +59 -0
- package/dist/lib/hook-migration.d.ts +42 -0
- package/dist/lib/hook-migration.js +94 -0
- package/dist/lib/path-segment.d.ts +23 -0
- package/dist/lib/path-segment.js +41 -0
- package/dist/lib/plan-parser.js +5 -13
- package/dist/lib/stray-state-audit.d.ts +3 -2
- package/dist/lib/stray-state-audit.js +14 -20
- package/dist/lib/telemetry/daemon.d.ts +18 -0
- package/dist/lib/telemetry/daemon.js +18 -2
- package/dist/lib/verify/git.d.ts +11 -4
- package/dist/lib/verify/git.js +11 -4
- package/dist/lib/verify/roots.d.ts +40 -0
- package/dist/lib/verify/roots.js +54 -0
- package/dist/lib/verify/verify.js +17 -2
- package/dist/lib/worktree/layout.d.ts +56 -0
- package/dist/lib/worktree/layout.js +136 -0
- package/dist/lib/worktree/repos.d.ts +46 -0
- package/dist/lib/worktree/repos.js +100 -0
- package/dist/lib/worktree/shareable.d.ts +47 -0
- package/dist/lib/worktree/shareable.js +301 -0
- package/dist/lib/worktree/sync.d.ts +62 -0
- package/dist/lib/worktree/sync.js +174 -0
- package/extensions/worktree/hooks/on_enable.sh +17 -10
- package/extensions/worktree/scripts/lib/workbench-helpers.sh +84 -0
- package/extensions/worktree/scripts/preflight.sh +4 -8
- package/extensions/worktree/scripts/refresh-worktree.sh +4 -8
- package/extensions/worktree/scripts/setup-worktree.sh +40 -16
- package/extensions/worktree/skill.md +8 -6
- package/hooks/_hook-paths.js +78 -15
- package/hooks/workbench-sync.js +119 -0
- package/package.json +109 -109
- package/skills/catchup.md +17 -0
- /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_buildManifest.js +0 -0
- /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_clientMiddlewareManifest.js +0 -0
- /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_ssgManifest.js +0 -0
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
CONFIG_FILE="$CONFIG_DIR/${
|
|
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/${
|
|
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
|
-
|
|
91
|
+
done
|
|
85
92
|
|
|
86
93
|
echo "Worktree extension scaffolding complete."
|