@brainervirus/workit-core 7.2.0 → 7.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -10,41 +10,49 @@ verifiable alone. Code-coupled work stays with one owner, who fans out after
10
10
  the blocking part lands. A worker whose whole job is re-running one command is
11
11
  ceremony; do it yourself.
12
12
 
13
- 1. **Slice** (workit-shape): each slice gets a branch, a file-scope manifest
14
- (the globs it may write) and, if it depends on another, its stack parent.
15
- 2. **Check disjointness.** No two manifests overlap. Shared files (lockfile,
16
- registry, barrel exports) belong to one slice, or to you after fan-in.
17
- 3. **Brief each worker** with the fixed template and refuse to spawn while a
18
- field is empty: GOAL, SCOPE (the manifest), CONTEXT (pointers, not pasted
19
- text), ACCEPTANCE (Given/When/Then), VERIFY (exact commands), TIMEBOX,
20
- FORBIDDEN, REPORT, STANDING. STANDING is every standing order and user
21
- directive so far, pasted verbatim into each spawn and respawn, because
22
- directives decay across resumes. Template: `references/brief.md`.
23
- 4. **Spawn all workers in one message**, in the background, each in its own
13
+ 1. **Plan the slices** (workit-shape) in a plan file (`references/brief.md`):
14
+ per slice an id, branch, TIER, the file-scope manifest (SCOPE globs),
15
+ `owns` for shared files (lockfile, registry, barrels), `dependsOn` only for
16
+ a real dependency (independent PRs off trunk are the default), and the
17
+ brief fields. `workit fanout plan <plan.json>` refuses an empty brief field
18
+ (exit 2) and two slices that may write one file (exit 3) with a fix: an
19
+ owner for a shared file, or a dependency that serializes them. Apply it and
20
+ re-run: refuse to spawn while a field is empty or the plan is refused.
21
+ Its `waves` say which slices may run together; keep 4-6 in flight.
22
+ 2. **Brief each worker** from its slice with the fixed template: GOAL, SCOPE,
23
+ CONTEXT (pointers, not pasted text), ACCEPTANCE (Given/When/Then), VERIFY
24
+ (exact commands), TIER, TIMEBOX, FORBIDDEN, REPORT, STANDING. STANDING is
25
+ every standing order and user directive so far, pasted verbatim into each
26
+ spawn and respawn, because directives decay across resumes.
27
+ 3. **Spawn all workers in one message**, in the background, each in its own
24
28
  worktree (Claude Code: the `implementer` agent; elsewhere
25
29
  `git worktree add --detach ../<repo>-wt/<slug> origin/<base>`). The first
26
30
  command a worker runs is `workit git branch <branch> --base <base>`.
27
- 5. **Judge liveness by side effects only:** new commits and pushes
31
+ 4. **Judge liveness by side effects only:** new commits and pushes
28
32
  (`git log <branch>`), PR and check changes (`workit pr status --branch <b>`).
29
33
  No progress past the timebox means stuck. Stop the old worker and observe
30
34
  that it exited (a timeout is not proof). `git worktree remove --force`
31
35
  drops its uncommitted changes, so first record `git -C <wt> status --short`
32
- in the ledger or your report; only then remove the worktree. Respawn with the brief in
33
- `MODE: resume` (consolidated: original, later directives, its last report):
36
+ in the ledger or your report; only then remove the worktree. Respawn with
37
+ the brief in `MODE: resume` (original, later directives, its last report):
34
38
  the new worker runs `git switch <branch>` in its fresh worktree instead of
35
39
  `workit git branch`. Never two live workers on one branch. Replace at most
36
40
  twice, then re-slice or report the gap. Never chain resumes.
37
- 6. **Verify each slice independently.** A fresh agent that did not write it
41
+ 5. **Verify each slice independently.** A fresh agent that did not write it
38
42
  (Claude Code: the `verifier` agent) runs VERIFY and verify-<app>, then
39
43
  `workit ledger verdict <result> --branch <b> --how "<evidence>"` under the
40
44
  session you started it with (`WORKIT_SESSION_ID=<lead>-v<n>`, set by you,
41
45
  never chosen by the author; Claude Code: the hook names one).
42
46
  A worker's report is a pointer, never evidence.
43
- 7. **Fan in.** Compare `git diff --name-only <base>...<b>` with the slice's
44
- SCOPE: any file outside it stops the fan-in with a report. Then
45
- `workit ledger check --branch <b>` for each slice; restack
46
- stacked slices with `workit stack sync`. Only you touch topology: workers
47
- never rebase, retarget or merge. Then workit-ship.
47
+ 6. **Fan in** with `workit fanout check`: out-of-scope files (any file outside
48
+ it stops the fan-in), and `git merge-tree` conflicts with trunk and between
49
+ siblings, charged to the slice that lands later. Fix what it names (an
50
+ out-of-scope edit becomes a follow-up slice) until it exits 0. A landed
51
+ slice reads as not found: re-plan without it and drop it from dependents'
52
+ `dependsOn`. Then `workit ledger check --branch <b>` per slice; land in its
53
+ order. Stacked slices: `workit stack plan <bottom> … <top>` once, then
54
+ `workit stack sync` and `land`. Only you touch topology: workers never
55
+ rebase, retarget or merge. Then workit-ship.
48
56
 
49
57
  ## Example
50
58
 
@@ -58,5 +66,6 @@ counts; SCOPE: `src/routes/usage.ts`, `test/usage.test.ts`; VERIFY:
58
66
  ## Check
59
67
 
60
68
  ```sh
69
+ workit fanout check # exit 0: in scope, no conflicts, landing order printed
61
70
  workit ledger check --branch <b> # per slice: accepted (current, passing, independent)
62
71
  ```
@@ -12,6 +12,7 @@ CONTEXT: <pointers: spec section, ledger decisions, the neighbour file to imitat
12
12
  ACCEPTANCE:
13
13
  - Given <state>, When <action>, Then <observable result>
14
14
  VERIFY: <exact commands, e.g. `workit check test`, the verify-<app> feature to drive>
15
+ TIER: <mundane | standard | hard: how much model the slice needs>
15
16
  TIMEBOX: <wall clock or turn budget; past it without a new commit you will be replaced>
16
17
  FORBIDDEN: <no edits outside SCOPE; no rebase, retarget, merge or force-push; no new dependencies; ...>
17
18
  REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
@@ -34,12 +35,56 @@ ACCEPTANCE:
34
35
  - Given 3 runs today and 1 yesterday, When GET /v1/usage, Then the last two entries are {"runs":1} and {"runs":3}
35
36
  - Given no auth header, When GET /v1/usage, Then the status is 401
36
37
  VERIFY: `workit check test`; verify-api feature "usage"
38
+ TIER: standard
37
39
  TIMEBOX: 45 minutes
38
40
  FORBIDDEN: no edits outside SCOPE; no schema migration; no rebase or force-push; no new packages
39
41
  REPORT: as in the template
40
42
  STANDING: conventional commits; no comments that restate code; ask nothing, record rulings instead
41
43
  ```
42
44
 
45
+ ## Plan file
46
+
47
+ `workit fanout plan <plan.json>` records the slices before any spawn. Each
48
+ slice carries the brief fields the plan checks (goal, scope, acceptance,
49
+ verify, forbidden must be filled; tier is mundane, standard or hard). `base`
50
+ defaults to the trunk, or to the branch of a single `dependsOn` slice (a
51
+ stack); `worktree` defaults to `../<repo>-wt/<id>`. `owns` claims a shared
52
+ file another slice's glob also matches. Globs that match no file yet are compared
53
+ through a sample path. Escape literal brackets: `"app/\\[id\\]/page.tsx"`.
54
+
55
+ ```json
56
+ {
57
+ "name": "usage",
58
+ "trunk": "main",
59
+ "slices": [
60
+ {
61
+ "id": "usage-endpoint",
62
+ "branch": "feature/usage-endpoint",
63
+ "tier": "standard",
64
+ "scope": ["src/routes/usage.ts", "src/queries/usage.ts", "test/usage.test.ts"],
65
+ "owns": ["package.json"],
66
+ "goal": "GET /v1/usage returns the run count per UTC day for the last 7 days",
67
+ "acceptance": ["Given no auth header, When GET /v1/usage, Then the status is 401"],
68
+ "verify": ["workit check test"],
69
+ "forbidden": ["no edits outside SCOPE", "no rebase or force-push"],
70
+ "context": "docs/usage/spec.md Behavior",
71
+ "timebox": "45 minutes"
72
+ },
73
+ {
74
+ "id": "usage-docs",
75
+ "branch": "docs/usage",
76
+ "tier": "mundane",
77
+ "dependsOn": ["usage-endpoint"],
78
+ "scope": ["docs/usage/**"],
79
+ "goal": "The API guide documents GET /v1/usage",
80
+ "acceptance": ["Given the guide, When a reader looks up usage, Then the response shape is shown"],
81
+ "verify": ["workit check docs"],
82
+ "forbidden": ["no edits outside SCOPE"]
83
+ }
84
+ ]
85
+ }
86
+ ```
87
+
43
88
  ## Worker rules (paste into the brief when the host has no implementer agent)
44
89
 
45
90
  1. First command. `MODE: new`: `workit git branch <branch> --base <base>` (the
@@ -0,0 +1,294 @@
1
+ // `workit fanout check` (G2): the fan-in gate over a recorded fanout plan.
2
+ //
3
+ // For each slice branch it reads what git says, never what a worker reported:
4
+ // - scope: `git diff --name-only <base>...<branch>` against the slice's
5
+ // manifest (its scope and owns, minus files another slice owns); any other
6
+ // file is an out-of-scope edit;
7
+ // - trunk: `git merge-tree --write-tree` of the branch with the trunk;
8
+ // - siblings: the same merge between every pair of slice branches, so two
9
+ // PRs that would conflict once the first lands are caught before either
10
+ // does. A pairwise conflict is charged to the slice that lands later; the
11
+ // earlier one stays landable.
12
+ // The landing order puts dependencies first, then the plan's order. A slice
13
+ // is ready when none of the above fired and every slice it depends on is
14
+ // ready. Verdicts are reported (`workit ledger check`) but gated by
15
+ // `workit pr merge`, not here. Refs are read as they are locally: fetch first.
16
+ import { checkVerdicts, readLedger, type ReadRow } from "./ledger";
17
+ import {
18
+ FANOUT_VERSION,
19
+ branchRef,
20
+ fanoutFail,
21
+ gitRun,
22
+ inScope,
23
+ landingOrder,
24
+ nulList,
25
+ trunkRef,
26
+ type FanoutFile,
27
+ type FanoutResult,
28
+ type Slice,
29
+ } from "./fanout";
30
+
31
+ export type SliceStatus = "ready" | "blocked";
32
+
33
+ export type SliceCheck = {
34
+ id: string;
35
+ branch: string;
36
+ head: string | null;
37
+ base: string;
38
+ status: SliceStatus;
39
+ changed: number;
40
+ outOfScope: string[];
41
+ trunkConflicts: string[];
42
+ siblingConflicts: Array<{ with: string; paths: string[] }>;
43
+ waitsFor: string[];
44
+ verdict: { accepted: boolean; basis: string };
45
+ reasons: string[];
46
+ };
47
+
48
+ export type SiblingConflict = {
49
+ /** Lands first in the landing order. */
50
+ first: string;
51
+ /** Lands second; it carries the conflict. */
52
+ second: string;
53
+ paths: string[];
54
+ /** The two branches name one file with different case (one file on macOS and Windows). */
55
+ caseOnly?: true;
56
+ resolution: string;
57
+ };
58
+
59
+ export type CheckOutcome = {
60
+ name: string;
61
+ trunk: string;
62
+ trunkRef: string;
63
+ ready: boolean;
64
+ slices: SliceCheck[];
65
+ conflicts: SiblingConflict[];
66
+ landingOrder: string[];
67
+ next: string;
68
+ notes: string[];
69
+ };
70
+
71
+ const SAFE = (value: string | null): value is string => Boolean(value) && !value?.startsWith("-");
72
+
73
+ /** Paths both sides change incompatibly, [] when clean, or an error. */
74
+ function mergeConflicts(cwd: string, a: string, b: string): string[] | { error: string } {
75
+ const run = gitRun(cwd, [
76
+ "merge-tree",
77
+ "--write-tree",
78
+ "--name-only",
79
+ "--no-messages",
80
+ "-z",
81
+ a,
82
+ b,
83
+ ]);
84
+ if (run.status === 0) return [];
85
+ if (run.status === 1) return [...new Set(nulList(run.stdout).slice(1))];
86
+ const first = run.stderr.trim().split("\n")[0] || `git merge-tree exited ${run.status}`;
87
+ return {
88
+ error: /write-tree|usage: git merge-tree/u.test(run.stderr)
89
+ ? `${first} (needs git >= 2.38 for merge-tree --write-tree)`
90
+ : first,
91
+ };
92
+ }
93
+
94
+ const isAncestor = (cwd: string, a: string, b: string): boolean =>
95
+ gitRun(cwd, ["merge-base", "--is-ancestor", a, b]).ok;
96
+
97
+ export function checkFanout(
98
+ cwd: string,
99
+ plan: FanoutFile,
100
+ options: { only?: readonly string[]; trunkRef?: string | null } = {},
101
+ ): FanoutResult<CheckOutcome> {
102
+ const unknown = (options.only ?? []).filter((id) => !plan.slices.some((s) => s.id === id));
103
+ if (unknown.length)
104
+ return fanoutFail(
105
+ "invalid_input",
106
+ `${unknown.join(", ")}: not a slice of fanout ${plan.name}`,
107
+ `slices: ${plan.slices.map((slice) => slice.id).join(", ")}`,
108
+ );
109
+ const trunk = options.trunkRef ?? trunkRef(cwd, plan.trunk);
110
+ if (!SAFE(trunk) || !resolves(cwd, trunk))
111
+ return fanoutFail(
112
+ "unavailable",
113
+ `trunk ${options.trunkRef ?? plan.trunk} does not resolve here`,
114
+ `git fetch, or pass --base <ref>`,
115
+ );
116
+ const order = landingOrder(plan.slices);
117
+ const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
118
+ const tips = new Map(plan.slices.map((slice) => [slice.id, branchRef(cwd, slice.branch)]));
119
+ const ledger = readLedger(cwd);
120
+ const rows: readonly ReadRow[] = ledger.ok ? ledger.value.rows : [];
121
+
122
+ const checks = new Map<string, SliceCheck>();
123
+ const changedBy = new Map<string, string[]>();
124
+ for (const slice of plan.slices)
125
+ checks.set(slice.id, inspect(cwd, plan, slice, trunk, tips, rows, changedBy));
126
+
127
+ // Pairwise siblings: skip a pair where one contains the other (a stacked child).
128
+ const conflicts: SiblingConflict[] = [];
129
+ for (let i = 0; i < order.length; i += 1)
130
+ for (let j = i + 1; j < order.length; j += 1) {
131
+ const first = order[i];
132
+ const second = order[j];
133
+ const a = tips.get(first);
134
+ const b = tips.get(second);
135
+ if (!a || !b || isAncestor(cwd, a, b) || isAncestor(cwd, b, a)) continue;
136
+ const check = checks.get(second) as SliceCheck;
137
+ const theirs = new Map(
138
+ (changedBy.get(first) ?? []).map((file) => [file.toLowerCase(), file]),
139
+ );
140
+ const cased = (changedBy.get(second) ?? []).flatMap((file) => {
141
+ const other = theirs.get(file.toLowerCase());
142
+ return other !== undefined && other !== file ? [`${other} ~ ${file}`] : [];
143
+ });
144
+ if (cased.length) {
145
+ conflicts.push({
146
+ first,
147
+ second,
148
+ paths: cased,
149
+ caseOnly: true,
150
+ resolution: `use ${first}'s spelling on ${second} (git mv), then workit fanout check`,
151
+ });
152
+ check.siblingConflicts.push({ with: first, paths: cased });
153
+ check.reasons.push(`differs only in case from ${first}: ${cased.join(", ")}`);
154
+ }
155
+ const result = mergeConflicts(cwd, a, b);
156
+ if (!Array.isArray(result)) {
157
+ check.reasons.push(`cannot merge with ${first}: ${result.error}`);
158
+ continue;
159
+ }
160
+ if (result.length === 0) continue;
161
+ const related = byId.get(second)?.dependsOn.includes(first);
162
+ conflicts.push({
163
+ first,
164
+ second,
165
+ paths: result,
166
+ resolution: related
167
+ ? `rebase ${second} onto ${byId.get(first)?.branch} (its dependency), then workit fanout check`
168
+ : `land ${first} first, then rebase ${second} onto ${plan.trunk} and run workit fanout check; or make ${second} depend on ${first}`,
169
+ });
170
+ check.siblingConflicts.push({ with: first, paths: result });
171
+ check.reasons.push(`conflicts with ${first} on ${result.join(", ")}`);
172
+ }
173
+
174
+ for (const id of order) {
175
+ const check = checks.get(id) as SliceCheck;
176
+ check.waitsFor = (byId.get(id)?.dependsOn ?? []).filter(
177
+ (dep) => checks.get(dep)?.status !== "ready",
178
+ );
179
+ if (check.waitsFor.length) check.reasons.push(`waits for ${check.waitsFor.join(", ")}`);
180
+ check.status = check.reasons.length ? "blocked" : "ready";
181
+ }
182
+
183
+ const selected = options.only?.length ? new Set(options.only) : null;
184
+ const slices = order
185
+ .filter((id) => !selected || selected.has(id))
186
+ .map((id) => checks.get(id) as SliceCheck);
187
+ const ready = slices.every((check) => check.status === "ready");
188
+ const blocked = slices.find((check) => check.status === "blocked");
189
+ const notes =
190
+ plan.v === FANOUT_VERSION
191
+ ? []
192
+ : [
193
+ `plan file version ${plan.v} is not ${FANOUT_VERSION}; read leniently (re-plan to rewrite it)`,
194
+ ];
195
+ const next = blocked
196
+ ? unblockFor(blocked, plan)
197
+ : `land in this order: ${slices.map((check) => check.id).join(", ")} (workit pr merge each once verified; stacked slices with workit stack land)`;
198
+ return {
199
+ ok: true,
200
+ data: {
201
+ name: plan.name,
202
+ trunk: plan.trunk,
203
+ trunkRef: trunk,
204
+ ready,
205
+ slices,
206
+ conflicts: conflicts.filter(
207
+ (conflict) => !selected || selected.has(conflict.first) || selected.has(conflict.second),
208
+ ),
209
+ landingOrder: order,
210
+ notes,
211
+ next,
212
+ },
213
+ };
214
+ }
215
+
216
+ const resolves = (cwd: string, ref: string): boolean =>
217
+ gitRun(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok;
218
+
219
+ function inspect(
220
+ cwd: string,
221
+ plan: FanoutFile,
222
+ slice: Slice,
223
+ trunk: string,
224
+ tips: ReadonlyMap<string, string | null>,
225
+ rows: readonly ReadRow[],
226
+ changedBy: Map<string, string[]>,
227
+ ): SliceCheck {
228
+ const head = tips.get(slice.id) ?? null;
229
+ const check: SliceCheck = {
230
+ id: slice.id,
231
+ branch: slice.branch,
232
+ head,
233
+ base: slice.base,
234
+ status: "blocked",
235
+ changed: 0,
236
+ outOfScope: [],
237
+ trunkConflicts: [],
238
+ siblingConflicts: [],
239
+ waitsFor: [],
240
+ verdict: { accepted: false, basis: "missing" },
241
+ reasons: [],
242
+ };
243
+ if (!head) {
244
+ check.reasons.push(`branch ${slice.branch} not found (not started, landed, or not fetched)`);
245
+ return check;
246
+ }
247
+ const parent = plan.slices.find((other) => other.branch === slice.base);
248
+ const baseRef =
249
+ slice.base === plan.trunk
250
+ ? trunk
251
+ : parent
252
+ ? (tips.get(parent.id) ?? null)
253
+ : branchRef(cwd, slice.base);
254
+ if (!SAFE(baseRef)) {
255
+ check.reasons.push(`base ${slice.base} not found`);
256
+ return check;
257
+ }
258
+ const diff = gitRun(cwd, ["diff", "--name-only", "--no-renames", "-z", `${baseRef}...${head}`]);
259
+ if (!diff.ok) {
260
+ check.reasons.push(`cannot diff ${slice.base}...${slice.branch}: ${diff.stderr.trim()}`);
261
+ return check;
262
+ }
263
+ const changed = nulList(diff.stdout);
264
+ changedBy.set(slice.id, changed);
265
+ check.changed = changed.length;
266
+ if (changed.length === 0) check.reasons.push(`no changes beyond ${slice.base}`);
267
+ check.outOfScope = changed.filter((file) => !inScope(plan, slice, file)).toSorted();
268
+ if (check.outOfScope.length)
269
+ check.reasons.push(`edits outside its scope: ${check.outOfScope.join(", ")}`);
270
+ const trunkResult = mergeConflicts(cwd, trunk, head);
271
+ if (Array.isArray(trunkResult)) {
272
+ check.trunkConflicts = trunkResult;
273
+ if (trunkResult.length)
274
+ check.reasons.push(`conflicts with ${plan.trunk} on ${trunkResult.join(", ")}`);
275
+ } else check.reasons.push(`cannot merge with ${plan.trunk}: ${trunkResult.error}`);
276
+ const verdict = checkVerdicts(cwd, slice.branch, rows);
277
+ check.verdict = { accepted: verdict.accepted.accepted, basis: verdict.current.basis };
278
+ return check;
279
+ }
280
+
281
+ function unblockFor(check: SliceCheck, plan: FanoutFile): string {
282
+ if (!check.head)
283
+ return `start or fetch ${check.branch}, then workit fanout check; if it landed, re-plan without slice ${check.id}`;
284
+ if (check.outOfScope.length)
285
+ return `move ${check.outOfScope.join(", ")} off ${check.branch} (a follow-up slice), or widen slice ${check.id}'s scope with workit fanout plan, then workit fanout check`;
286
+ if (check.trunkConflicts.length)
287
+ return `rebase ${check.branch} onto ${plan.trunk} and resolve ${check.trunkConflicts.join(", ")}, then workit fanout check`;
288
+ if (check.siblingConflicts.length) {
289
+ const first = check.siblingConflicts[0].with;
290
+ return `land ${first} first, then rebase ${check.branch} onto ${plan.trunk} and run workit fanout check`;
291
+ }
292
+ if (check.waitsFor.length) return `unblock ${check.waitsFor.join(", ")} first`;
293
+ return check.reasons[0] ?? "workit fanout check";
294
+ }
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
+ }