@brainervirus/workit-core 7.2.0 → 7.4.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/src/fanout.ts ADDED
@@ -0,0 +1,916 @@
1
+ // `workit fanout plan` (G1): the slice registry for parallel work.
2
+ //
3
+ // A plan names the slices one lead fans out: each has an id, a branch, a base,
4
+ // a worktree, a model TIER, its dependencies and a brief (GOAL, SCOPE as a
5
+ // file-scope manifest, ACCEPTANCE, VERIFY, FORBIDDEN). The file lives at
6
+ // `<store>/fanouts/<name>.json` (D13: under the git common dir, shared by
7
+ // every worktree, surviving worktree removal), next to the stacks, and each
8
+ // plan appends an observed `fanout.planned` ledger row.
9
+ //
10
+ // Planning refuses (`invalid_input`) a brief with an empty or placeholder
11
+ // field, unknown or cyclic dependencies and malformed scopes, and refuses
12
+ // (`blocked`) two slices that may write the same file unless that is
13
+ // resolved: exactly one slice lists the file under `owns`, or the slices are
14
+ // serialized by a dependency. Overlap is evaluated on the trunk's tracked
15
+ // files plus one sample path per glob (`src/new/**` -> `src/new/<any>`), so
16
+ // directories nobody has created yet are compared too, and paths differing
17
+ // only in case count as one file. Each unresolved overlap carries one
18
+ // deterministic suggestion: an owner for known shared files (lockfiles,
19
+ // manifests, barrels, CI config), else a dependency that serializes them.
20
+ //
21
+ // The CLI never spawns, waits or wakes anything: it only records and checks.
22
+ import { spawnSync } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
26
+ import { appendObserved, defaultBase, storeRoot, type LedgerActor } from "./ledger";
27
+ import { stackFileName } from "./stack";
28
+
29
+ export const FANOUT_VERSION = 1;
30
+ export const TIERS = ["mundane", "standard", "hard"] as const;
31
+ export type Tier = (typeof TIERS)[number];
32
+
33
+ export type Slice = {
34
+ id: string;
35
+ branch: string;
36
+ /** The branch it is cut from and diffed against: the trunk, or its single dependency's branch. */
37
+ base: string;
38
+ worktree: string;
39
+ tier: Tier;
40
+ dependsOn: string[];
41
+ /** The file-scope manifest: globs (`*`, `**`, `?`, `{a,b}`) relative to the repo root. */
42
+ scope: string[];
43
+ /** Shared files this slice alone may write, even where another scope matches them. */
44
+ owns: string[];
45
+ goal: string;
46
+ acceptance: string[];
47
+ verify: string[];
48
+ forbidden: string[];
49
+ context: string | null;
50
+ timebox: string | null;
51
+ };
52
+
53
+ export type FanoutFile = {
54
+ v: number;
55
+ name: string;
56
+ trunk: string;
57
+ /** Extra shared-file globs on top of DEFAULT_SHARED. */
58
+ shared: string[];
59
+ slices: Slice[];
60
+ createdAt: string;
61
+ updatedAt: string;
62
+ };
63
+
64
+ export type FanoutError = {
65
+ ok: false;
66
+ code: "blocked" | "busy" | "failed" | "unavailable" | "not_found" | "invalid_input";
67
+ error: string;
68
+ unblock?: string;
69
+ data?: Record<string, unknown>;
70
+ };
71
+ export type FanoutResult<T> = { ok: true; data: T } | FanoutError;
72
+
73
+ export const fanoutFail = (
74
+ code: FanoutError["code"],
75
+ error: string,
76
+ unblock?: string,
77
+ data?: Record<string, unknown>,
78
+ ): FanoutError => ({
79
+ ok: false,
80
+ code,
81
+ error,
82
+ ...(unblock ? { unblock } : {}),
83
+ ...(data ? { data } : {}),
84
+ });
85
+
86
+ /** Files that every slice tends to touch; overlap on them is resolved by an owner. */
87
+ export const DEFAULT_SHARED: readonly string[] = [
88
+ "**/package.json",
89
+ "**/{package-lock.json,npm-shrinkwrap.json,bun.lock,bun.lockb,pnpm-lock.yaml,pnpm-workspace.yaml,yarn.lock}",
90
+ "**/{Cargo.lock,go.mod,go.sum,poetry.lock,uv.lock,Gemfile.lock,composer.lock}",
91
+ "**/index.{ts,tsx,js,jsx,mjs,cjs}",
92
+ "**/tsconfig.json",
93
+ ".github/**",
94
+ ".gitlab-ci.yml",
95
+ "CHANGELOG.md",
96
+ ];
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // scope globs
100
+
101
+ const escapeRegExp = (text: string): string => text.replace(/[.+^$()|[\]\\{}*?]/gu, "\\$&");
102
+ /** The pattern with `\x` escapes resolved: what a wildcard-free pattern names. */
103
+ const unescape = (pattern: string): string => pattern.replace(/\\(.)/gu, "$1");
104
+ /** Has an unescaped `*`, `?` or `{`. */
105
+ const hasWildcard = (pattern: string): boolean => /[*?{]/u.test(pattern.replace(/\\./gu, ""));
106
+
107
+ /**
108
+ * One concrete path the pattern matches, standing in for files nobody has
109
+ * created yet: `src/new/**` -> `src/new/<any>`, `src/*.ts` -> `src/<any>.ts`.
110
+ */
111
+ export function scopeSamples(pattern: string): string[] {
112
+ // A plain path covers what is below it too, so `src/new` and `src/new/**`
113
+ // overlap through `src/new/<any>` without a second sample.
114
+ if (!hasWildcard(pattern)) return [unescape(pattern)];
115
+ let out = "";
116
+ for (let index = 0; index < pattern.length; index += 1) {
117
+ const char = pattern[index];
118
+ if (char === "\\" && index + 1 < pattern.length) {
119
+ out += pattern[index + 1];
120
+ index += 1;
121
+ } else if (char === "*" && pattern[index + 1] === "*") {
122
+ const atSegmentStart = index === 0 || pattern[index - 1] === "/";
123
+ if (atSegmentStart && pattern[index + 2] === "/") index += 2;
124
+ else {
125
+ out += "<any>";
126
+ index += 1;
127
+ }
128
+ } else if (char === "*") out += "<any>";
129
+ else if (char === "?") out += "x";
130
+ else if (char === "{" && pattern.indexOf("}", index) > index) {
131
+ const close = pattern.indexOf("}", index);
132
+ out += pattern.slice(index + 1, close).split(",")[0];
133
+ index = close;
134
+ } else out += char;
135
+ }
136
+ return [out];
137
+ }
138
+
139
+ function globSource(pattern: string): string {
140
+ let out = "";
141
+ for (let index = 0; index < pattern.length; index += 1) {
142
+ const char = pattern[index];
143
+ if (char === "\\" && index + 1 < pattern.length) {
144
+ out += escapeRegExp(pattern[index + 1]);
145
+ index += 1;
146
+ } else if (char === "*" && pattern[index + 1] === "*") {
147
+ const atSegmentStart = index === 0 || pattern[index - 1] === "/";
148
+ if (atSegmentStart && pattern[index + 2] === "/") {
149
+ out += "(?:[^/]*/)*";
150
+ index += 2;
151
+ } else {
152
+ out += ".*";
153
+ index += 1;
154
+ }
155
+ } else if (char === "*") out += "[^/]*";
156
+ else if (char === "?") out += "[^/]";
157
+ else if (char === "{" && pattern.indexOf("}", index) > index) {
158
+ // An unclosed brace is a literal `{` (stored plans are not trusted).
159
+ const close = pattern.indexOf("}", index);
160
+ const alternatives = pattern
161
+ .slice(index + 1, close)
162
+ .split(",")
163
+ .map(globSource);
164
+ out += `(?:${alternatives.join("|")})`;
165
+ index = close;
166
+ } else out += escapeRegExp(char);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ const compiled = new Map<string, RegExp>();
172
+
173
+ /**
174
+ * Does `file` (repo-relative, `/`-separated) fall under `pattern`? Dotfiles
175
+ * match like any other name, the same on every runtime. A pattern without
176
+ * wildcards also covers everything below it, so `src/api` is a directory
177
+ * scope as well as a file.
178
+ */
179
+ export function matchesScope(pattern: string, file: string): boolean {
180
+ let regex = compiled.get(pattern);
181
+ if (!regex) {
182
+ regex = new RegExp(`^${globSource(pattern)}$`, "u");
183
+ compiled.set(pattern, regex);
184
+ }
185
+ return regex.test(file) || (!hasWildcard(pattern) && file.startsWith(`${unescape(pattern)}/`));
186
+ }
187
+
188
+ const matchesAny = (patterns: readonly string[], file: string): boolean =>
189
+ patterns.some((pattern) => matchesScope(pattern, file));
190
+
191
+ /** A scope entry in canonical form, or why it is refused. */
192
+ function normalizePattern(raw: string): { pattern: string } | { error: string } {
193
+ // A backslash escapes a glob character (`app/\\[id\\]`); any other one is a
194
+ // Windows separator.
195
+ let pattern = raw.trim().replace(/\\(?![[\]{}*?])/gu, "/");
196
+ while (pattern.startsWith("./")) pattern = pattern.slice(2);
197
+ if (pattern === "." || pattern === "") pattern = raw.trim() ? "**" : "";
198
+ if (pattern.endsWith("/")) pattern = `${pattern}**`;
199
+ if (!pattern) return { error: "is empty" };
200
+ if (pattern.startsWith("/") || /^[A-Za-z]:/u.test(pattern))
201
+ return { error: "must be relative to the repository root" };
202
+ if (pattern.split("/").includes("..")) return { error: "must not contain .." };
203
+ const bare = pattern.replace(/\\./gu, "");
204
+ if (/[[\]]/u.test(bare))
205
+ return {
206
+ error:
207
+ 'has an unescaped [ or ]: write \\[ and \\] for literal brackets (app/\\[id\\]/page.tsx; in JSON "app/\\\\[id\\\\]/page.tsx")',
208
+ };
209
+ if (bare.startsWith("!")) return { error: "uses ! (negation is not supported)" };
210
+ let depth = 0;
211
+ for (const char of bare) {
212
+ if (char === "{") depth += 1;
213
+ if (char === "}") depth -= 1;
214
+ if (depth < 0 || depth > 1) return { error: "has unbalanced or nested braces" };
215
+ }
216
+ if (depth !== 0) return { error: "has unbalanced or nested braces" };
217
+ return { pattern };
218
+ }
219
+
220
+ /** The slice may write `file`: its scope or owns matches, and no other slice owns it. */
221
+ export function inScope(plan: FanoutFile, slice: Slice, file: string): boolean {
222
+ if (matchesAny(slice.owns, file)) return true;
223
+ if (!matchesAny(slice.scope, file)) return false;
224
+ return !plan.slices.some((other) => other.id !== slice.id && matchesAny(other.owns, file));
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // parse (input and stored file; D17: unknown keys are ignored)
229
+
230
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
231
+ typeof value === "object" && value !== null && !Array.isArray(value);
232
+
233
+ const PLACEHOLDER = /^(?:<[^>]*>|tbd|todo|fixme|\?+|\.\.\.|…)$/iu;
234
+ const filled = (text: string): boolean => text.trim() !== "" && !PLACEHOLDER.test(text.trim());
235
+
236
+ /** A string or list of strings; null when absent or of another type. */
237
+ const textList = (value: unknown): string[] | null => {
238
+ if (typeof value === "string") return [value];
239
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
240
+ return null;
241
+ };
242
+
243
+ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
244
+
245
+ export type PlanDefaults = {
246
+ trunk: string;
247
+ /** Where default worktrees go: `<repo>-wt/` next to the main checkout. */
248
+ worktreeRoot: string;
249
+ };
250
+
251
+ type Parsed = { ok: true; plan: FanoutFile } | { ok: false; problems: string[] };
252
+
253
+ /**
254
+ * Validate a plan document. Every problem is reported at once, so one edit
255
+ * fixes them all; the brief fields GOAL, SCOPE, ACCEPTANCE, VERIFY and
256
+ * FORBIDDEN must be non-empty and not a template placeholder.
257
+ */
258
+ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string | null): Parsed {
259
+ const problems: string[] = [];
260
+ if (!isRecord(raw)) return { ok: false, problems: ["the plan must be a JSON object"] };
261
+ const planName = name ?? (typeof raw.name === "string" ? raw.name.trim() : "");
262
+ if (!planName || planName.length > 200 || /\p{Cc}/u.test(planName))
263
+ problems.push('name: pass --name or set a 1-200 character "name"');
264
+ const trunk =
265
+ typeof raw.trunk === "string" && raw.trunk.trim() ? raw.trunk.trim() : defaults.trunk;
266
+ const shared: string[] = [];
267
+ for (const entry of textList(raw.shared ?? []) ?? ["<not a list>"]) {
268
+ const normalized = normalizePattern(entry);
269
+ if ("error" in normalized) problems.push(`shared "${entry}" ${normalized.error}`);
270
+ else shared.push(normalized.pattern);
271
+ }
272
+ if (!Array.isArray(raw.slices) || raw.slices.length === 0)
273
+ return { ok: false, problems: [...problems, "slices: list at least one slice"] };
274
+
275
+ const slices: Slice[] = [];
276
+ const seenIds = new Set<string>();
277
+ const seenBranches = new Map<string, string>();
278
+ raw.slices.forEach((item, index) => {
279
+ const where =
280
+ isRecord(item) && typeof item.id === "string" ? `slice ${item.id}` : `slice #${index + 1}`;
281
+ if (!isRecord(item)) {
282
+ problems.push(`${where}: must be an object`);
283
+ return;
284
+ }
285
+ const id = typeof item.id === "string" ? item.id : "";
286
+ if (!ID.test(id)) problems.push(`${where}: id must match ${ID.source}`);
287
+ else if (seenIds.has(id)) problems.push(`${where}: duplicate id`);
288
+ seenIds.add(id);
289
+ const branch = typeof item.branch === "string" ? item.branch.trim() : "";
290
+ if (!branch) problems.push(`${where}: branch is empty`);
291
+ else if (seenBranches.has(branch))
292
+ problems.push(`${where}: branch ${branch} is also slice ${seenBranches.get(branch)}'s`);
293
+ else seenBranches.set(branch, id);
294
+ if (branch === trunk) problems.push(`${where}: branch must not be the trunk ${trunk}`);
295
+
296
+ const goal = typeof item.goal === "string" ? item.goal.trim() : "";
297
+ if (!filled(goal)) problems.push(`${where}: goal is empty`);
298
+ const brief = (field: string): string[] => {
299
+ const list = textList(item[field]);
300
+ const kept = (list ?? []).map((entry) => entry.trim()).filter(filled);
301
+ if (kept.length === 0) problems.push(`${where}: ${field} is empty`);
302
+ return kept;
303
+ };
304
+ const acceptance = brief("acceptance");
305
+ const verify = brief("verify");
306
+ const forbidden = brief("forbidden");
307
+ const scope: string[] = [];
308
+ const owns: string[] = [];
309
+ for (const [field, out] of [
310
+ ["scope", scope],
311
+ ["owns", owns],
312
+ ] as const) {
313
+ const list = textList(item[field] ?? []);
314
+ if (list === null) {
315
+ problems.push(`${where}: ${field} must be a list of globs`);
316
+ continue;
317
+ }
318
+ for (const entry of list.filter(filled)) {
319
+ const normalized = normalizePattern(entry);
320
+ if ("error" in normalized)
321
+ problems.push(`${where}: ${field} "${entry}" ${normalized.error}`);
322
+ else out.push(normalized.pattern);
323
+ }
324
+ }
325
+ if (scope.length === 0 && textList(item.scope ?? []) !== null)
326
+ problems.push(`${where}: scope is empty`);
327
+ const tier = item.tier;
328
+ if (typeof tier !== "string" || !(TIERS as readonly string[]).includes(tier))
329
+ problems.push(`${where}: tier must be one of ${TIERS.join(", ")}`);
330
+ const dependsOn = textList(item.dependsOn ?? []);
331
+ if (dependsOn === null) problems.push(`${where}: dependsOn must be a list of slice ids`);
332
+ const optional = (field: string): string | null =>
333
+ typeof item[field] === "string" && filled(item[field]) ? item[field].trim() : null;
334
+ slices.push({
335
+ id,
336
+ branch,
337
+ base: optional("base") ?? "",
338
+ worktree: optional("worktree") ?? path.join(defaults.worktreeRoot, id),
339
+ tier: tier as Tier,
340
+ dependsOn: [...new Set(dependsOn ?? [])],
341
+ scope,
342
+ owns,
343
+ goal,
344
+ acceptance,
345
+ verify,
346
+ forbidden,
347
+ context: optional("context"),
348
+ timebox: optional("timebox"),
349
+ });
350
+ });
351
+
352
+ const byId = new Map(slices.map((slice) => [slice.id, slice]));
353
+ const byBranch = new Map(slices.map((slice) => [slice.branch, slice]));
354
+ for (const slice of slices) {
355
+ for (const dep of slice.dependsOn) {
356
+ if (dep === slice.id) problems.push(`slice ${slice.id}: depends on itself`);
357
+ else if (!byId.has(dep)) problems.push(`slice ${slice.id}: depends on unknown slice ${dep}`);
358
+ }
359
+ const explicitBase = slice.base;
360
+ if (explicitBase) {
361
+ const parent = byBranch.get(explicitBase);
362
+ if (parent && !slice.dependsOn.includes(parent.id))
363
+ problems.push(
364
+ `slice ${slice.id}: base ${explicitBase} is slice ${parent.id}'s branch; add "${parent.id}" to its dependsOn`,
365
+ );
366
+ } else {
367
+ const single = slice.dependsOn.length === 1 ? byId.get(slice.dependsOn[0]) : undefined;
368
+ slice.base = single ? single.branch : trunk;
369
+ }
370
+ }
371
+ const cycle = findCycle(slices);
372
+ if (cycle) problems.push(`dependency cycle: ${cycle.join(" -> ")}`);
373
+ if (problems.length) return { ok: false, problems };
374
+ return {
375
+ ok: true,
376
+ plan: {
377
+ v: FANOUT_VERSION,
378
+ name: planName,
379
+ trunk,
380
+ shared,
381
+ slices,
382
+ createdAt: "",
383
+ updatedAt: "",
384
+ },
385
+ };
386
+ }
387
+
388
+ function findCycle(slices: readonly Slice[]): string[] | null {
389
+ const byId = new Map(slices.map((slice) => [slice.id, slice]));
390
+ const state = new Map<string, "open" | "done">();
391
+ const trail: string[] = [];
392
+ const visit = (id: string): string[] | null => {
393
+ if (state.get(id) === "done") return null;
394
+ if (state.get(id) === "open") return [...trail.slice(trail.indexOf(id)), id];
395
+ state.set(id, "open");
396
+ trail.push(id);
397
+ for (const dep of byId.get(id)?.dependsOn ?? []) {
398
+ if (!byId.has(dep) || dep === id) continue;
399
+ const found = visit(dep);
400
+ if (found) return found;
401
+ }
402
+ trail.pop();
403
+ state.set(id, "done");
404
+ return null;
405
+ };
406
+ for (const slice of slices) {
407
+ const found = visit(slice.id);
408
+ if (found) return found;
409
+ }
410
+ return null;
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // ordering
415
+
416
+ /** Dependencies first; among the ready ones, the order the plan lists them. */
417
+ export function landingOrder(slices: readonly Slice[]): string[] {
418
+ const order: string[] = [];
419
+ const placed = new Set<string>();
420
+ const known = new Set(slices.map((slice) => slice.id));
421
+ while (order.length < slices.length) {
422
+ const next = slices.find(
423
+ (slice) =>
424
+ !placed.has(slice.id) && slice.dependsOn.every((dep) => placed.has(dep) || !known.has(dep)),
425
+ );
426
+ if (!next) break;
427
+ order.push(next.id);
428
+ placed.add(next.id);
429
+ }
430
+ return order;
431
+ }
432
+
433
+ /** Slices that can run at the same time: wave n waits only for waves before it. */
434
+ export function waves(slices: readonly Slice[]): string[][] {
435
+ const level = new Map<string, number>();
436
+ const byId = new Map(slices.map((slice) => [slice.id, slice]));
437
+ for (const id of landingOrder(slices)) {
438
+ const deps = byId.get(id)?.dependsOn ?? [];
439
+ level.set(id, deps.length ? Math.max(...deps.map((dep) => level.get(dep) ?? 0)) + 1 : 0);
440
+ }
441
+ const out: string[][] = [];
442
+ for (const slice of slices) (out[level.get(slice.id) ?? 0] ??= []).push(slice.id);
443
+ return out.filter(Boolean);
444
+ }
445
+
446
+ /** Every slice `id` waits for, directly or through another dependency. */
447
+ function ancestors(slices: readonly Slice[]): Map<string, Set<string>> {
448
+ const byId = new Map(slices.map((slice) => [slice.id, slice]));
449
+ const out = new Map<string, Set<string>>();
450
+ for (const id of landingOrder(slices)) {
451
+ const set = new Set<string>();
452
+ for (const dep of byId.get(id)?.dependsOn ?? []) {
453
+ set.add(dep);
454
+ for (const deeper of out.get(dep) ?? []) set.add(deeper);
455
+ }
456
+ out.set(id, set);
457
+ }
458
+ return out;
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // overlap
463
+
464
+ export type Overlap = {
465
+ path: string;
466
+ slices: string[];
467
+ resolution: "owner" | "serialized";
468
+ owner?: string;
469
+ };
470
+
471
+ type OwnerSuggestion = { kind: "owner"; slice: string; paths: string[]; text: string };
472
+ type Suggestion =
473
+ | OwnerSuggestion
474
+ | { kind: "serialize"; edges: Array<{ slice: string; dependsOn: string }>; text: string };
475
+
476
+ export type OverlapConflict = {
477
+ slices: string[];
478
+ paths: string[];
479
+ /** Every path is a known shared file (lockfile, manifest, barrel, CI config). */
480
+ shared: boolean;
481
+ reason: "overlap" | "several_owners";
482
+ suggestion: Suggestion;
483
+ alternative?: Suggestion;
484
+ };
485
+
486
+ const quoteList = (items: readonly string[]): string => items.map((item) => `"${item}"`).join(", ");
487
+
488
+ const ownerSuggestion = (slice: string, paths: string[]): OwnerSuggestion => ({
489
+ kind: "owner",
490
+ slice,
491
+ paths,
492
+ text: `add ${quoteList(paths)} to "owns" of slice ${slice}`,
493
+ });
494
+
495
+ const serializeSuggestion = (ids: readonly string[]): Suggestion => {
496
+ const edges = ids.slice(1).map((slice, index) => ({ slice, dependsOn: ids[index] }));
497
+ return {
498
+ kind: "serialize",
499
+ edges,
500
+ text: edges
501
+ .map((edge) => `add "${edge.dependsOn}" to "dependsOn" of slice ${edge.slice}`)
502
+ .join("; "),
503
+ };
504
+ };
505
+
506
+ /**
507
+ * Which files more than one slice may write, given the candidate paths.
508
+ * Resolved overlaps (one owner, or every pair serialized by a dependency)
509
+ * are listed; the rest are conflicts grouped by the slices involved.
510
+ */
511
+ export function analyzeOverlap(
512
+ plan: FanoutFile,
513
+ candidates: Iterable<string>,
514
+ ): { overlaps: Overlap[]; conflicts: OverlapConflict[] } {
515
+ const shared = [...DEFAULT_SHARED, ...plan.shared];
516
+ const before = ancestors(plan.slices);
517
+ const related = (a: string, b: string): boolean =>
518
+ Boolean(before.get(a)?.has(b) || before.get(b)?.has(a));
519
+ const overlaps: Overlap[] = [];
520
+ const groups = new Map<string, OverlapConflict & { members: Slice[] }>();
521
+ // Paths that differ only in case are one file on case-insensitive checkouts.
522
+ const byCase = new Map<string, string[]>();
523
+ for (const file of [...new Set(candidates)].toSorted())
524
+ (
525
+ byCase.get(file.toLowerCase()) ?? byCase.set(file.toLowerCase(), []).get(file.toLowerCase())
526
+ )?.push(file);
527
+ for (const spellings of byCase.values()) {
528
+ const file = spellings.join(" ~ ");
529
+ const any = (patterns: readonly string[]) =>
530
+ spellings.some((spelling) => matchesAny(patterns, spelling));
531
+ const matching = plan.slices.filter((slice) => any(slice.scope) || any(slice.owns));
532
+ if (matching.length < 2) continue;
533
+ const ids = matching.map((slice) => slice.id);
534
+ const owners = matching.filter((slice) => any(slice.owns));
535
+ if (owners.length === 1) {
536
+ overlaps.push({ path: file, slices: ids, resolution: "owner", owner: owners[0].id });
537
+ continue;
538
+ }
539
+ if (
540
+ owners.length === 0 &&
541
+ matching.every((a, i) => matching.slice(i + 1).every((b) => related(a.id, b.id)))
542
+ ) {
543
+ overlaps.push({ path: file, slices: ids, resolution: "serialized" });
544
+ continue;
545
+ }
546
+ const members = owners.length > 1 ? owners : matching;
547
+ const reason = owners.length > 1 ? "several_owners" : "overlap";
548
+ const isShared = any(shared);
549
+ const key = `${reason}|${isShared}|${members.map((slice) => slice.id).join(",")}`;
550
+ const group = groups.get(key);
551
+ if (group) group.paths.push(file);
552
+ else
553
+ groups.set(key, {
554
+ slices: members.map((slice) => slice.id),
555
+ paths: [file],
556
+ shared: isShared,
557
+ reason,
558
+ members,
559
+ suggestion: ownerSuggestion(members[0].id, []),
560
+ });
561
+ }
562
+ const conflicts = [...groups.values()].map(({ members, ...group }) => {
563
+ // The owner: the first slice (plan order) that names one of the paths
564
+ // literally, else the first slice. Serializing keeps plan order.
565
+ const literal = members.find((slice) =>
566
+ slice.scope.some((pattern) => group.paths.includes(pattern)),
567
+ );
568
+ const owner = ownerSuggestion((literal ?? members[0]).id, group.paths);
569
+ if (group.reason === "several_owners")
570
+ return {
571
+ ...group,
572
+ suggestion: {
573
+ ...owner,
574
+ text: `keep ${quoteList(group.paths)} in "owns" of slice ${owner.slice} only`,
575
+ },
576
+ };
577
+ const serialize = serializeSuggestion(group.slices);
578
+ return group.shared
579
+ ? { ...group, suggestion: owner, alternative: serialize }
580
+ : { ...group, suggestion: serialize, alternative: owner };
581
+ });
582
+ return { overlaps, conflicts };
583
+ }
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // git
587
+
588
+ type GitRun = { ok: boolean; status: number | null; stdout: string; stderr: string };
589
+
590
+ export const gitRun = (cwd: string, args: string[]): GitRun => {
591
+ const run = spawnSync("git", args, {
592
+ cwd,
593
+ encoding: "utf8",
594
+ timeout: GIT_TIMEOUTS.local,
595
+ killSignal: "SIGKILL",
596
+ windowsHide: true,
597
+ maxBuffer: 64 * 1024 * 1024,
598
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C", LANGUAGE: "C" },
599
+ });
600
+ return {
601
+ ok: run.status === 0,
602
+ status: run.status,
603
+ stdout: run.stdout ?? "",
604
+ stderr: run.error ? String(run.error.message) : (run.stderr ?? ""),
605
+ };
606
+ };
607
+
608
+ export const nulList = (text: string): string[] => text.split("\0").filter(Boolean);
609
+
610
+ /** The commit a branch name points at: the local branch, else its remote-tracking ref. */
611
+ export function branchRef(cwd: string, branch: string): string | null {
612
+ if (!branch || branch.startsWith("-")) return null;
613
+ const local = resolveRef(cwd, `refs/heads/${branch}`);
614
+ if (local) return local;
615
+ const remote = pushRemoteName(cwd) ?? "origin";
616
+ return resolveRef(cwd, `refs/remotes/${remote}/${branch}`);
617
+ }
618
+
619
+ /** The trunk as the forge sees it (remote-tracking ref) when known, else the local branch. */
620
+ export function trunkRef(cwd: string, trunk: string): string | null {
621
+ if (!trunk || trunk.startsWith("-")) return null;
622
+ const remote = pushRemoteName(cwd) ?? "origin";
623
+ for (const ref of [`refs/remotes/${remote}/${trunk}`, `refs/heads/${trunk}`, trunk])
624
+ if (resolveRef(cwd, ref)) return ref;
625
+ return null;
626
+ }
627
+
628
+ /** The trunk name when the plan does not give one: origin's default branch, else main. */
629
+ export function defaultTrunk(cwd: string): string {
630
+ const base = defaultBase(cwd);
631
+ return base ? base.replace(/^origin\//u, "") : "main";
632
+ }
633
+
634
+ /** `<repo>-wt/` beside the main checkout. */
635
+ export function worktreeRoot(cwd: string): string {
636
+ const common = gitCommonDir(cwd);
637
+ const main = common && path.basename(common) === ".git" ? path.dirname(common) : cwd;
638
+ return path.join(path.dirname(main), `${path.basename(main)}-wt`);
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // store
643
+
644
+ export function fanoutsDir(cwd: string): FanoutResult<string> {
645
+ const root = storeRoot(cwd);
646
+ if (!root.ok) return fanoutFail(root.code, root.error, root.unblock);
647
+ return { ok: true, data: path.join(root.value.root, "fanouts") };
648
+ }
649
+
650
+ /**
651
+ * Tolerant read (D17) that still re-validates what the checks rely on: every
652
+ * glob normalizes, every dependency is a known slice, no cycle. A file that
653
+ * fails is "not a valid plan", never half-trusted.
654
+ */
655
+ function parseStored(raw: unknown): FanoutFile | null {
656
+ if (!isRecord(raw) || typeof raw.name !== "string" || typeof raw.trunk !== "string") return null;
657
+ if (!Array.isArray(raw.slices)) return null;
658
+ const list = (value: unknown): string[] =>
659
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
660
+ const text = (value: unknown): string => (typeof value === "string" ? value : "");
661
+ let globsValid = true;
662
+ const globs = (value: unknown): string[] =>
663
+ list(value).map((entry) => {
664
+ const normalized = normalizePattern(entry);
665
+ if ("error" in normalized) {
666
+ globsValid = false;
667
+ return entry;
668
+ }
669
+ return normalized.pattern;
670
+ });
671
+ const slices: Slice[] = [];
672
+ for (const item of raw.slices) {
673
+ if (!isRecord(item) || typeof item.id !== "string" || typeof item.branch !== "string")
674
+ return null;
675
+ slices.push({
676
+ id: item.id,
677
+ branch: item.branch,
678
+ base: text(item.base) || raw.trunk,
679
+ worktree: text(item.worktree),
680
+ tier: (TIERS as readonly unknown[]).includes(item.tier) ? (item.tier as Tier) : "standard",
681
+ dependsOn: list(item.dependsOn),
682
+ scope: globs(item.scope),
683
+ owns: globs(item.owns),
684
+ goal: text(item.goal),
685
+ acceptance: list(item.acceptance),
686
+ verify: list(item.verify),
687
+ forbidden: list(item.forbidden),
688
+ context: typeof item.context === "string" ? item.context : null,
689
+ timebox: typeof item.timebox === "string" ? item.timebox : null,
690
+ });
691
+ }
692
+ const shared = globs(raw.shared);
693
+ const ids = new Set(slices.map((slice) => slice.id));
694
+ if (
695
+ !globsValid ||
696
+ ids.size !== slices.length ||
697
+ slices.some((slice) => slice.dependsOn.some((dep) => dep === slice.id || !ids.has(dep))) ||
698
+ findCycle(slices)
699
+ )
700
+ return null;
701
+ return {
702
+ v: typeof raw.v === "number" ? raw.v : FANOUT_VERSION,
703
+ name: raw.name,
704
+ trunk: raw.trunk,
705
+ shared,
706
+ slices,
707
+ createdAt: text(raw.createdAt),
708
+ updatedAt: text(raw.updatedAt),
709
+ };
710
+ }
711
+
712
+ export function readFanout(cwd: string, name: string): FanoutResult<FanoutFile | null> {
713
+ const dir = fanoutsDir(cwd);
714
+ if (!dir.ok) return dir;
715
+ const file = path.join(dir.data, stackFileName(name));
716
+ let parsed: FanoutFile | null = null;
717
+ try {
718
+ parsed = parseStored(JSON.parse(fs.readFileSync(file, "utf8")));
719
+ } catch (error) {
720
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, data: null };
721
+ parsed = null;
722
+ }
723
+ return parsed
724
+ ? { ok: true, data: parsed }
725
+ : fanoutFail(
726
+ "failed",
727
+ `fanout file ${file} is not a valid plan`,
728
+ `workit fanout plan <plan.json> --name ${name} # rewrite it`,
729
+ );
730
+ }
731
+
732
+ function listFanouts(cwd: string): FanoutResult<FanoutFile[]> {
733
+ const dir = fanoutsDir(cwd);
734
+ if (!dir.ok) return dir;
735
+ let names: string[] = [];
736
+ try {
737
+ names = fs.readdirSync(dir.data).filter((entry) => entry.endsWith(".json"));
738
+ } catch {
739
+ return { ok: true, data: [] };
740
+ }
741
+ const out: FanoutFile[] = [];
742
+ for (const entry of names) {
743
+ let parsed: FanoutFile | null = null;
744
+ try {
745
+ parsed = parseStored(JSON.parse(fs.readFileSync(path.join(dir.data, entry), "utf8")));
746
+ } catch {
747
+ parsed = null;
748
+ }
749
+ if (parsed && stackFileName(parsed.name) === entry) out.push(parsed);
750
+ }
751
+ return { ok: true, data: out.toSorted((a, b) => a.name.localeCompare(b.name)) };
752
+ }
753
+
754
+ /** The plan `name`, else the one with the current branch as a slice, else the only one. */
755
+ export function selectFanout(
756
+ cwd: string,
757
+ name: string | null,
758
+ current: string | null,
759
+ ): FanoutResult<FanoutFile> {
760
+ if (name) {
761
+ const read = readFanout(cwd, name);
762
+ if (!read.ok) return read;
763
+ return read.data
764
+ ? { ok: true, data: read.data }
765
+ : fanoutFail("not_found", `no fanout plan named ${name}`, "workit fanout plan <plan.json>");
766
+ }
767
+ const all = listFanouts(cwd);
768
+ if (!all.ok) return all;
769
+ const holding = all.data.filter((plan) => plan.slices.some((slice) => slice.branch === current));
770
+ if (holding.length === 1) return { ok: true, data: holding[0] };
771
+ if (all.data.length === 1) return { ok: true, data: all.data[0] };
772
+ return all.data.length === 0
773
+ ? fanoutFail("not_found", "no fanout plan is recorded here", "workit fanout plan <plan.json>")
774
+ : fanoutFail(
775
+ "invalid_input",
776
+ `several fanout plans match; pick one (${all.data.map((plan) => plan.name).join(", ")})`,
777
+ "pass --name <plan>",
778
+ );
779
+ }
780
+
781
+ function writeFanout(cwd: string, plan: FanoutFile): FanoutResult<string> {
782
+ const dir = fanoutsDir(cwd);
783
+ if (!dir.ok) return dir;
784
+ const file = path.join(dir.data, stackFileName(plan.name));
785
+ const temp = `${file}.${process.pid}.tmp`;
786
+ try {
787
+ fs.mkdirSync(dir.data, { recursive: true });
788
+ fs.writeFileSync(temp, `${JSON.stringify(plan, null, 2)}\n`);
789
+ fs.renameSync(temp, file);
790
+ return { ok: true, data: file };
791
+ } catch (error) {
792
+ fs.rmSync(temp, { force: true });
793
+ return fanoutFail("unavailable", `cannot write ${file}: ${(error as Error).message}`);
794
+ }
795
+ }
796
+
797
+ // ---------------------------------------------------------------------------
798
+ // plan
799
+
800
+ export type PlanOutcome = {
801
+ name: string;
802
+ trunk: string;
803
+ file: string;
804
+ created: boolean;
805
+ slices: Array<
806
+ Pick<Slice, "id" | "branch" | "base" | "worktree" | "tier" | "dependsOn" | "scope" | "owns">
807
+ >;
808
+ overlaps: Overlap[];
809
+ waves: string[][];
810
+ landingOrder: string[];
811
+ notes: string[];
812
+ };
813
+
814
+ /** Above this many slices in one wave, the lead queues the rest (4-6 in flight). */
815
+ export const IN_FLIGHT_CAP = 6;
816
+
817
+ export function planFanout(
818
+ cwd: string,
819
+ input: { raw: unknown; name: string | null; trunk: string | null; actor: LedgerActor },
820
+ now: Date = new Date(),
821
+ ): FanoutResult<PlanOutcome> {
822
+ const dir = fanoutsDir(cwd);
823
+ if (!dir.ok) return dir;
824
+ const parsed = parsePlan(
825
+ input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
826
+ { trunk: input.trunk ?? defaultTrunk(cwd), worktreeRoot: worktreeRoot(cwd) },
827
+ input.name,
828
+ );
829
+ if (!parsed.ok)
830
+ return fanoutFail(
831
+ "invalid_input",
832
+ `the plan is incomplete: ${parsed.problems[0]}${parsed.problems.length > 1 ? ` (+${parsed.problems.length - 1} more)` : ""}`,
833
+ "fill every listed field, then run workit fanout plan again",
834
+ { problems: parsed.problems },
835
+ );
836
+ const plan = parsed.plan;
837
+ const badBranches = plan.slices
838
+ .filter((slice) => !gitRun(cwd, ["check-ref-format", "--branch", slice.branch]).ok)
839
+ .map((slice) => `slice ${slice.id}: ${slice.branch} is not a valid branch name`);
840
+ if (badBranches.length)
841
+ return fanoutFail("invalid_input", badBranches[0], "rename the branch in the plan", {
842
+ problems: badBranches,
843
+ });
844
+
845
+ const ref = trunkRef(cwd, plan.trunk);
846
+ const tracked = ref
847
+ ? nulList(gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", ref]).stdout)
848
+ : [];
849
+ const samples = plan.slices.flatMap((slice) =>
850
+ [...slice.scope, ...slice.owns].flatMap(scopeSamples),
851
+ );
852
+ const { overlaps, conflicts } = analyzeOverlap(plan, [...tracked, ...samples]);
853
+ if (conflicts.length)
854
+ return fanoutFail(
855
+ "blocked",
856
+ `${conflicts.length} file-scope overlap${conflicts.length === 1 ? "" : "s"} between slices: ${conflicts[0].slices.join(" and ")} on ${conflicts[0].paths[0]}${conflicts[0].paths.length > 1 ? ` (+${conflicts[0].paths.length - 1})` : ""}`,
857
+ conflicts[0].suggestion.text,
858
+ { name: plan.name, conflicts, overlaps },
859
+ );
860
+
861
+ const existing = readFanout(cwd, plan.name);
862
+ const previous = existing.ok ? existing.data : null;
863
+ plan.createdAt = previous?.createdAt || now.toISOString();
864
+ plan.updatedAt = now.toISOString();
865
+ const written = writeFanout(cwd, plan);
866
+ if (!written.ok) return written;
867
+ appendObserved(cwd, {
868
+ type: "fanout.planned",
869
+ actor: input.actor,
870
+ fanout: plan.name,
871
+ trunk: plan.trunk,
872
+ slices: plan.slices.length,
873
+ ids: plan.slices.slice(0, 20).map((slice) => slice.id),
874
+ });
875
+
876
+ const grouped = waves(plan.slices);
877
+ const notes: string[] = [];
878
+ if (!ref)
879
+ notes.push(
880
+ `trunk ${plan.trunk} does not resolve here; only sample paths of each glob were compared`,
881
+ );
882
+ grouped.forEach((wave, index) => {
883
+ if (wave.length > IN_FLIGHT_CAP)
884
+ notes.push(
885
+ `wave ${index + 1} has ${wave.length} slices; keep 4-${IN_FLIGHT_CAP} in flight and queue the rest`,
886
+ );
887
+ });
888
+ for (const slice of plan.slices)
889
+ if (slice.dependsOn.length > 1 && slice.base === plan.trunk)
890
+ notes.push(
891
+ `slice ${slice.id} depends on ${slice.dependsOn.join(", ")}: start it from ${plan.trunk} after they land`,
892
+ );
893
+ return {
894
+ ok: true,
895
+ data: {
896
+ name: plan.name,
897
+ trunk: plan.trunk,
898
+ file: written.data,
899
+ created: previous === null,
900
+ slices: plan.slices.map(({ id, branch, base, worktree, tier, dependsOn, scope, owns }) => ({
901
+ id,
902
+ branch,
903
+ base,
904
+ worktree,
905
+ tier,
906
+ dependsOn,
907
+ scope,
908
+ owns,
909
+ })),
910
+ overlaps,
911
+ waves: grouped,
912
+ landingOrder: landingOrder(plan.slices),
913
+ notes,
914
+ },
915
+ };
916
+ }