@bermudi/pi-delegate 0.1.11 → 0.1.13

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/settings.ts CHANGED
@@ -4,15 +4,15 @@ import * as path from "node:path";
4
4
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
5
  import { VALID_THINKING } from "./constants.ts";
6
6
 
7
- export interface AgentOverride {
8
- model?: string;
9
- thinking?: ThinkingLevel;
10
- tools?: string[];
11
- }
7
+ /**
8
+ * One-release compatibility bridge for delegate overrides formerly stored in
9
+ * Pi's user/project settings.json. Only model and thinking are bridged; tools
10
+ * remain operator-controlled because project files must not restore shell
11
+ * capability. Modern delegate.json values win field-by-field.
12
+ */
12
13
 
13
- export interface DelegateSettings {
14
- agentOverrides?: Record<string, AgentOverride>;
15
- agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
14
+ function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16
16
  }
17
17
 
18
18
  /** Read and validate a JSON settings object, returning null on I/O or parse errors. */
@@ -44,8 +44,104 @@ export function readDelegateSettingsFile(
44
44
  }
45
45
  }
46
46
 
47
- function isRecord(value: unknown): value is Record<string, unknown> {
48
- return value !== null && typeof value === "object" && !Array.isArray(value);
47
+ interface LegacyDetectionCacheEntry {
48
+ signature: string;
49
+ carriesDelegateBlock: boolean;
50
+ }
51
+
52
+ const legacyDetectionCache = new Map<string, LegacyDetectionCacheEntry>();
53
+ const warnedLegacyDetectionFailures = new Set<string>();
54
+
55
+ /** A cheap file identity used to invalidate cached misses and parse failures
56
+ * when a settings file is created or edited. */
57
+ function settingsFileSignature(filePath: string): string {
58
+ try {
59
+ const stat = fs.statSync(filePath);
60
+ return [
61
+ "present",
62
+ stat.dev,
63
+ stat.ino,
64
+ stat.size,
65
+ stat.mtimeMs,
66
+ stat.ctimeMs,
67
+ stat.mode,
68
+ ].join(":");
69
+ } catch (error) {
70
+ if (
71
+ error instanceof Error &&
72
+ "code" in error &&
73
+ (error as NodeJS.ErrnoException).code === "ENOENT"
74
+ ) {
75
+ return "missing";
76
+ }
77
+ // An inaccessible or otherwise unstatable file is not a stable miss. Let
78
+ // the read path report it on each attempt rather than hiding a later fix.
79
+ return `unstatable:${Date.now()}`;
80
+ }
81
+ }
82
+
83
+ function settingsCarryDelegateBlock(filePath: string): boolean {
84
+ const key = path.resolve(filePath);
85
+ const signature = settingsFileSignature(key);
86
+ const cached = legacyDetectionCache.get(key);
87
+ if (cached?.signature === signature) return cached.carriesDelegateBlock;
88
+
89
+ const parsed = readDelegateSettingsFile(key);
90
+ const carriesDelegateBlock = parsed !== null && isRecord(parsed.delegate);
91
+ legacyDetectionCache.set(key, { signature, carriesDelegateBlock });
92
+ return carriesDelegateBlock;
93
+ }
94
+
95
+ /** Paths of pi settings files (user + nearest project `.pi/settings.json`)
96
+ * that still carry a `delegate` block. Fail-open: never throws. */
97
+ export function findLegacyDelegateSettings(cwd: string): string[] {
98
+ const paths: string[] = [];
99
+ try {
100
+ const userPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
101
+ if (settingsCarryDelegateBlock(userPath)) paths.push(userPath);
102
+
103
+ // Nearest `.pi` directory walking up from cwd — the same discovery the
104
+ // old loader used, so a project that relied on overrides is still caught.
105
+ let dir = path.resolve(cwd);
106
+ const root = path.resolve("/");
107
+ for (;;) {
108
+ if (fs.existsSync(path.join(dir, ".pi"))) {
109
+ const projectPath = path.join(dir, ".pi", "settings.json");
110
+ if (settingsCarryDelegateBlock(projectPath)) paths.push(projectPath);
111
+ break;
112
+ }
113
+ if (dir === root) break;
114
+ const parent = path.dirname(dir);
115
+ if (parent === dir) break;
116
+ dir = parent;
117
+ }
118
+ } catch (error) {
119
+ // Detection is best-effort and must never block a dispatch, but silently
120
+ // losing the migration signal would make ignored legacy overrides hard to
121
+ // diagnose. Report each affected cwd once to avoid warning floods when a
122
+ // dispatch resolves several tasks in the same project.
123
+ if (!warnedLegacyDetectionFailures.has(cwd)) {
124
+ warnedLegacyDetectionFailures.add(cwd);
125
+ const detail = error instanceof Error ? error.message : String(error);
126
+ console.warn(
127
+ `[delegate] could not check for legacy delegate settings from '${cwd}': ${detail}. Overrides in pi settings.json may be ignored; move them to ~/.pi/agent/delegate.json.`,
128
+ );
129
+ }
130
+ }
131
+ return paths;
132
+ }
133
+
134
+ const warnedLegacySources = new Set<string>();
135
+
136
+ /** Shape returned by the temporary compatibility loader. */
137
+ export interface DelegateSettings {
138
+ agentOverrides?: Record<string, AgentOverride>;
139
+ agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
140
+ }
141
+
142
+ export interface AgentOverride {
143
+ model?: string;
144
+ thinking?: ThinkingLevel;
49
145
  }
50
146
 
51
147
  function normalizeOverride(
@@ -79,21 +175,9 @@ function normalizeOverride(
79
175
  }
80
176
  result.thinking = value as ThinkingLevel;
81
177
  } else if (key === "tools") {
82
- if (
83
- !Array.isArray(value) ||
84
- value.some((tool) => typeof tool !== "string")
85
- ) {
86
- console.warn(
87
- `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: tools must be a string array.`,
88
- );
89
- return null;
90
- }
91
- result.tools = [...value];
92
- } else if (key === "skills") {
93
178
  console.warn(
94
- `[delegate] ignoring unsupported skills override for agent '${agentName}' in ${source}: per-agent skill filtering is not supported.`,
179
+ `[delegate] ignoring legacy tools override for agent '${agentName}' in ${source}: the temporary compatibility bridge honors only model and thinking.`,
95
180
  );
96
- return null;
97
181
  } else {
98
182
  console.warn(
99
183
  `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: unknown field '${key}'.`,
@@ -115,7 +199,7 @@ function normalizeOverrides(
115
199
  return {};
116
200
  }
117
201
 
118
- const result: Record<string, AgentOverride> = {};
202
+ const result = Object.create(null) as Record<string, AgentOverride>;
119
203
  const seenNames = new Map<string, string>();
120
204
  for (const [agentName, value] of Object.entries(raw)) {
121
205
  const normalizedAgentName = agentName.trim();
@@ -150,7 +234,10 @@ function normalizeOverridesByParentModel(
150
234
  return {};
151
235
  }
152
236
 
153
- const result: Record<string, Record<string, AgentOverride>> = {};
237
+ const result = Object.create(null) as Record<
238
+ string,
239
+ Record<string, AgentOverride>
240
+ >;
154
241
  const seenModels = new Map<string, string>();
155
242
  for (const [parentModel, overrides] of Object.entries(raw)) {
156
243
  const normalizedParentModel = parentModel.trim();
@@ -176,13 +263,9 @@ function normalizeOverridesByParentModel(
176
263
  return result;
177
264
  }
178
265
 
179
- function getDelegateSettings(filePath: string): DelegateSettings | null {
266
+ function readLegacyDelegateSettings(filePath: string): DelegateSettings | null {
180
267
  const settings = readDelegateSettingsFile(filePath);
181
- if (
182
- !settings?.delegate ||
183
- typeof settings.delegate !== "object" ||
184
- Array.isArray(settings.delegate)
185
- ) {
268
+ if (!isRecord(settings?.delegate)) {
186
269
  if (settings?.delegate !== undefined) {
187
270
  console.warn(
188
271
  `[delegate] ignoring malformed delegate settings in ${filePath}: expected an object.`,
@@ -190,35 +273,74 @@ function getDelegateSettings(filePath: string): DelegateSettings | null {
190
273
  }
191
274
  return null;
192
275
  }
193
- const raw = settings.delegate as Record<string, unknown>;
276
+
194
277
  const result: DelegateSettings = {};
195
- if (raw.agentOverrides !== undefined) {
196
- result.agentOverrides = normalizeOverrides(raw.agentOverrides, filePath);
278
+ if (settings.delegate.agentOverrides !== undefined) {
279
+ result.agentOverrides = normalizeOverrides(
280
+ settings.delegate.agentOverrides,
281
+ filePath,
282
+ );
197
283
  }
198
- if (raw.agentOverridesByParentModel !== undefined) {
284
+ if (settings.delegate.agentOverridesByParentModel !== undefined) {
199
285
  result.agentOverridesByParentModel = normalizeOverridesByParentModel(
200
- raw.agentOverridesByParentModel,
286
+ settings.delegate.agentOverridesByParentModel,
201
287
  filePath,
202
288
  );
203
289
  }
204
290
  return result;
205
291
  }
206
292
 
207
- const delegateSettingsCache = new Map<string, DelegateSettings | null>();
293
+ function mergeOverride(
294
+ base: AgentOverride | undefined,
295
+ override: AgentOverride | undefined,
296
+ ): AgentOverride {
297
+ return { ...(base ?? {}), ...(override ?? {}) };
298
+ }
208
299
 
209
- /** Load merged delegate settings: project overrides user.
210
- * Result is cached per cwd until the next delegate dispatch clears it. */
300
+ function mergeOverrides(
301
+ user: Record<string, AgentOverride> | undefined,
302
+ project: Record<string, AgentOverride> | undefined,
303
+ ): Record<string, AgentOverride> {
304
+ const result = Object.create(null) as Record<string, AgentOverride>;
305
+ for (const name of new Set([
306
+ ...Object.keys(user ?? {}),
307
+ ...Object.keys(project ?? {}),
308
+ ])) {
309
+ result[name] = mergeOverride(user?.[name], project?.[name]);
310
+ }
311
+ return result;
312
+ }
313
+
314
+ function mergeParentModelOverrides(
315
+ user: Record<string, Record<string, AgentOverride>> | undefined,
316
+ project: Record<string, Record<string, AgentOverride>> | undefined,
317
+ ): Record<string, Record<string, AgentOverride>> {
318
+ const result = Object.create(null) as Record<
319
+ string,
320
+ Record<string, AgentOverride>
321
+ >;
322
+ for (const model of new Set([
323
+ ...Object.keys(user ?? {}),
324
+ ...Object.keys(project ?? {}),
325
+ ])) {
326
+ result[model] = mergeOverrides(user?.[model], project?.[model]);
327
+ }
328
+ return result;
329
+ }
330
+
331
+ /**
332
+ * Temporary compatibility reader. It accepts a working directory and returns
333
+ * the merged view (project fields override user fields). Deliberately rereads
334
+ * the two small JSON files: this bridge lasts one release, and avoiding a cache
335
+ * makes file create/edit/delete visible on the very next dispatch.
336
+ */
211
337
  export function loadDelegateSettings(cwd: string): DelegateSettings | null {
212
338
  const key = path.resolve(cwd);
213
- const cached = delegateSettingsCache.get(key);
214
- if (cached !== undefined) return cached;
215
-
216
339
  const userPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
217
- // Look for project root by finding .pi/ directory, not just .pi/agents/
218
- let projectPath: string | null = null;
340
+ let projectPath: string | undefined;
219
341
  let dir = key;
220
342
  const root = path.resolve("/");
221
- while (true) {
343
+ for (;;) {
222
344
  if (fs.existsSync(path.join(dir, ".pi"))) {
223
345
  projectPath = path.join(dir, ".pi", "settings.json");
224
346
  break;
@@ -229,13 +351,12 @@ export function loadDelegateSettings(cwd: string): DelegateSettings | null {
229
351
  dir = parent;
230
352
  }
231
353
 
232
- const user = getDelegateSettings(userPath);
233
- const project = projectPath ? getDelegateSettings(projectPath) : null;
234
-
354
+ const user = readLegacyDelegateSettings(userPath);
355
+ const project = projectPath ? readLegacyDelegateSettings(projectPath) : null;
235
356
  if (!user && !project) {
236
- delegateSettingsCache.set(key, null);
237
357
  return null;
238
358
  }
359
+
239
360
  const result: DelegateSettings = {
240
361
  ...(user?.agentOverrides || project?.agentOverrides
241
362
  ? {
@@ -255,46 +376,43 @@ export function loadDelegateSettings(cwd: string): DelegateSettings | null {
255
376
  }
256
377
  : {}),
257
378
  };
258
- delegateSettingsCache.set(key, result);
259
379
  return result;
260
380
  }
261
381
 
262
- function mergeOverride(
263
- base: AgentOverride | undefined,
264
- override: AgentOverride | undefined,
265
- ): AgentOverride {
266
- return { ...(base ?? {}), ...(override ?? {}) };
267
- }
268
-
269
- function mergeOverrides(
270
- user: Record<string, AgentOverride> | undefined,
271
- project: Record<string, AgentOverride> | undefined,
272
- ): Record<string, AgentOverride> {
273
- const result: Record<string, AgentOverride> = {};
274
- for (const name of new Set([
275
- ...Object.keys(user ?? {}),
276
- ...Object.keys(project ?? {}),
277
- ])) {
278
- result[name] = mergeOverride(user?.[name], project?.[name]);
279
- }
280
- return result;
281
- }
282
-
283
- function mergeParentModelOverrides(
284
- user: Record<string, Record<string, AgentOverride>> | undefined,
285
- project: Record<string, Record<string, AgentOverride>> | undefined,
286
- ): Record<string, Record<string, AgentOverride>> {
287
- const result: Record<string, Record<string, AgentOverride>> = {};
288
- for (const model of new Set([
289
- ...Object.keys(user ?? {}),
290
- ...Object.keys(project ?? {}),
291
- ])) {
292
- result[model] = mergeOverrides(user?.[model], project?.[model]);
382
+ /** Warn (once per cwd) when pi settings files still carry a legacy `delegate`
383
+ * block. The same message goes to stderr and, when available, the TUI. Keys
384
+ * include both cwd and source so a newly-created project setting is not hidden
385
+ * by an earlier user-setting warning. */
386
+ export function warnLegacyDelegateSettingsMoved(
387
+ cwd: string,
388
+ notify?: (message: string) => void,
389
+ ): void {
390
+ const cwdKey = path.resolve(cwd);
391
+ const paths = findLegacyDelegateSettings(cwd);
392
+ if (paths.length === 0) return;
393
+ for (const filePath of paths) {
394
+ const warningKey = `${cwdKey}\0${filePath}`;
395
+ if (warnedLegacySources.has(warningKey)) continue;
396
+ warnedLegacySources.add(warningKey);
397
+ const message =
398
+ `[delegate] TEMPORARY legacy compatibility: using model/thinking overrides from ${filePath}. ` +
399
+ "Move user-global overrides to ~/.pi/agent/delegate.json. Project-local overrides have no future config-file equivalent; use .pi/agents/*.md or explicit task model/thinking fields. " +
400
+ "Legacy settings compatibility is available for v0.1.12 only and will be removed in v0.1.13. Legacy tools overrides are not honored.";
401
+ console.warn(message);
402
+ try {
403
+ notify?.(message);
404
+ } catch (error) {
405
+ console.error(
406
+ "[delegate] legacy settings TUI notification failed",
407
+ error,
408
+ );
409
+ }
293
410
  }
294
- return result;
295
411
  }
296
412
 
297
- /** Clear settings read from earlier delegate calls so edits are visible. */
413
+ /** @deprecated Clear the set of cwd's already warned about legacy settings. */
298
414
  export function clearDelegateSettingsCache(): void {
299
- delegateSettingsCache.clear();
415
+ warnedLegacySources.clear();
416
+ warnedLegacyDetectionFailures.clear();
417
+ legacyDetectionCache.clear();
300
418
  }
@@ -0,0 +1,273 @@
1
+ import { execFile } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import { mapConcurrent } from "./concurrency.ts";
4
+ import { isPathWithinDirectoryLexical } from "./trusted-paths.ts";
5
+ import type { ResolvedTask } from "./types.ts";
6
+
7
+ const GIT_TIMEOUT_MS = 5_000;
8
+ const PREFLIGHT_CONCURRENCY = 4;
9
+ const NON_MUTATING_TOOLS = new Set([
10
+ "read",
11
+ "grep",
12
+ "find",
13
+ "ls",
14
+ "web_search",
15
+ ]);
16
+ const GIT_REDIRECTS = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] as const;
17
+
18
+ export interface SharedWriteScope {
19
+ kind: "git" | "directory";
20
+ root: string;
21
+ /** Additional physical roots reachable from the task cwd. Present only
22
+ * while resolving admissions; conflict results expose the witness root. */
23
+ roots?: ReachRoot[];
24
+ }
25
+
26
+ interface ReachRoot {
27
+ kind: "git" | "directory";
28
+ root: string;
29
+ }
30
+
31
+ export interface SharedWriteConflict {
32
+ scope: SharedWriteScope;
33
+ taskIndexes: number[];
34
+ }
35
+
36
+ export class SharedWriteSafetyError extends Error {}
37
+
38
+ class GitScopeError extends Error {
39
+ constructor(
40
+ message: string,
41
+ readonly stderr: string,
42
+ options?: ErrorOptions,
43
+ ) {
44
+ super(message, options);
45
+ }
46
+ }
47
+
48
+ function gitRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string> {
49
+ const env = Object.fromEntries(
50
+ Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")),
51
+ );
52
+ return new Promise((resolve, reject) => {
53
+ execFile(
54
+ "git",
55
+ ["rev-parse", "--show-toplevel"],
56
+ {
57
+ cwd,
58
+ signal,
59
+ timeout: GIT_TIMEOUT_MS,
60
+ maxBuffer: 1024 * 1024,
61
+ env: {
62
+ ...env,
63
+ LC_ALL: "C",
64
+ LANG: "C",
65
+ GIT_OPTIONAL_LOCKS: "0",
66
+ GIT_DISCOVERY_ACROSS_FILESYSTEM: "1",
67
+ },
68
+ },
69
+ (error, stdout, stderr) => {
70
+ if (error) {
71
+ reject(
72
+ new GitScopeError(error.message, stderr.trim(), { cause: error }),
73
+ );
74
+ return;
75
+ }
76
+ resolve(stdout.trim());
77
+ },
78
+ );
79
+ });
80
+ }
81
+
82
+ /** Resolve a primary write boundary and, for an external core.worktree, the
83
+ * physical cwd that remains reachable. Plain directories use the task cwd.
84
+ * Only Git's explicit "not a repository" permits directory fallback. */
85
+ export async function resolveSharedWriteScope(
86
+ cwd: string,
87
+ signal?: AbortSignal,
88
+ ): Promise<SharedWriteScope> {
89
+ let physicalCwd: string;
90
+ try {
91
+ physicalCwd = await fs.promises.realpath(cwd);
92
+ } catch (error) {
93
+ throw new SharedWriteSafetyError(
94
+ `Could not resolve task directory '${cwd}'.`,
95
+ { cause: error },
96
+ );
97
+ }
98
+
99
+ try {
100
+ const root = await gitRepositoryRoot(physicalCwd, signal);
101
+ if (!root) {
102
+ throw new SharedWriteSafetyError(
103
+ `Git returned an empty repository root for '${physicalCwd}'.`,
104
+ );
105
+ }
106
+ const physicalRoot = await fs.promises.realpath(root);
107
+ const roots: ReachRoot[] = [{ kind: "git", root: physicalRoot }];
108
+ if (!isPathWithinDirectoryLexical(physicalRoot, physicalCwd)) {
109
+ roots.push({ kind: "directory", root: physicalCwd });
110
+ }
111
+ return { kind: "git", root: physicalRoot, roots };
112
+ } catch (error) {
113
+ if (
114
+ error instanceof GitScopeError &&
115
+ /not a git repository/i.test(error.stderr)
116
+ ) {
117
+ return { kind: "directory", root: physicalCwd };
118
+ }
119
+ if (error instanceof SharedWriteSafetyError) throw error;
120
+ const detail =
121
+ error instanceof GitScopeError
122
+ ? error.stderr || error.message
123
+ : error instanceof Error
124
+ ? error.message
125
+ : String(error);
126
+ throw new SharedWriteSafetyError(
127
+ `Could not safely determine the Git root for '${physicalCwd}': ${detail}`,
128
+ { cause: error },
129
+ );
130
+ }
131
+ }
132
+
133
+ export function isSharedWriter(task: ResolvedTask): boolean {
134
+ return (
135
+ task.workspace === "shared" &&
136
+ task.tools.some((tool) => !NON_MUTATING_TOOLS.has(tool))
137
+ );
138
+ }
139
+
140
+ function reachRoots(scope: SharedWriteScope): ReachRoot[] {
141
+ return scope.roots ?? [{ kind: scope.kind, root: scope.root }];
142
+ }
143
+
144
+ function rootsOverlap(left: ReachRoot, right: ReachRoot): boolean {
145
+ return (
146
+ isPathWithinDirectoryLexical(left.root, right.root) ||
147
+ isPathWithinDirectoryLexical(right.root, left.root)
148
+ );
149
+ }
150
+
151
+ function shallower(left: ReachRoot, right: ReachRoot): ReachRoot {
152
+ return isPathWithinDirectoryLexical(left.root, right.root) ? left : right;
153
+ }
154
+
155
+ function overlapWitness(
156
+ left: SharedWriteScope,
157
+ right: SharedWriteScope,
158
+ ): ReachRoot | undefined {
159
+ let witness: ReachRoot | undefined;
160
+ for (const leftRoot of reachRoots(left)) {
161
+ for (const rightRoot of reachRoots(right)) {
162
+ if (!rootsOverlap(leftRoot, rightRoot)) continue;
163
+ const candidate = shallower(leftRoot, rightRoot);
164
+ if (
165
+ witness === undefined ||
166
+ isPathWithinDirectoryLexical(candidate.root, witness.root)
167
+ ) {
168
+ witness = candidate;
169
+ }
170
+ }
171
+ }
172
+ return witness;
173
+ }
174
+
175
+ /** Find connected overlapping writer scopes while preserving task order.
176
+ * Unknown tools fail closed as mutating. This is an in-process admission
177
+ * boundary, not filesystem confinement or a claim about external processes. */
178
+ export async function findSharedWriteConflicts(
179
+ tasks: readonly ResolvedTask[],
180
+ signal?: AbortSignal,
181
+ ): Promise<SharedWriteConflict[]> {
182
+ const candidates = tasks
183
+ .map((task, taskIndex) => ({ task, taskIndex }))
184
+ .filter(({ task }) => isSharedWriter(task));
185
+ if (candidates.length < 2) return [];
186
+
187
+ const redirects = GIT_REDIRECTS.filter(
188
+ (name) => process.env[name] !== undefined,
189
+ );
190
+ if (
191
+ redirects.length &&
192
+ candidates.some(({ task }) => task.tools.includes("bash"))
193
+ ) {
194
+ throw new SharedWriteSafetyError(
195
+ `Could not safely verify a bash-capable shared-write batch while ${redirects.join(", ")} redirects Git repository context.`,
196
+ );
197
+ }
198
+
199
+ const uniqueCwds = [...new Set(candidates.map(({ task }) => task.cwd))];
200
+ const scopes = await mapConcurrent(uniqueCwds, PREFLIGHT_CONCURRENCY, (cwd) =>
201
+ resolveSharedWriteScope(cwd, signal),
202
+ );
203
+ const byCwd = new Map(
204
+ uniqueCwds.map((cwd, index) => [cwd, scopes[index]!] as const),
205
+ );
206
+ const resolved = candidates.map(({ task, taskIndex }) => ({
207
+ taskIndex,
208
+ scope: byCwd.get(task.cwd)!,
209
+ }));
210
+
211
+ const parents = resolved.map((_, index) => index);
212
+ const find = (index: number): number => {
213
+ while (parents[index] !== index) {
214
+ parents[index] = parents[parents[index]!]!;
215
+ index = parents[index]!;
216
+ }
217
+ return index;
218
+ };
219
+ const union = (left: number, right: number): void => {
220
+ const a = find(left);
221
+ const b = find(right);
222
+ if (a !== b) parents[b] = a;
223
+ };
224
+ for (let left = 0; left < resolved.length; left++) {
225
+ for (let right = left + 1; right < resolved.length; right++) {
226
+ if (
227
+ overlapWitness(resolved[left]!.scope, resolved[right]!.scope) !==
228
+ undefined
229
+ ) {
230
+ union(left, right);
231
+ }
232
+ }
233
+ }
234
+
235
+ const components = new Map<number, number[]>();
236
+ for (let index = 0; index < resolved.length; index++) {
237
+ const root = find(index);
238
+ const members = components.get(root);
239
+ if (members) members.push(index);
240
+ else components.set(root, [index]);
241
+ }
242
+
243
+ const conflicts: SharedWriteConflict[] = [];
244
+ for (const members of components.values()) {
245
+ if (members.length < 2) continue;
246
+ let witness: ReachRoot | undefined;
247
+ for (let left = 0; left < members.length; left++) {
248
+ for (let right = left + 1; right < members.length; right++) {
249
+ const candidate = overlapWitness(
250
+ resolved[members[left]!]!.scope,
251
+ resolved[members[right]!]!.scope,
252
+ );
253
+ if (
254
+ candidate &&
255
+ (witness === undefined ||
256
+ isPathWithinDirectoryLexical(candidate.root, witness.root))
257
+ ) {
258
+ witness = candidate;
259
+ }
260
+ }
261
+ }
262
+ if (!witness) {
263
+ throw new SharedWriteSafetyError(
264
+ "Invariant violation: connected writer scopes have no overlap witness.",
265
+ );
266
+ }
267
+ conflicts.push({
268
+ scope: { kind: witness.kind, root: witness.root },
269
+ taskIndexes: members.map((index) => resolved[index]!.taskIndex),
270
+ });
271
+ }
272
+ return conflicts;
273
+ }