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