pi-squire 0.9.4 → 0.10.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.
@@ -0,0 +1,420 @@
1
+ // Where a delegate runs and whether it trusts that project. A launch either
2
+ // inherits the parent's cwd or selects an existing directory; this module
3
+ // resolves that directory, observes its Git checkout read-only, and decides
4
+ // the explicit Pi trust flag in the parent, asking the user only when no
5
+ // saved or current decision applies and the target has protected resources.
6
+ // Where a delegate runs and whether it trusts that project. A launch either
7
+ // inherits the parent's cwd, selects an existing directory or, for a managed
8
+ // worktree, runs in a new checkout (`worktree.ts`); this module resolves an
9
+ // existing directory, observes its Git checkout read-only, and decides the
10
+ // explicit Pi trust flag in the parent, asking the user only when no saved or
11
+ // current decision applies and the target has protected resources.
12
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join, resolve } from "node:path";
15
+
16
+ import {
17
+ type ExtensionContext,
18
+ hasTrustRequiringProjectResources,
19
+ ProjectTrustStore,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { Type } from "typebox";
22
+
23
+ import { askGit, type GitLaunch, inspectGit, PathSummarySchema } from "./git.ts";
24
+ import type { ManagedWorkspace } from "./worktree.ts";
25
+
26
+ export {
27
+ askGit,
28
+ type GitAnswer,
29
+ type GitIdentity,
30
+ type GitLaunch,
31
+ type GitPosition,
32
+ onePath,
33
+ type PathSummary,
34
+ } from "./git.ts";
35
+ export { type ManagedWorkspace, prepareWorktree, type WorktreeOptions, type WorktreePlan } from "./worktree.ts";
36
+
37
+ import type { WorktreePlan } from "./worktree.ts";
38
+
39
+ export type DirectoryWorkspace = {
40
+ mode: "inherited" | "existing";
41
+ cwd: string;
42
+ git: GitLaunch;
43
+ };
44
+ export type Workspace = DirectoryWorkspace | ManagedWorkspace;
45
+ /** `GitPosition` as a schema. */
46
+ export const PositionSchema = Type.Union([
47
+ Type.Object({ branch: Type.String(), detached: Type.Literal(false) }),
48
+ Type.Object({ branch: Type.Null(), detached: Type.Literal(true) }),
49
+ ]);
50
+
51
+ /** `GitIdentity` as a schema. */
52
+ export const IdentitySchema = Type.Object({
53
+ mainRoot: Type.String(),
54
+ commonDir: Type.String(),
55
+ checkoutRoot: Type.String(),
56
+ });
57
+
58
+ const CheckoutSchema = Type.Object({
59
+ kind: Type.Literal("git"),
60
+ identity: IdentitySchema,
61
+ position: PositionSchema,
62
+ head: Type.Union([Type.String(), Type.Null()]),
63
+ baseline: Type.Union([Type.String(), Type.Null()], {
64
+ description: "The commit checked out at launch; null when the checkout had no commit yet.",
65
+ }),
66
+ });
67
+
68
+ const CWD_DESCRIPTION = "The directory the delegate's window opened in.";
69
+
70
+ /** `Workspace` as a schema, for the tools' structured outputs. */
71
+ export const WorkspaceSchema = Type.Union([
72
+ Type.Object({
73
+ mode: Type.Union([Type.Literal("inherited"), Type.Literal("existing")]),
74
+ cwd: Type.String({ description: CWD_DESCRIPTION }),
75
+ git: Type.Union(
76
+ [
77
+ CheckoutSchema,
78
+ Type.Object({ kind: Type.Literal("non-git") }),
79
+ Type.Object({ kind: Type.Literal("unknown"), reason: Type.String() }),
80
+ ],
81
+ { description: "The checkout at launch: git, non-git, or unknown when Git could not be asked." },
82
+ ),
83
+ }),
84
+ Type.Object({
85
+ mode: Type.Literal("managed"),
86
+ cwd: Type.String({ description: CWD_DESCRIPTION }),
87
+ git: CheckoutSchema,
88
+ sourceCwd: Type.String({ description: "The checkout the worktree was created from." }),
89
+ source: IdentitySchema,
90
+ createdBranch: Type.String({ description: "The branch created for the worktree, as originally named." }),
91
+ requestedBase: Type.String(),
92
+ baseCommit: Type.String({ description: "The commit the branch was created at." }),
93
+ sourceDirty: PathSummarySchema,
94
+ }),
95
+ ]);
96
+
97
+ /** `WorktreePlan` as a schema: what an attempt intended. */
98
+ export const WorktreePlanSchema = Type.Object({
99
+ sourceCwd: Type.String(),
100
+ source: IdentitySchema,
101
+ destination: Type.String(),
102
+ branch: Type.String(),
103
+ requestedBase: Type.String(),
104
+ baseCommit: Type.String(),
105
+ sourceDirty: PathSummarySchema,
106
+ trusted: Type.Boolean(),
107
+ });
108
+
109
+ /** Optional inputs some models fill with these instead of leaving them out. */
110
+ const OMITTED = new Set(["", "null", "none", "false", "undefined"]);
111
+
112
+ /** A workspace input as a string, or undefined when omitted; any other type is refused, never coerced. */
113
+ export function normalizeWorkspaceValue(value: unknown, name: string): string | undefined {
114
+ if (value === undefined || value === null) return undefined;
115
+ if (typeof value !== "string") {
116
+ throw new Error(`${name} must be a string, got ${Array.isArray(value) ? "an array" : typeof value}`);
117
+ }
118
+ return OMITTED.has(value) ? undefined : value;
119
+ }
120
+
121
+ /** The real path of an existing, accessible directory named by `value`, relative to `base`. */
122
+ function resolveDirectory(value: string, base: string): string {
123
+ // Only a leading `~` or `~/`: no variables, globs or `~user`.
124
+ let expanded = value;
125
+ if (value === "~") expanded = homedir();
126
+ else if (value.startsWith("~/")) expanded = join(homedir(), value.slice(2));
127
+ const absolute = resolve(base, expanded);
128
+ const named = `cwd ${JSON.stringify(value)}${absolute === value ? "" : ` (${absolute})`}`;
129
+ let real: string;
130
+ try {
131
+ real = realpathSync(absolute);
132
+ } catch (err) {
133
+ throw new Error(`${named} is not an accessible directory: ${(err as Error).message}`);
134
+ }
135
+ if (!statSync(real).isDirectory()) throw new Error(`${named} is not a directory`);
136
+ try {
137
+ accessSync(real, constants.R_OK | constants.X_OK);
138
+ } catch (err) {
139
+ throw new Error(`${named} cannot be accessed: ${(err as Error).message}`);
140
+ }
141
+ return real;
142
+ }
143
+
144
+ /** The trust dialog's choices, as Pi's own selector offers them. */
145
+ type Choice = { label: string; trusted: boolean; updates: { path: string; decision: boolean | null }[] };
146
+
147
+ function choices(key: string): Choice[] {
148
+ const parent = dirname(key);
149
+ return [
150
+ { label: "Trust", trusted: true, updates: [{ path: key, decision: true }] },
151
+ // The parent folder's approval clears the nearer key, which would otherwise outrank it.
152
+ ...(parent === key
153
+ ? []
154
+ : [
155
+ {
156
+ label: `Trust parent folder (${parent})`,
157
+ trusted: true,
158
+ updates: [
159
+ { path: parent, decision: true },
160
+ { path: key, decision: null },
161
+ ],
162
+ },
163
+ ]),
164
+ { label: "Trust (this session only)", trusted: true, updates: [] },
165
+ { label: "Do not trust", trusted: false, updates: [{ path: key, decision: false }] },
166
+ { label: "Do not trust (this session only)", trusted: false, updates: [] },
167
+ ];
168
+ }
169
+
170
+ /** The saved decision for `key` or its closest ancestor; a broken store refuses the launch. */
171
+ function savedDecision(store: ProjectTrustStore, key: string): boolean | null {
172
+ try {
173
+ return store.get(key);
174
+ } catch (err) {
175
+ throw new Error(
176
+ `could not read the project trust store: ${(err as Error).message}; fix or remove it, then delegate again`,
177
+ );
178
+ }
179
+ }
180
+
181
+ /** The end of the trust dialog queue: one question at a time in this parent, so an answer cannot reach another target. */
182
+ let dialogs: Promise<unknown> = Promise.resolve();
183
+
184
+ /** Why an aborted launch stopped. */
185
+ const ABORTED = "the launch was aborted; nothing was launched";
186
+
187
+ /** Run `ask` once every earlier question is settled; an abort while waiting rejects at once and skips `ask`. */
188
+ function oneAtATime<T>(ask: () => Promise<T>, signal: AbortSignal | undefined): Promise<T> {
189
+ const run = (): Promise<T> => {
190
+ if (signal?.aborted === true) return Promise.reject(new Error(ABORTED));
191
+ return ask();
192
+ };
193
+ const turn = dialogs.then(run, run);
194
+ dialogs = turn.catch(() => undefined);
195
+ if (signal === undefined) return turn;
196
+ const aborted = new Promise<never>((_, reject) => {
197
+ signal.addEventListener("abort", () => reject(new Error(ABORTED)), { once: true });
198
+ });
199
+ return Promise.race([turn, aborted]);
200
+ }
201
+
202
+ /** Refuse to go on once the launch's operation is aborted. */
203
+ function stopIfAborted(signal: AbortSignal | undefined): void {
204
+ if (signal?.aborted === true) throw new Error(ABORTED);
205
+ }
206
+
207
+ /** Ask the user whether the delegate trusts `cwd`'s project resources, saving only what they chose. */
208
+ async function askTrust(
209
+ ctx: ExtensionContext,
210
+ signal: AbortSignal | undefined,
211
+ store: ProjectTrustStore,
212
+ cwd: string,
213
+ key: string,
214
+ ): Promise<boolean> {
215
+ // An answer given while this question waited may already settle it.
216
+ const saved = savedDecision(store, key);
217
+ if (saved !== null) return saved;
218
+ const options = choices(key);
219
+ const title = [
220
+ "A delegate is about to start in a directory with project resources Pi loads only when trusted.",
221
+ `Directory: ${cwd}`,
222
+ ...(key === cwd ? [] : [`Main repository: ${key}`]),
223
+ `"Trust" and "Do not trust" save the decision for ${key}; "Trust parent folder" saves it for the folder named; "this session only" applies to this launch alone.`,
224
+ ].join("\n");
225
+ const answer = await ctx.ui.select(
226
+ title,
227
+ options.map((option) => option.label),
228
+ { signal },
229
+ );
230
+ stopIfAborted(signal);
231
+ const choice = options.find((option) => option.label === answer);
232
+ if (choice === undefined) throw new Error(`the trust question for ${cwd} was cancelled; nothing was launched`);
233
+ if (choice.updates.length > 0) {
234
+ try {
235
+ store.setMany(choice.updates);
236
+ } catch (err) {
237
+ throw new Error(`could not save the trust decision for ${key}: ${(err as Error).message}; nothing was launched`);
238
+ }
239
+ }
240
+ return choice.trusted;
241
+ }
242
+
243
+ /**
244
+ * Whether a delegate in the selected directory `cwd` is trusted. The parent's
245
+ * current trust holds only inside its own repository; elsewhere the saved
246
+ * decision for a linked worktree's main repository or for the directory
247
+ * itself, then no trust when nothing is protected, then the user's answer.
248
+ */
249
+ async function targetTrust(
250
+ cwd: string,
251
+ git: GitLaunch,
252
+ ctx: ExtensionContext,
253
+ agentDir: string,
254
+ signal: AbortSignal | undefined,
255
+ ): Promise<boolean> {
256
+ if (git.kind === "git") {
257
+ const parent = inspectGit(ctx.cwd);
258
+ if (parent.kind === "git" && parent.identity.commonDir === git.identity.commonDir) return ctx.isProjectTrusted();
259
+ }
260
+ // Linked status comes from Git, not a path prefix: a subdirectory of a main checkout keeps its own key.
261
+ const linked = git.kind === "git" && git.identity.checkoutRoot !== git.identity.mainRoot;
262
+ const key = linked ? git.identity.mainRoot : cwd;
263
+ const store = new ProjectTrustStore(agentDir);
264
+ const saved = savedDecision(store, key);
265
+ if (saved !== null) return saved;
266
+ if (!hasTrustRequiringProjectResources(cwd)) return false;
267
+ if (!ctx.hasUI) {
268
+ throw new Error(
269
+ `cwd ${cwd} has project resources that need trust and no trust decision is saved for ${key}; open Pi in ${key}, answer its trust question, then delegate again`,
270
+ );
271
+ }
272
+ return oneAtATime(() => askTrust(ctx, signal, store, cwd, key), signal);
273
+ }
274
+
275
+ const PROTECTED = [
276
+ ".pi/settings.json",
277
+ ".pi/mcp.json",
278
+ ".pi/extensions",
279
+ ".pi/skills",
280
+ ".pi/prompts",
281
+ ".pi/themes",
282
+ ".pi/SYSTEM.md",
283
+ ".pi/APPEND_SYSTEM.md",
284
+ ".agents/skills",
285
+ ];
286
+
287
+ function plannedTreeHasResources(cwd: string, base: string): string | undefined {
288
+ const tree = askGit(cwd, ["ls-tree", "-r", "-z", "--full-tree", base]);
289
+ if (!tree.ok) throw new Error(`could not inspect planned worktree resources at ${base}: ${tree.message}`);
290
+ for (const entry of tree.out.split("\0")) {
291
+ if (!entry) continue;
292
+ const tab = entry.indexOf("\t");
293
+ if (tab < 0) throw new Error(`could not parse planned worktree tree at ${base}`);
294
+ const metadata = entry.slice(0, tab);
295
+ const path = entry.slice(tab + 1);
296
+ const protectedPath = PROTECTED.some((item) => path === item || path.startsWith(`${item}/`));
297
+ const possibleIndirection =
298
+ path === ".pi" || path.startsWith(".pi/") || path === ".agents" || path.startsWith(".agents/");
299
+ if (metadata.startsWith("120000 ") && possibleIndirection)
300
+ throw new Error(
301
+ `planned project resource path ${path} is a symlink; establish trust explicitly before delegating`,
302
+ );
303
+ if (protectedPath) return tree.out;
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ function destinationHasAncestorSkills(destination: string): boolean {
309
+ try {
310
+ return hasTrustRequiringProjectResources(destination);
311
+ } catch {
312
+ throw new Error(
313
+ `could not inspect destination ancestor skills at ${join(dirname(destination), ".agents", "skills")}; establish trust explicitly before delegating`,
314
+ );
315
+ }
316
+ }
317
+
318
+ /** Trust plus the scoped facts that must remain true until the queued launch mutates anything. */
319
+ export type PlannedTrust = { trusted: boolean; revalidate(): void };
320
+
321
+ async function askPlannedResourceTrust(
322
+ plan: WorktreePlan,
323
+ ctx: ExtensionContext,
324
+ store: ProjectTrustStore,
325
+ signal?: AbortSignal,
326
+ ): Promise<{ trusted: boolean; exposure?: { tree: string | undefined; ancestors: boolean } }> {
327
+ const tree = plannedTreeHasResources(plan.source.checkoutRoot, plan.baseCommit);
328
+ const ancestors = destinationHasAncestorSkills(plan.destination);
329
+ const exposure = { tree, ancestors };
330
+ if (tree === undefined && !ancestors) return { trusted: false, exposure };
331
+ if (!ctx.hasUI)
332
+ throw new Error(
333
+ `the planned worktree in ${plan.destination} exposes project resources and has no saved trust decision for ${plan.source.mainRoot}; open Pi and establish trust, then delegate again`,
334
+ );
335
+ const trusted = await oneAtATime(async () => {
336
+ const savedAtTurn = savedDecision(store, plan.source.mainRoot);
337
+ if (savedAtTurn !== null) return savedAtTurn;
338
+ const answer = await askTrust(ctx, signal, store, plan.destination, plan.source.mainRoot);
339
+ if (
340
+ (tree !== undefined && plannedTreeHasResources(plan.source.checkoutRoot, plan.baseCommit) !== tree) ||
341
+ destinationHasAncestorSkills(plan.destination) !== ancestors
342
+ )
343
+ throw new Error("the planned resources changed while trust was being decided; delegate again");
344
+ return answer;
345
+ }, signal);
346
+ return { trusted, exposure };
347
+ }
348
+
349
+ /** Decide trust for a not-yet-created checkout and retain a small synchronous queue-time check. */
350
+ export async function trustPlannedWorktree(
351
+ plan: WorktreePlan,
352
+ ctx: ExtensionContext,
353
+ agentDir: string,
354
+ signal?: AbortSignal,
355
+ ): Promise<PlannedTrust> {
356
+ const parent = inspectGit(ctx.cwd);
357
+ const sameRepository = parent.kind === "git" && parent.identity.commonDir === plan.source.commonDir;
358
+ const store = new ProjectTrustStore(agentDir);
359
+ let trusted: boolean;
360
+ let exposure: { tree: string | undefined; ancestors: boolean } | undefined;
361
+ if (sameRepository) {
362
+ trusted = ctx.isProjectTrusted();
363
+ } else {
364
+ const saved = savedDecision(store, plan.source.mainRoot);
365
+ if (saved !== null) trusted = saved;
366
+ else {
367
+ const answer = await askPlannedResourceTrust(plan, ctx, store, signal);
368
+ trusted = answer.trusted;
369
+ exposure = answer.exposure;
370
+ }
371
+ }
372
+ const savedEntry = sameRepository ? undefined : store.getEntry(plan.source.mainRoot);
373
+ return {
374
+ trusted,
375
+ revalidate: () => {
376
+ const currentParent = inspectGit(ctx.cwd);
377
+ const stillSame = currentParent.kind === "git" && currentParent.identity.commonDir === plan.source.commonDir;
378
+ if (sameRepository) {
379
+ if (!stillSame || ctx.isProjectTrusted() !== trusted)
380
+ throw new Error("the parent trust or repository changed while waiting; delegate again");
381
+ return;
382
+ }
383
+ if (stillSame || JSON.stringify(store.getEntry(plan.source.mainRoot)) !== JSON.stringify(savedEntry))
384
+ throw new Error("the scoped project trust changed while waiting; delegate again");
385
+ if (
386
+ exposure !== undefined &&
387
+ (plannedTreeHasResources(plan.source.checkoutRoot, plan.baseCommit) !== exposure.tree ||
388
+ destinationHasAncestorSkills(plan.destination) !== exposure.ancestors)
389
+ )
390
+ throw new Error("the planned resources changed while waiting; delegate again");
391
+ },
392
+ };
393
+ }
394
+
395
+ /**
396
+ * The delegate's workspace and trust. With no `cwd`, the parent's directory
397
+ * and current trust; with one, its real path and a trust decision for it.
398
+ * `signal` is the launch's operation (a tool call's own signal); the turn's
399
+ * `ctx.signal` aborts it too.
400
+ */
401
+ export async function prepareDirectory(
402
+ request: { cwd?: string; signal?: AbortSignal },
403
+ ctx: ExtensionContext,
404
+ agentDir: string,
405
+ ): Promise<{ workspace: DirectoryWorkspace; trusted: boolean }> {
406
+ if (request.cwd === undefined) {
407
+ return {
408
+ workspace: { mode: "inherited", cwd: ctx.cwd, git: inspectGit(ctx.cwd) },
409
+ trusted: ctx.isProjectTrusted(),
410
+ };
411
+ }
412
+ const signals = [request.signal, ctx.signal].filter((item): item is AbortSignal => item !== undefined);
413
+ const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined;
414
+ stopIfAborted(signal);
415
+ const cwd = resolveDirectory(request.cwd, ctx.cwd);
416
+ const git = inspectGit(cwd);
417
+ const trusted = await targetTrust(cwd, git, ctx, agentDir, signal);
418
+ stopIfAborted(signal);
419
+ return { workspace: { mode: "existing", cwd, git }, trusted };
420
+ }
@@ -0,0 +1,138 @@
1
+ // Call-time placement settings for managed worktrees. This module only
2
+ // chooses a destination; source, base, trust and lifecycle remain in the
3
+ // worktree plan and creation code.
4
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
+
8
+ import type { GitIdentity } from "./git.ts";
9
+
10
+ type Settings = {
11
+ worktreeRoot?: string;
12
+ worktreeRootByRepo: Map<string, string>;
13
+ };
14
+
15
+ const SETTINGS_NAME = "settings.json";
16
+ const exists = (path: string): boolean => lstatSync(path, { throwIfNoEntry: false }) !== undefined;
17
+
18
+ /** Resolve existing symlink ancestors while retaining the not-yet-created suffix. */
19
+ function canonical(path: string): string {
20
+ const rest: string[] = [];
21
+ let current = resolve(path);
22
+ while (!exists(current) && dirname(current) !== current) {
23
+ rest.unshift(basename(current));
24
+ current = dirname(current);
25
+ }
26
+ return join(realpathSync(current), ...rest);
27
+ }
28
+
29
+ /** Only a leading home marker is expanded; other tildes remain ordinary path characters. */
30
+ function expandHome(path: string): string {
31
+ if (path === "~") return homedir();
32
+ return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
33
+ }
34
+
35
+ function isObject(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ function settingsError(file: string, problem: string): Error {
40
+ return new Error(`invalid worktree placement settings ${file}: ${problem}`);
41
+ }
42
+
43
+ /** Validate all templates, including overrides not selected by this launch. */
44
+ function validateTemplate(file: string, template: string): void {
45
+ if (template.trim() === "") throw settingsError(file, "templates must not be empty");
46
+ const remainder = template.replace(/\{[^{}]*\}/g, (placeholder) => {
47
+ const name = placeholder.slice(1, -1);
48
+ if (name !== "branch" && name !== "repo" && name !== "repoParent") {
49
+ throw settingsError(file, `unknown template placeholder ${placeholder}`);
50
+ }
51
+ return "";
52
+ });
53
+ if (/[{}]/.test(remainder)) throw settingsError(file, "templates contain an unmatched brace");
54
+ if (!template.includes("{branch}")) throw settingsError(file, "every template must contain {branch}");
55
+ }
56
+
57
+ function readJson(file: string): unknown {
58
+ try {
59
+ return JSON.parse(readFileSync(file, "utf8"));
60
+ } catch (err) {
61
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
62
+ throw settingsError(file, `could not read or parse the file: ${(err as Error).message}`);
63
+ }
64
+ }
65
+
66
+ function readOverrides(
67
+ file: string,
68
+ settingsDir: string,
69
+ value: Record<string, unknown> | undefined,
70
+ ): Map<string, string> {
71
+ const templates = new Map<string, string>();
72
+ const originalKeys = new Map<string, string>();
73
+ for (const [key, template] of Object.entries(value ?? {})) {
74
+ if (key === "") throw settingsError(file, "repository override keys must not be empty");
75
+ if (typeof template !== "string") {
76
+ throw settingsError(file, `worktreeRootByRepo[${JSON.stringify(key)}] must be a string`);
77
+ }
78
+ validateTemplate(file, template);
79
+ let canonicalKey: string;
80
+ try {
81
+ canonicalKey = canonical(resolve(settingsDir, expandHome(key)));
82
+ } catch (err) {
83
+ throw settingsError(file, `could not resolve repository key ${JSON.stringify(key)}: ${(err as Error).message}`);
84
+ }
85
+ const previousKey = originalKeys.get(canonicalKey);
86
+ if (previousKey !== undefined) {
87
+ throw settingsError(
88
+ file,
89
+ `ambiguous duplicate canonical repository key ${canonicalKey} (overrides ${JSON.stringify(previousKey)} and ${JSON.stringify(key)})`,
90
+ );
91
+ }
92
+ originalKeys.set(canonicalKey, key);
93
+ templates.set(canonicalKey, template);
94
+ }
95
+ return templates;
96
+ }
97
+
98
+ function readSettings(agentDir: string): Settings {
99
+ const file = join(agentDir, "squire", SETTINGS_NAME);
100
+ const parsed = readJson(file);
101
+ if (!isObject(parsed)) throw settingsError(file, "the top level must be an object");
102
+
103
+ const worktreeRoot = parsed.worktreeRoot;
104
+ if (worktreeRoot !== undefined && typeof worktreeRoot !== "string") {
105
+ throw settingsError(file, "worktreeRoot must be a string when provided");
106
+ }
107
+ if (typeof worktreeRoot === "string") validateTemplate(file, worktreeRoot);
108
+
109
+ const byRepo = parsed.worktreeRootByRepo;
110
+ if (byRepo !== undefined && !isObject(byRepo)) {
111
+ throw settingsError(file, "worktreeRootByRepo must be an object when provided");
112
+ }
113
+ return {
114
+ ...(typeof worktreeRoot === "string" ? { worktreeRoot } : {}),
115
+ worktreeRootByRepo: readOverrides(file, dirname(file), byRepo),
116
+ };
117
+ }
118
+
119
+ /** Choose and canonicalize the configured or built-in destination for this call. */
120
+ export function worktreeDestination(agentDir: string, identity: GitIdentity, branch: string): string {
121
+ const settings = readSettings(agentDir);
122
+ const settingsDir = join(agentDir, "squire");
123
+ const override = settings.worktreeRootByRepo.get(identity.mainRoot);
124
+ const template = override ?? settings.worktreeRoot ?? "{repoParent}/{repo}-worktrees/{branch}";
125
+ const expanded = template.replace(/\{([^{}]+)\}/g, (_placeholder, name: string) => {
126
+ switch (name) {
127
+ case "repoParent":
128
+ return dirname(identity.mainRoot);
129
+ case "repo":
130
+ return basename(identity.mainRoot);
131
+ case "branch":
132
+ return branch.replaceAll("/", "-");
133
+ default:
134
+ throw settingsError(join(settingsDir, SETTINGS_NAME), `unknown template placeholder {${name}}`);
135
+ }
136
+ });
137
+ return canonical(resolve(settingsDir, expandHome(expanded)));
138
+ }