@brainervirus/workit-core 7.6.0 → 7.7.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.6.0",
3
+ "version": "7.7.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": [
@@ -8,60 +8,60 @@ description: Run independent slices in parallel - one worker per isolated worktr
8
8
  Fan out only independent slices: disjoint files, no shared mutable state, each
9
9
  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
- ceremony; do it yourself.
11
+ ceremony; do it yourself. Workit never spawns agents; you do.
12
12
 
13
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 or
18
- placeholder (`<goal>`, `TBD`) brief field (exit 2) and two slices that may
19
- write one file (exit 3) with a fix: an owner for a shared file, or a
20
- dependency that serializes them. Apply it and re-run: refuse to spawn while
21
- a field is empty or the plan is refused. Its `waves` say which slices may
22
- run together; keep 4-6 in flight.
23
- 2. **Brief each worker** from its slice with the fixed template: GOAL, SCOPE,
24
- CONTEXT (pointers, not pasted text), ACCEPTANCE (Given/When/Then), VERIFY
25
- (exact commands), TIER, TIMEBOX, SCRATCH, FORBIDDEN, REPORT, STANDING.
26
- STANDING is every standing order and user directive so far, pasted
27
- verbatim into each spawn and respawn: directives decay across resumes.
28
- 3. **Spawn all workers in one message**, in the background, each in its own
29
- worktree: Claude Code's `implementer` agent (first command `workit git
30
- branch <branch> --base <base>`); elsewhere `workit fanout worktree create
31
- <slice>`, which prints the SCRATCH dir. Refill from `fanout status`.
32
- 4. **Judge liveness by side effects only:** `workit fanout status` (head age,
33
- PR, CI, verdict, landed; STUCK past the TIMEBOX, default 30 min). Stop a
34
- stuck worker and observe that it exited (a timeout is not proof). Removing
35
- a worktree drops its uncommitted changes: `workit fanout worktree release
36
- <slice>` records `git status` in the ledger first and refuses them without
37
- `--force` (native worktrees: record `git -C <wt> status --short` first).
38
- Respawn in `MODE: resume` (brief, directives, last report), same branch.
39
- Never two live workers on one branch. Replace at most twice, then re-slice
40
- or report the gap. Never chain resumes.
41
- 5. **Verify each slice independently.** A fresh agent that did not write it
42
- (Claude Code: the `verifier` agent) runs VERIFY and verify-<app>, then
43
- `workit ledger verdict <result> --branch <b> --how "<evidence>"` under the
44
- session you started it with (`WORKIT_SESSION_ID=<lead>-v<n>`, set by you,
45
- never chosen by the author; Claude Code: the hook names one).
46
- A worker's report is a pointer, never evidence.
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 slice
51
- whose PR merged reads as landed; its dependents stop waiting. Then
52
- `workit ledger check --branch <b>` per slice, land in `fanout status` order
53
- and release the worktrees you made. Stacks: `workit stack plan <bottom> …
54
- <top>` once, then `stack sync` and `land`. Only you touch topology: workers
55
- never rebase, retarget or merge. Then ship (workit-ship).
14
+ per slice an id, branch, TIER, SCOPE globs, `owns` for shared files,
15
+ `dependsOn` only for a real dependency, and the brief fields. `workit fanout
16
+ plan <plan.json>` refuses an empty or placeholder brief field (exit 2) and
17
+ two slices that may write one file (exit 3) with a fix; apply it and re-run:
18
+ refuse to spawn while a field is empty or the plan is refused. One PR per
19
+ slice is the default; `"fanIn": "integration"` only when the user wants one
20
+ PR (`references/orchestration.md`).
21
+ 2. **Record standing orders** (`workit ledger standing add "<order>"`): every
22
+ user directive all workers share. Directives decay across resumes.
23
+ 3. **Brief each worker verbatim** with `workit fanout brief <slice>`: plan
24
+ fields, standing orders, SCRATCH, session id, fan-in rule. Never hand-edit
25
+ it; change the plan or the standing orders and render again.
26
+ 4. **Spawn in a rolling window**, in the background, each in its own worktree:
27
+ keep 4-6 in flight and refill from `spawnable` in `workit fanout status` as
28
+ each one finishes. Pick the model from TIER (`references/orchestration.md`).
29
+ Claude Code: the `implementer` agent; elsewhere first `workit fanout
30
+ worktree create <slice>`.
31
+ 5. **Judge liveness by side effects only:** `workit fanout status` (head age,
32
+ PR, CI, verdict, landed; STUCK past the TIMEBOX). Stop a stuck worker and
33
+ observe that it exited (a timeout is not proof). Removing a worktree drops
34
+ its uncommitted changes: `workit fanout worktree release <slice>` records
35
+ `git status` in the ledger first and refuses them without `--force`. Never
36
+ two live workers on one branch.
37
+ 6. **Retry once, then escalate.** A stuck or failed slice gets one retry with
38
+ a fresh brief (`workit fanout brief <slice> --mode resume`, same branch);
39
+ if that fails too, re-slice it, take it over, or report the gap. Never
40
+ chain resumes. A hard slice may race instead: N attempts, keep the best
41
+ verdict (`references/orchestration.md`).
42
+ 7. **Verify by the workspace `verification` setting** (`workit grant show`).
43
+ A report is a pointer, never evidence. `self`: a verifier that wrote none
44
+ of the slices, or you if you wrote none. `independent` or high risk: a
45
+ separate verifier session, never yours (doctrine: the ledger only refuses
46
+ authors). One verifier may take a batch of slices, one verdict per branch
47
+ (`workit ledger verdict <result> --branch <b> --how "<evidence>"`), in a
48
+ session never chosen by the author. A review panel on separate models only at high risk.
49
+ 8. **Fan in** with `workit fanout check`: out-of-scope files (any file outside
50
+ it stops the fan-in) and `git merge-tree` conflicts with trunk and between
51
+ siblings. Fix what it names until it exits 0; a merged slice reads as
52
+ landed. Then `workit ledger check --branch <b>` per slice, land in `fanout
53
+ status` order, release the worktrees you made, and `workit ledger standing
54
+ clear`. Only you touch topology: workers never rebase, retarget or merge,
55
+ except the integration-tip merge in integration mode. Then ship
56
+ (workit-ship).
56
57
 
57
58
  ## Example
58
59
 
59
60
  Bad brief: "Do the API part and add tests." (no scope, no acceptance, no
60
61
  verify command, so nobody can tell when it is done)
61
62
 
62
- Good brief: `references/brief.md` (GOAL: `GET /v1/usage` returns daily run
63
- counts; SCOPE: `src/routes/usage.ts`, `test/usage.test.ts`; VERIFY:
64
- `workit check test`; ...).
63
+ Good brief: the output of `workit fanout brief usage-endpoint`
64
+ (`references/brief.md` shows one).
65
65
 
66
66
  ## Check
67
67
 
@@ -1,8 +1,11 @@
1
1
  # Worker brief template
2
2
 
3
- Every field is required. A brief with an empty field is not spawned. Point to
4
- files and ledger rows instead of pasting their content. Size the slice so the
5
- worker finishes it in about 150k tokens of context; split a larger one first.
3
+ `workit fanout brief <slice>` renders this brief from the plan, the standing
4
+ orders in force (`workit ledger standing list`) and the slice's scratch dir;
5
+ pass its output verbatim. The template below is what it fills in. Every field
6
+ is required. A brief with an empty field is not spawned. Point to files and
7
+ ledger rows instead of pasting their content. Size the slice so the worker
8
+ finishes it in about 150k tokens of context; split a larger one first.
6
9
 
7
10
  ```md
8
11
  MODE: <new | resume (a replacement continuing an existing branch)>
@@ -16,11 +19,12 @@ VERIFY: <exact commands, e.g. `workit check test`, the verify-<app> feature to d
16
19
  TIER: <mundane | standard | hard: how much model the slice needs>
17
20
  TIMEBOX: <wall clock or turn budget; past it without a new commit you will be replaced>
18
21
  SCRATCH: <your own temp dir: the one `fanout worktree create` printed, else `mktemp -d`; never a shared path>
19
- FORBIDDEN: <no edits outside SCOPE; no rebase, retarget, merge or force-push; no new dependencies; ...>
22
+ FORBIDDEN: <no edits outside SCOPE; no new dependencies; ...>
23
+ FAN-IN: <one PR per slice: no rebase, retarget, merge or force-push | integration: merge the integration tip before reporting, nothing else>
20
24
  REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
21
25
  each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
22
26
  anything out of scope as a follow-up, not a diff.
23
- STANDING: <the standing orders, verbatim: user preferences and every directive given so far>
27
+ STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
24
28
  export WORKIT_SESSION_ID=<lead>-w<n> (set by the lead; a verifier brief gets <lead>-v<n>)
25
29
  ```
26
30
 
@@ -40,7 +44,8 @@ VERIFY: `workit check test`; verify-api feature "usage"
40
44
  TIER: standard
41
45
  TIMEBOX: 45 minutes
42
46
  SCRATCH: ../app-wt/usage-endpoint/.workit-scratch
43
- FORBIDDEN: no edits outside SCOPE; no schema migration; no rebase or force-push; no new packages
47
+ FORBIDDEN: no edits outside SCOPE; no schema migration; no new packages
48
+ FAN-IN: one PR per slice. Never rebase, retarget, merge or force-push: the lead owns topology.
44
49
  REPORT: as in the template
45
50
  STANDING: conventional commits; no comments that restate code; ask nothing, record rulings instead
46
51
  ```
@@ -55,7 +60,9 @@ stack); `worktree` defaults to `../<repo>-wt/<id>`. `owns` claims a shared
55
60
  file another slice's glob also matches. Globs that match no file yet are compared
56
61
  through a sample path. Use `/` as the separator; a backslash only escapes
57
62
  literal brackets: `"app/\\[id\\]/page.tsx"`. A `timebox` such as `45 minutes`
58
- is the slice's STUCK threshold in `workit fanout status`.
63
+ is the slice's STUCK threshold in `workit fanout status`. `"fanIn":
64
+ "integration"` (with an integration branch as `trunk`) is the one-PR mode in
65
+ `orchestration.md`; the default `"prs"` lands one PR per slice.
59
66
 
60
67
  ```json
61
68
  {
@@ -90,7 +97,7 @@ is the slice's STUCK threshold in `workit fanout status`.
90
97
  }
91
98
  ```
92
99
 
93
- ## Worker rules (paste into the brief when the host has no implementer agent)
100
+ ## Worker rules (`workit fanout brief` appends them as RULES)
94
101
 
95
102
  1. First command. `MODE: new`: `workit git branch <branch> --base <base>` (the
96
103
  worktree may start on a name that breaks branch policy). `MODE: resume`:
@@ -0,0 +1,117 @@
1
+ # Running a fanout
2
+
3
+ How the lead picks models, keeps workers flowing, retries, races, verifies
4
+ and fans in. Workit records and checks; the host's own subagents run the work.
5
+
6
+ ## TIER to model
7
+
8
+ TIER is a field of each slice and of its brief. Map it where the host lets you
9
+ pick a model per spawn; nothing in the workspace config routes it.
10
+
11
+ | TIER | Claude Code (`model` on the Agent call) | Hosts without a per-spawn model |
12
+ | --- | --- | --- |
13
+ | scouting (read-only search, not a slice) | `haiku` | host default |
14
+ | mundane | `sonnet` | host default |
15
+ | standard | omit it (inherits yours) | host default |
16
+ | hard | omit it (inherits yours); consider a race | host default |
17
+
18
+ OpenCode takes a subagent's model from its agent config: pick a configured
19
+ agent whose model fits the TIER, else the default. Codex, Cursor and Pi run
20
+ every worker on the host's model, so TIER only sizes the timebox and decides
21
+ whether to race.
22
+
23
+ ## Rolling window
24
+
25
+ Keep 4-6 workers in flight on Claude Code; elsewhere as many as the host runs
26
+ in the background, never more than 6. Do not wait for a whole wave: when one
27
+ worker finishes (its notification, or a new head or PR in `workit fanout
28
+ status`), verify it, then spawn the next slice from `spawnable` in the same
29
+ status output. A slice whose dependencies landed, or a stacked child whose
30
+ parent is verified, appears there by itself.
31
+
32
+ ## Retry, then escalate
33
+
34
+ A slice is retried when it is STUCK, its worker exits without meeting
35
+ ACCEPTANCE, or its verdict fails. Stop the worker and observe that it exited.
36
+ Release its worktree: `workit fanout worktree release <slice>` (native
37
+ worktrees: record `git -C <wt> status --short` first). Then spawn one fresh
38
+ worker with `workit fanout brief <slice> --mode resume`: same branch, and the
39
+ brief points at its head and its ledger rows (last report, verdicts,
40
+ rulings). If the retry fails too, escalate instead of a third worker:
41
+ re-slice it smaller (`workit fanout plan` again), take it over yourself, or
42
+ report the gap to the user.
43
+
44
+ ## Race a hard slice
45
+
46
+ When a hard slice has an uncertain approach and a cheap VERIFY, run 2-3
47
+ attempts at once instead of retrying in series. `workit fanout brief <slice>
48
+ --attempt <n>` gives each attempt its own branch (`<branch>-try<n>`), session
49
+ and scratch dir. Each attempt needs its own worktree: Claude Code's
50
+ `implementer` isolates natively; on other hosts `fanout worktree create` makes
51
+ only the slice's own worktree, so run the attempts one after another there.
52
+ Verify every attempt, keep the one with the best verdict (accepted first,
53
+ then the fewest changed files, then the earliest), point the slice branch at
54
+ it (`git branch <branch> <winner>`), have the verifier record the verdict on
55
+ `<branch>` (same head), and delete the losing attempt branches.
56
+
57
+ ## Verification
58
+
59
+ Follow the workspace `verification` setting (`workit grant show`); the
60
+ worker never records a verdict on its own work.
61
+
62
+ - `self` (the default): a verifier that wrote none of the slices records the
63
+ verdicts. A lead that authored none of the slices may record them in its
64
+ own session.
65
+ - `independent`, and any slice at high risk: a separate verifier session
66
+ records them, never the lead's own; at high risk one verifier per slice,
67
+ plus a review panel (below). This is doctrine, not enforced: the ledger
68
+ only tells a branch's authors from everyone else, and a lead that wrote
69
+ none of it counts as independent.
70
+
71
+ Which session id a verdict carries, per host:
72
+
73
+ - Claude Code: subagents inherit your `WORKIT_SESSION_ID`, so the
74
+ SubagentStart hook names each `verifier` and `reviewer` its own session
75
+ (`<lead>:<agent id>`), passed as `--session`; that one wins over any
76
+ `<lead>-v<n>` in a brief. An `implementer` uses the session its brief
77
+ exports for its workit commands.
78
+ - Other hosts: the session the brief sets wins: `export
79
+ WORKIT_SESSION_ID=<lead>-w-<slice>` from `workit fanout brief` for a
80
+ worker, `<lead>-v<n>` (or `--as verifier`) for a verifier you start.
81
+
82
+ A batch verifier takes several slices on one surface in one session: for
83
+ each branch it checks out the head, runs that slice's VERIFY, and records its
84
+ own `workit ledger verdict <result> --branch <b> --how "<evidence>"`. Every
85
+ verdict is keyed to that branch's head SHA; a slice whose head moves after
86
+ it needs a new one. Batch at most what fits one context, about 4 slices.
87
+
88
+ ## Review panel (high risk only)
89
+
90
+ At risk=high only, 2-3 fresh reviewers (workit-review), each on a model
91
+ other than the author's (Claude Code: a different `model`), each records
92
+ `workit ledger verdict <result> --kind review --branch <b> --how "<findings>"`.
93
+ Land only when every panel verdict passes. Below high risk a panel costs
94
+ more than it finds.
95
+
96
+ ## Integration-branch fan-in (one PR)
97
+
98
+ Only when the user wants one PR for the whole fanout:
99
+
100
+ 1. Cut the integration branch (`workit git branch <integration> --base
101
+ <trunk>`) and plan with `"fanIn": "integration"` and `--trunk
102
+ <integration>`. Planning refuses integration mode on origin's default
103
+ branch.
104
+ 2. Every brief then tells the worker to merge the integration tip into its
105
+ branch before reporting (the fast-forward rule). That merge is the only
106
+ one a worker makes, and only in this mode.
107
+ 3. Land each verified slice with `git merge --ff-only <slice-branch>` in the
108
+ integration checkout. Not a fast-forward (the tip moved since the worker
109
+ merged)? Resume the worker to merge again; never a merge commit of your own.
110
+ 4. `workit fanout check` gates against the integration branch; a slice whose
111
+ tip is on it reads as landed. When all have landed, ship the integration
112
+ branch as one PR (workit-ship).
113
+
114
+ ## Stacks
115
+
116
+ For stacked slices (`dependsOn` one parent): `workit stack plan <bottom> …
117
+ <top>` once, then `workit stack sync` and `workit stack land`.
@@ -0,0 +1,178 @@
1
+ // `workit fanout brief <slice>`: the complete worker brief, rendered from the
2
+ // plan, the standing orders in force for the fanout (`workit ledger standing`)
3
+ // and the slice's scratch dir, so the lead passes it to the worker verbatim.
4
+ //
5
+ // MODE is `resume` when the branch already exists (a retry continues it),
6
+ // else `new`; `--attempt <n>` renders one attempt of a race on its own branch
7
+ // `<branch>-try<n>` (refused when another slice has that branch), worktree
8
+ // `<worktree>+try<n>` and session `<lead>-w-<slice>+try<n>`: slice ids cannot
9
+ // contain `+`, so no attempt collides with another slice's path or session.
10
+ // The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
11
+ // worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
12
+ // one PR per slice forbids every merge; integration mode has the worker merge
13
+ // the integration tip before reporting, and nothing else. It only reads, apart
14
+ // from hiding `.workit-scratch/` in the repository's info/exclude.
15
+ import { createHash } from "node:crypto";
16
+ import { activeStanding, readLedger, type LedgerActor } from "./ledger";
17
+ import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
18
+ import {
19
+ SCRATCH_DIR,
20
+ excludeScratch,
21
+ findSlice,
22
+ listWorktrees,
23
+ samePath,
24
+ slicePath,
25
+ } from "./fanout-worktree";
26
+
27
+ export type BriefMode = "new" | "resume";
28
+
29
+ export type BriefOutcome = {
30
+ fanout: string;
31
+ slice: string;
32
+ mode: BriefMode;
33
+ attempt: number | null;
34
+ branch: string;
35
+ base: string;
36
+ tier: string;
37
+ /** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
38
+ scratch: string;
39
+ session: string;
40
+ standing: Array<{ id: string; what: string }>;
41
+ text: string;
42
+ };
43
+
44
+ export const DEFAULT_TIMEBOX = "30 minutes";
45
+
46
+ const REPORT = [
47
+ "REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,",
48
+ " each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),",
49
+ " anything out of scope as a follow-up, not a diff.",
50
+ ];
51
+
52
+ const rules = (mode: BriefMode, branch: string, base: string): string[] => [
53
+ "RULES:",
54
+ mode === "new"
55
+ ? ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`
56
+ : ` 1. First command: \`git switch ${branch}\`, then continue from its head.`,
57
+ ' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
58
+ " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
59
+ " 4. Never record a verdict on your own work.",
60
+ ];
61
+
62
+ /** A session id fragment that the ledger accepts ([A-Za-z0-9_.:@/+-]). */
63
+ const sessionSafe = (text: string): string => text.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
64
+
65
+ /** The ledger's session id limit. */
66
+ const SESSION_MAX = 128;
67
+
68
+ /** `<lead>-w-<slice>[+try<n>]`, with the slice part hashed when the whole would be too long. */
69
+ function workerSession(lead: string, slice: string, attempt: number | null): string {
70
+ const tail = attempt === null ? "" : `+try${attempt}`;
71
+ const full = `${lead}-w-${slice}${tail}`;
72
+ if (full.length <= SESSION_MAX) return full;
73
+ const digest = createHash("sha256").update(slice).digest("hex").slice(0, 12);
74
+ return `${lead.slice(0, SESSION_MAX - 3 - digest.length - tail.length)}-w-${digest}${tail}`;
75
+ }
76
+
77
+ export function renderBrief(
78
+ cwd: string,
79
+ plan: FanoutFile,
80
+ input: { slice: string; mode: BriefMode | null; attempt: number | null; actor: LedgerActor },
81
+ ): FanoutResult<BriefOutcome> {
82
+ const found = findSlice(plan, input.slice);
83
+ if (!found.ok) return found;
84
+ const slice = found.data;
85
+ const ledger = readLedger(cwd);
86
+ if (!ledger.ok) return fanoutFail(ledger.code, ledger.error, ledger.unblock);
87
+ if (!input.actor.session)
88
+ return fanoutFail(
89
+ "blocked",
90
+ "WORKIT_SESSION_ID is not set: worker session ids derive from the lead's",
91
+ "export WORKIT_SESSION_ID=<your session id>, then render the brief again",
92
+ );
93
+ const branch = input.attempt === null ? slice.branch : `${slice.branch}-try${input.attempt}`;
94
+ const taken = plan.slices.find((other) => other.id !== slice.id && other.branch === branch);
95
+ if (taken)
96
+ return fanoutFail(
97
+ "invalid_input",
98
+ `attempt branch ${branch} is slice ${taken.id}'s branch`,
99
+ "pick another --attempt number, or rename one of the branches in the plan",
100
+ );
101
+ const head = branchRef(cwd, branch);
102
+ const mode: BriefMode = input.mode ?? (head ? "resume" : "new");
103
+ if (mode === "resume" && !head)
104
+ return fanoutFail(
105
+ "not_found",
106
+ `MODE resume needs the branch ${branch}, which does not exist here`,
107
+ `workit fanout brief ${slice.id} --mode new`,
108
+ );
109
+
110
+ const planned = `${slicePath(cwd, slice)}${input.attempt === null ? "" : `+try${input.attempt}`}`;
111
+ const exists = listWorktrees(cwd).some((entry) => samePath(entry.path, planned));
112
+ excludeScratch(cwd);
113
+ const scratch = exists ? `${planned}/${SCRATCH_DIR}` : SCRATCH_DIR;
114
+ const scratchLine = exists
115
+ ? `SCRATCH: ${scratch} (yours alone; temp files go here, never a shared path)`
116
+ : `SCRATCH: ${SCRATCH_DIR}/ at your worktree root (mkdir -p it; git ignores it; never a shared path)`;
117
+
118
+ const standing = activeStanding(ledger.value.rows, plan.name, planLeads(plan)).map((row) => ({
119
+ id: row.id,
120
+ what: String(row.what),
121
+ }));
122
+ const session = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
123
+ const fanIn =
124
+ plan.fanIn === "integration"
125
+ ? [
126
+ `FAN-IN: integration branch ${plan.trunk}. Before you report, merge its tip into your branch`,
127
+ ` (\`git merge --no-edit ${plan.trunk}\`), resolve conflicts inside SCOPE and re-run VERIFY, so the`,
128
+ " lead's merge is a fast-forward. No other merge, and no rebase, retarget or force-push.",
129
+ ]
130
+ : [
131
+ "FAN-IN: one PR per slice. Never rebase, retarget, merge or force-push: the lead owns topology.",
132
+ ];
133
+
134
+ const lines = [
135
+ `MODE: ${mode}${input.attempt === null ? "" : ` (race attempt ${input.attempt}: the lead keeps the attempt with the best verdict)`}`,
136
+ `GOAL: ${slice.goal}`,
137
+ `SCOPE: ${slice.scope.join(", ")}`,
138
+ ...(slice.owns.length ? [` owns: ${slice.owns.join(", ")}`] : []),
139
+ ` branch: ${branch} base: ${slice.base}`,
140
+ `CONTEXT: ${slice.context ?? "none given: read the files in SCOPE and the neighbours they import"}`,
141
+ ...(mode === "resume" && head
142
+ ? [
143
+ ` resume: continue ${branch} from ${head.slice(0, 12)}; read \`git log ${slice.base}..${branch}\` and \`workit ledger list --branch ${branch}\` (the last report, verdicts, rulings)`,
144
+ ]
145
+ : []),
146
+ "ACCEPTANCE:",
147
+ ...slice.acceptance.map((line) => ` - ${line}`),
148
+ `VERIFY: ${slice.verify.join("; ")}`,
149
+ `TIER: ${slice.tier}`,
150
+ `TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
151
+ scratchLine,
152
+ `FORBIDDEN: ${slice.forbidden.join("; ")}`,
153
+ ...fanIn,
154
+ ...REPORT,
155
+ "STANDING:",
156
+ ...(standing.length
157
+ ? standing.map((order) => ` - ${order.what}`)
158
+ : [" - none recorded beyond this brief"]),
159
+ ` export WORKIT_SESSION_ID=${session}`,
160
+ ...rules(mode, branch, slice.base),
161
+ ];
162
+ return {
163
+ ok: true,
164
+ data: {
165
+ fanout: plan.name,
166
+ slice: slice.id,
167
+ mode,
168
+ attempt: input.attempt,
169
+ branch,
170
+ base: slice.base,
171
+ tier: slice.tier,
172
+ scratch,
173
+ session,
174
+ standing,
175
+ text: `${lines.join("\n")}\n`,
176
+ },
177
+ };
178
+ }
@@ -293,7 +293,8 @@ export function sliceLandings(
293
293
  createdFrom: sliceStart(cwd, rows, plan.name, slice),
294
294
  parentHead,
295
295
  fanout: plan.name,
296
- since: plan.createdAt || null,
296
+ since: slice.hashSince || plan.createdAt || null,
297
+ sliceHash: slice.hash || null,
297
298
  rows,
298
299
  }),
299
300
  );
@@ -12,8 +12,10 @@
12
12
  // commit (a squash merge that applied without the trunk touching the same
13
13
  // lines). Anything else reads as not landed.
14
14
  // A merged PR whose branch is gone counts only when the ledger links that
15
- // branch to this slice (a worktree row, or a row recorded on its merged
16
- // head), so a reused branch name from older work is not taken for this slice.
15
+ // branch to this slice: a worktree row made under the slice's current hash,
16
+ // or a row recorded on its merged head since that hash was planned. A reused
17
+ // branch name from older work, an older plan of the same name, or an earlier
18
+ // definition of this slice never links; editing a sibling changes nothing.
17
19
  // A landing whose changed paths read on the trunk exactly as before it (a
18
20
  // revert) is not landed; the note says so.
19
21
  // The forge is asked through a breaker: after the first unavailable answer
@@ -184,24 +186,44 @@ function trunkPatchIds(ctx: LandingContext, fork: string): Set<string> {
184
186
  return ids;
185
187
  }
186
188
 
189
+ /**
190
+ * Does `row` belong to the slice's current run? A worktree row made under its
191
+ * hash does; any other row only when recorded since that hash was planned.
192
+ */
193
+ export function inSliceRun(
194
+ row: Record<string, unknown>,
195
+ slice: Pick<Slice, "hash" | "hashSince">,
196
+ ): boolean {
197
+ if (row.type === "fanout.worktree.created" && typeof row.sliceHash === "string" && slice.hash)
198
+ return row.sliceHash === slice.hash;
199
+ const since = Date.parse(slice.hashSince);
200
+ return Number.isNaN(since) || Date.parse(String(row.at)) >= since;
201
+ }
202
+
187
203
  /** The ledger links `branch` to this slice: a worktree row, or a row recorded on `head`. */
188
204
  function linked(
189
205
  rows: readonly Record<string, unknown>[],
190
- input: Pick<LandingInput, "fanout" | "since">,
206
+ input: Pick<LandingInput, "fanout" | "since" | "sliceHash">,
191
207
  slice: Pick<Slice, "id" | "branch">,
192
208
  head: string | null,
193
209
  ): boolean {
194
- // Only rows from this run of the fanout: a plan of the same name made
195
- // earlier must not lend its merged PRs to this one.
210
+ // Only rows from this run of the slice: a plan of the same name made
211
+ // earlier, or this slice re-planned with another definition, lends nothing.
196
212
  const since = input.since ? Date.parse(input.since) : Number.NaN;
197
- return rows.some(
198
- (row) =>
199
- (Number.isNaN(since) || Date.parse(String(row.at)) >= since) &&
200
- ((row.type === "fanout.worktree.created" &&
201
- row.fanout === input.fanout &&
202
- row.slice === slice.id) ||
203
- (head !== null && row.branch === slice.branch && row.head === head)),
204
- );
213
+ const recent = (row: Record<string, unknown>) =>
214
+ Number.isNaN(since) || Date.parse(String(row.at)) >= since;
215
+ return rows.some((row) => {
216
+ if (
217
+ row.type === "fanout.worktree.created" &&
218
+ row.fanout === input.fanout &&
219
+ row.slice === slice.id
220
+ )
221
+ // A row from before slice hashes falls back to its time.
222
+ return typeof row.sliceHash === "string" && input.sliceHash
223
+ ? row.sliceHash === input.sliceHash
224
+ : recent(row);
225
+ return recent(row) && head !== null && row.branch === slice.branch && row.head === head;
226
+ });
205
227
  }
206
228
 
207
229
  export type LandingInput = {
@@ -212,8 +234,10 @@ export type LandingInput = {
212
234
  /** Its dependency branch's tip (or merged head) for a stacked slice. */
213
235
  parentHead: string | null;
214
236
  fanout: string;
215
- /** When this fanout plan was first made (FanoutFile.createdAt): older rows do not link. */
237
+ /** When this slice definition was first planned (Slice.hashSince): older rows do not link. */
216
238
  since: string | null;
239
+ /** The slice's definition hash: worktree rows link only under the same one. */
240
+ sliceHash: string | null;
217
241
  rows: readonly Record<string, unknown>[];
218
242
  };
219
243
 
@@ -298,7 +322,7 @@ export function sliceStart(
298
322
  cwd: string,
299
323
  rows: readonly Record<string, unknown>[],
300
324
  fanout: string,
301
- slice: Pick<Slice, "id" | "branch">,
325
+ slice: Pick<Slice, "id" | "branch" | "hash" | "hashSince">,
302
326
  ): string | null {
303
327
  const reflog = branchCreatedAt(cwd, slice.branch);
304
328
  if (reflog) return reflog;
@@ -307,7 +331,8 @@ export function sliceStart(
307
331
  candidate.type === "fanout.worktree.created" &&
308
332
  candidate.fanout === fanout &&
309
333
  candidate.slice === slice.id &&
310
- typeof candidate.baseSha === "string",
334
+ typeof candidate.baseSha === "string" &&
335
+ inSliceRun(candidate, slice),
311
336
  );
312
337
  return (row?.baseSha as string | undefined) ?? null;
313
338
  }
@@ -20,7 +20,7 @@ import { checksState, gatingChecks, missingRequired, type ChecksState } from "./
20
20
  import type { ResolvedForge } from "./forge/resolve";
21
21
  import { checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
22
22
  import { sliceLandings } from "./fanout-check";
23
- import { forgeBreaker } from "./fanout-landed";
23
+ import { forgeBreaker, inSliceRun } from "./fanout-landed";
24
24
  import {
25
25
  branchRef,
26
26
  fanoutFail,
@@ -123,6 +123,8 @@ function activity(cwd: string, plan: FanoutFile, slice: Slice, rows: readonly Re
123
123
  ]).stdout,
124
124
  );
125
125
  const reflog = moved ? isoOf(moved[1]) : null;
126
+ // Liveness reads every row on the branch or slice: re-planning a slice
127
+ // never makes a live worker look idle.
126
128
  const ledger = rows
127
129
  .filter(
128
130
  (row) => row.branch === slice.branch || (row.fanout === plan.name && row.slice === slice.id),
@@ -136,7 +138,8 @@ function activity(cwd: string, plan: FanoutFile, slice: Slice, rows: readonly Re
136
138
  (row) =>
137
139
  row.type === "fanout.worktree.created" &&
138
140
  row.fanout === plan.name &&
139
- row.slice === slice.id,
141
+ row.slice === slice.id &&
142
+ inSliceRun(row, slice),
140
143
  ),
141
144
  };
142
145
  }
@@ -267,7 +270,8 @@ export function fanoutStatus(
267
270
  (candidate) =>
268
271
  candidate.type === "fanout.worktree.created" &&
269
272
  candidate.fanout === plan.name &&
270
- candidate.slice === id,
273
+ candidate.slice === id &&
274
+ inSliceRun(candidate, byId.get(id) as Slice),
271
275
  );
272
276
  row.state = created ? "active" : "not_started";
273
277
  if (!created) spawnable.push(id);
@@ -12,10 +12,17 @@
12
12
  // (`fanout.worktree.released`), refuses while it has uncommitted changes
13
13
  // unless forced, then runs `git worktree remove` (which takes the scratch dir
14
14
  // with it). The branch is kept. It only removes a worktree that `create` made
15
- // for this fanout, slice and path (its ledger row says so), on the slice's
16
- // branch or detached; never the main checkout, the one the command runs in,
17
- // or a worktree someone else made there, not even with --force. An empty
15
+ // for this fanout, slice and path, on the slice's branch or detached; never
16
+ // the main checkout, the one the command runs in, or a worktree someone else
17
+ // made there, not even with --force. "Made by create" is exact: create writes
18
+ // a random token into git's admin dir for the worktree
19
+ // (`.git/worktrees/<id>/workit-fanout-token`) and records it with the admin
20
+ // dir's id and inode; release and adopt need all three to match. A worktree
21
+ // removed by hand and re-added at the path gets a new admin dir without the
22
+ // token, on any filesystem (no birth time needed). A row from before the
23
+ // token is matched by time only, so release refuses --force for it. An empty
18
24
  // directory that existed before `create` is left in place.
25
+ import { randomBytes } from "node:crypto";
19
26
  import fs from "node:fs";
20
27
  import path from "node:path";
21
28
  import { gitBranch } from "./git/ops";
@@ -68,7 +75,7 @@ const canonical = (target: string): string => {
68
75
  }
69
76
  };
70
77
 
71
- const samePath = (a: string, b: string): boolean => {
78
+ export const samePath = (a: string, b: string): boolean => {
72
79
  const left = canonical(a);
73
80
  const right = canonical(b);
74
81
  return process.platform === "win32" || process.platform === "darwin"
@@ -82,7 +89,7 @@ const inside = (child: string, parent: string): boolean => {
82
89
  };
83
90
 
84
91
  /** `git worktree list --porcelain -z`; the first entry is the main worktree. */
85
- function listWorktrees(cwd: string): WorktreeEntry[] {
92
+ export function listWorktrees(cwd: string): WorktreeEntry[] {
86
93
  const out: WorktreeEntry[] = [];
87
94
  let current: WorktreeEntry | null = null;
88
95
  for (const field of gitRun(cwd, ["worktree", "list", "--porcelain", "-z"]).stdout.split("\0")) {
@@ -98,7 +105,7 @@ function listWorktrees(cwd: string): WorktreeEntry[] {
98
105
  }
99
106
 
100
107
  /** The slice's worktree path: absolute since plan time; an older relative one is the main checkout's. */
101
- const slicePath = (cwd: string, slice: Slice): string =>
108
+ export const slicePath = (cwd: string, slice: Slice): string =>
102
109
  path.isAbsolute(slice.worktree)
103
110
  ? slice.worktree
104
111
  : path.resolve(mainCheckout(cwd), slice.worktree);
@@ -110,8 +117,10 @@ const CLOCK_SLACK_MS = 2000;
110
117
  * The live `fanout.worktree.created` row for this fanout, slice and path:
111
118
  * the newest one, unless a later release removed that worktree (a row is
112
119
  * void once its worktree is gone), and only while the worktree at the path
113
- * is the one it recorded: git's admin dir for it (`.git/worktrees/<id>`) was
114
- * made no earlier than the row. Anything else is someone else's worktree.
120
+ * is the one it recorded. `exact`: the admin dir's token, id and inode match
121
+ * the row. A row without a token (written before it) matches when the admin
122
+ * dir is no older than the row, which a re-added worktree also passes, so it
123
+ * is never exact. Anything else is someone else's worktree.
115
124
  */
116
125
  function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string) {
117
126
  const ledger = readLedger(cwd);
@@ -134,24 +143,75 @@ function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string)
134
143
  atPath(candidate),
135
144
  );
136
145
  if (removedSince) return null;
137
- const made = adminDirTime(target);
138
- if (made === null || made + CLOCK_SLACK_MS < Date.parse(row.at)) return null;
139
- return row;
146
+ const admin = adminDir(target);
147
+ if (!admin) return null;
148
+ if (typeof row.adminToken === "string")
149
+ return row.adminToken === admin.token && row.adminId === admin.id && row.adminIno === admin.ino
150
+ ? { row, exact: true }
151
+ : null;
152
+ const made = admin.birthMs ?? admin.mtimeMs;
153
+ if (made + CLOCK_SLACK_MS < Date.parse(row.at)) return null;
154
+ return { row, exact: false };
140
155
  }
141
156
 
142
- /** When git made the worktree's admin dir (birth time, else mtime); null when unknown. */
143
- function adminDirTime(worktree: string): number | null {
157
+ const TOKEN_FILE = "workit-fanout-token";
158
+
159
+ /** The filesystem calls identity relies on; tests stub them. */
160
+ export const worktreeDeps = {
161
+ stat: (file: string): fs.BigIntStats => fs.statSync(file, { bigint: true }),
162
+ };
163
+
164
+ type AdminDir = {
165
+ path: string;
166
+ /** `<id>` of `.git/worktrees/<id>`. */
167
+ id: string;
168
+ ino: string;
169
+ /** The token create wrote there, or null. */
170
+ token: string | null;
171
+ birthMs: number | null;
172
+ mtimeMs: number;
173
+ };
174
+
175
+ /** Git's admin dir for the worktree: what tells this worktree from a later one at the path. */
176
+ function adminDir(worktree: string): AdminDir | null {
144
177
  const dir = gitRun(worktree, ["rev-parse", "--absolute-git-dir"]);
145
178
  if (!dir.ok) return null;
179
+ const file = dir.stdout.trim();
180
+ let token: string | null = null;
181
+ try {
182
+ token = fs.readFileSync(path.join(file, TOKEN_FILE), "utf8").trim() || null;
183
+ } catch {
184
+ token = null;
185
+ }
186
+ try {
187
+ const stat = worktreeDeps.stat(file);
188
+ return {
189
+ path: file,
190
+ id: path.basename(file),
191
+ ino: stat.ino.toString(),
192
+ token,
193
+ birthMs: stat.birthtimeNs > 0n ? Number(stat.birthtimeMs) : null,
194
+ mtimeMs: Number(stat.mtimeMs),
195
+ };
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ /** Mark the admin dir of a worktree create just made; null when it cannot. */
202
+ function markAdminDir(worktree: string): (AdminDir & { token: string }) | null {
203
+ const admin = adminDir(worktree);
204
+ if (!admin) return null;
205
+ const token = randomBytes(16).toString("hex");
146
206
  try {
147
- const stat = fs.statSync(dir.stdout.trim());
148
- return stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
207
+ fs.writeFileSync(path.join(admin.path, TOKEN_FILE), `${token}\n`);
149
208
  } catch {
150
209
  return null;
151
210
  }
211
+ return { ...admin, token };
152
212
  }
153
213
 
154
- function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
214
+ export function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
155
215
  const slice = plan.slices.find((candidate) => candidate.id === id);
156
216
  return slice
157
217
  ? { ok: true, data: slice }
@@ -163,7 +223,7 @@ function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
163
223
  }
164
224
 
165
225
  /** Hide the scratch dir from git status and `git add -A` in every worktree. */
166
- function excludeScratch(cwd: string): void {
226
+ export function excludeScratch(cwd: string): void {
167
227
  const common = gitCommonDir(cwd);
168
228
  if (!common) return;
169
229
  const file = path.join(common, "info", "exclude");
@@ -325,6 +385,7 @@ export function createSliceWorktree(
325
385
  const scratch = ensureScratch(target);
326
386
  const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
327
387
  const baseSha = mode === "new" ? head : null;
388
+ const admin = markAdminDir(target);
328
389
  appendObserved(cwd, {
329
390
  type: "fanout.worktree.created",
330
391
  actor: input.actor,
@@ -337,6 +398,8 @@ export function createSliceWorktree(
337
398
  head,
338
399
  dirExisted,
339
400
  planCreatedAt: plan.createdAt,
401
+ sliceHash: slice.hash,
402
+ ...(admin ? { adminId: admin.id, adminIno: admin.ino, adminToken: admin.token } : {}),
340
403
  ...(baseSha ? { baseSha } : {}),
341
404
  });
342
405
  return { ok: true, data: { ...base, scratch, head, mode, created: true, notes } };
@@ -410,13 +473,20 @@ export function releaseSliceWorktree(
410
473
  "blocked",
411
474
  `${entry.path} is on ${entry.branch.replace(/^refs\/heads\//u, "")}, not slice ${slice.id}'s ${slice.branch}; nothing was removed`,
412
475
  );
413
- const made = createdRow(cwd, plan, slice, entry.path);
414
- if (!made)
476
+ const owned = createdRow(cwd, plan, slice, entry.path);
477
+ if (!owned)
415
478
  return fanoutFail(
416
479
  "blocked",
417
480
  `${entry.path} was not made by workit fanout worktree create for slice ${slice.id} (no ledger row); nothing was removed`,
418
481
  `remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
419
482
  );
483
+ if (input.force && !owned.exact)
484
+ return fanoutFail(
485
+ "blocked",
486
+ `${entry.path} was created before workit recorded worktree identity, so it cannot be told from a worktree someone re-added there; --force is refused and nothing was removed`,
487
+ `release it without --force, or remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
488
+ );
489
+ const made = owned.row;
420
490
  out.head = entry.head;
421
491
  const status = gitRun(entry.path, [
422
492
  "status",
package/src/fanout.ts CHANGED
@@ -18,12 +18,33 @@
18
18
  // deterministic suggestion: an owner for known shared files (lockfiles,
19
19
  // manifests, barrels, CI config), else a dependency that serializes them.
20
20
  //
21
+ // Each slice's `hash` covers what it is (goal, acceptance, verify, forbidden,
22
+ // scope, owns, branch, base, dependencies), not how it is briefed (tier,
23
+ // timebox, context) or where its worktree goes. A re-plan that changes a
24
+ // slice starts a new run for that slice alone: its `hashSince` moves to now,
25
+ // and only ledger rows recorded under its hash (worktree rows carry
26
+ // `sliceHash`) or since then link a gone branch's merged PR to it or count as
27
+ // its worktree. Liveness still reads every row. Its siblings keep their runs.
28
+ //
29
+ // `fanIn: "integration"` is the one-PR mode: the trunk is an integration
30
+ // branch (never origin's default branch), workers merge its tip into their
31
+ // branch before reporting, and the lead fast-forwards it.
32
+ //
21
33
  // The CLI never spawns, waits or wakes anything: it only records and checks.
22
34
  import { spawnSync } from "node:child_process";
35
+ import { createHash } from "node:crypto";
23
36
  import fs from "node:fs";
24
37
  import path from "node:path";
25
38
  import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
26
- import { appendObserved, defaultBase, storeRoot, type LedgerActor } from "./ledger";
39
+ import {
40
+ activeStanding,
41
+ appendObserved,
42
+ isDelegateSession,
43
+ defaultBase,
44
+ readLedger,
45
+ storeRoot,
46
+ type LedgerActor,
47
+ } from "./ledger";
27
48
  import { stackFileName } from "./stack";
28
49
 
29
50
  export const FANOUT_VERSION = 1;
@@ -48,15 +69,28 @@ export type Slice = {
48
69
  forbidden: string[];
49
70
  context: string | null;
50
71
  timebox: string | null;
72
+ /** sliceHash of this definition: which run of the slice a ledger row belongs to. */
73
+ hash: string;
74
+ /** When a slice with this hash was first planned: older rows are not its run. */
75
+ hashSince: string;
51
76
  };
52
77
 
78
+ export const FAN_IN_MODES = ["prs", "integration"] as const;
79
+ export type FanInMode = (typeof FAN_IN_MODES)[number];
80
+
53
81
  export type FanoutFile = {
54
82
  v: number;
55
83
  name: string;
56
84
  trunk: string;
85
+ /** prs: one PR per slice (default); integration: slices fast-forward one integration branch. */
86
+ fanIn: FanInMode;
57
87
  /** Extra shared-file globs on top of DEFAULT_SHARED. */
58
88
  shared: string[];
59
89
  slices: Slice[];
90
+ /** The lead: the session that made the plan or took it over (`--take-lead`). */
91
+ leadSession: string | null;
92
+ /** Leads it took over from: their standing orders stay in force until cleared. */
93
+ formerLeads: string[];
60
94
  createdAt: string;
61
95
  updatedAt: string;
62
96
  };
@@ -308,6 +342,9 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
308
342
  if ("error" in normalized) problems.push(`shared "${entry}" ${normalized.error}`);
309
343
  else shared.push(normalized.pattern);
310
344
  }
345
+ const fanIn = raw.fanIn ?? "prs";
346
+ if (!(FAN_IN_MODES as readonly unknown[]).includes(fanIn))
347
+ problems.push(`fanIn must be one of ${FAN_IN_MODES.join(", ")}`);
311
348
  if (!Array.isArray(raw.slices) || raw.slices.length === 0)
312
349
  return { ok: false, problems: [...problems, "slices: list at least one slice"] };
313
350
 
@@ -388,6 +425,8 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
388
425
  forbidden,
389
426
  context: optional("context"),
390
427
  timebox: optional("timebox"),
428
+ hash: "",
429
+ hashSince: "",
391
430
  });
392
431
  });
393
432
 
@@ -419,8 +458,11 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
419
458
  v: FANOUT_VERSION,
420
459
  name: planName,
421
460
  trunk,
461
+ fanIn: fanIn as FanInMode,
422
462
  shared,
423
463
  slices,
464
+ leadSession: null,
465
+ formerLeads: [],
424
466
  createdAt: "",
425
467
  updatedAt: "",
426
468
  },
@@ -728,6 +770,29 @@ export function worktreeRoot(cwd: string): string {
728
770
  return path.join(path.dirname(main), `${path.basename(main)}-wt`);
729
771
  }
730
772
 
773
+ /** Whose standing orders count: the lead and those it took over from; null before leads. */
774
+ export const planLeads = (
775
+ plan: Pick<FanoutFile, "leadSession" | "formerLeads">,
776
+ ): string[] | null => (plan.leadSession ? [plan.leadSession, ...plan.formerLeads] : null);
777
+
778
+ /**
779
+ * sha256 (16 hex) of the slice's definition. Brief-only fields (tier,
780
+ * timebox, context) and the worktree path are left out, so fixing a typo in
781
+ * them never re-dispatches landed work.
782
+ */
783
+ export function sliceHash(slice: Slice): string {
784
+ const {
785
+ worktree: _worktree,
786
+ hash: _hash,
787
+ hashSince: _since,
788
+ tier: _tier,
789
+ timebox: _timebox,
790
+ context: _context,
791
+ ...definition
792
+ } = slice;
793
+ return createHash("sha256").update(JSON.stringify(definition)).digest("hex").slice(0, 16);
794
+ }
795
+
731
796
  // ---------------------------------------------------------------------------
732
797
  // store
733
798
 
@@ -777,8 +842,12 @@ function parseStored(raw: unknown): FanoutFile | null {
777
842
  forbidden: list(item.forbidden),
778
843
  context: typeof item.context === "string" ? item.context : null,
779
844
  timebox: typeof item.timebox === "string" ? item.timebox : null,
845
+ hash: text(item.hash),
846
+ // A plan written before slice hashes: its run began at createdAt.
847
+ hashSince: text(item.hashSince) || text(raw.createdAt),
780
848
  });
781
849
  }
850
+ for (const slice of slices) slice.hash ||= sliceHash(slice);
782
851
  const shared = globs(raw.shared);
783
852
  const ids = new Set(slices.map((slice) => slice.id));
784
853
  if (
@@ -788,13 +857,18 @@ function parseStored(raw: unknown): FanoutFile | null {
788
857
  findCycle(slices)
789
858
  )
790
859
  return null;
860
+ const fanIn = raw.fanIn === "integration" ? "integration" : "prs";
861
+ const createdAt = text(raw.createdAt);
791
862
  return {
792
863
  v: typeof raw.v === "number" ? raw.v : FANOUT_VERSION,
793
864
  name: raw.name,
794
865
  trunk: raw.trunk,
866
+ fanIn,
795
867
  shared,
796
868
  slices,
797
- createdAt: text(raw.createdAt),
869
+ leadSession: typeof raw.leadSession === "string" && raw.leadSession ? raw.leadSession : null,
870
+ formerLeads: list(raw.formerLeads),
871
+ createdAt,
798
872
  updatedAt: text(raw.updatedAt),
799
873
  };
800
874
  }
@@ -890,6 +964,7 @@ function writeFanout(cwd: string, plan: FanoutFile): FanoutResult<string> {
890
964
  export type PlanOutcome = {
891
965
  name: string;
892
966
  trunk: string;
967
+ fanIn: FanInMode;
893
968
  file: string;
894
969
  created: boolean;
895
970
  slices: Array<
@@ -899,6 +974,8 @@ export type PlanOutcome = {
899
974
  waves: string[][];
900
975
  landingOrder: string[];
901
976
  notes: string[];
977
+ /** A new plan under a name that still has standing orders in force (an earlier run's?). */
978
+ warnings: string[];
902
979
  };
903
980
 
904
981
  /** Above this many slices in one wave, the lead queues the rest (4-6 in flight). */
@@ -906,11 +983,31 @@ export const IN_FLIGHT_CAP = 6;
906
983
 
907
984
  export function planFanout(
908
985
  cwd: string,
909
- input: { raw: unknown; name: string | null; trunk: string | null; actor: LedgerActor },
986
+ input: {
987
+ raw: unknown;
988
+ name: string | null;
989
+ trunk: string | null;
990
+ actor: LedgerActor;
991
+ /** Record this session as the lead (a lead resuming in a new session). */
992
+ takeLead?: boolean;
993
+ },
910
994
  now: Date = new Date(),
911
995
  ): FanoutResult<PlanOutcome> {
912
996
  const dir = fanoutsDir(cwd);
913
997
  if (!dir.ok) return dir;
998
+ const session = input.actor.session;
999
+ if (input.takeLead && !session)
1000
+ return fanoutFail(
1001
+ "blocked",
1002
+ "--take-lead needs a session: WORKIT_SESSION_ID is not set",
1003
+ "export WORKIT_SESSION_ID=<your session id>, then plan again with --take-lead",
1004
+ );
1005
+ if (input.takeLead && isDelegateSession(session))
1006
+ return fanoutFail(
1007
+ "blocked",
1008
+ `session ${session} is a worker or verifier id; only a lead takes the lead`,
1009
+ "run --take-lead from the lead's own session",
1010
+ );
914
1011
  const parsed = parsePlan(
915
1012
  input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
916
1013
  {
@@ -928,6 +1025,12 @@ export function planFanout(
928
1025
  { problems: parsed.problems },
929
1026
  );
930
1027
  const plan = parsed.plan;
1028
+ if (plan.fanIn === "integration" && plan.trunk === defaultTrunk(cwd))
1029
+ return fanoutFail(
1030
+ "invalid_input",
1031
+ `fanIn "integration" needs an integration branch as the trunk, not ${plan.trunk}`,
1032
+ `workit git branch <integration-branch> --base ${plan.trunk}, then plan with --trunk <integration-branch>`,
1033
+ );
931
1034
  const badBranches = plan.slices
932
1035
  .filter((slice) => !gitRun(cwd, ["check-ref-format", "--branch", slice.branch]).ok)
933
1036
  .map((slice) => `slice ${slice.id}: ${slice.branch} is not a valid branch name`);
@@ -975,20 +1078,60 @@ export function planFanout(
975
1078
  const existing = readFanout(cwd, plan.name);
976
1079
  const previous = existing.ok ? existing.data : null;
977
1080
  plan.createdAt = previous?.createdAt || now.toISOString();
1081
+ // A re-plan keeps the lead (and a plan from before leads stays without one)
1082
+ // unless this session takes it over.
1083
+ plan.leadSession = input.takeLead ? session : previous ? previous.leadSession : session;
1084
+ plan.formerLeads = [
1085
+ ...new Set([
1086
+ ...(previous?.formerLeads ?? []),
1087
+ ...(previous?.leadSession && previous.leadSession !== plan.leadSession
1088
+ ? [previous.leadSession]
1089
+ : []),
1090
+ ]),
1091
+ ].filter((former) => former !== plan.leadSession);
978
1092
  plan.updatedAt = now.toISOString();
1093
+ // Per slice: the same definition continues its run; a changed one starts anew.
1094
+ for (const slice of plan.slices) {
1095
+ slice.hash = sliceHash(slice);
1096
+ const before = previous?.slices.find((old) => old.id === slice.id);
1097
+ slice.hashSince =
1098
+ before && before.hash === slice.hash ? before.hashSince || plan.updatedAt : plan.updatedAt;
1099
+ }
979
1100
  const written = writeFanout(cwd, plan);
980
1101
  if (!written.ok) return written;
1102
+ if (previous && plan.leadSession !== previous.leadSession)
1103
+ appendObserved(cwd, {
1104
+ type: "fanout.lead.changed",
1105
+ actor: input.actor,
1106
+ fanout: plan.name,
1107
+ from: previous.leadSession,
1108
+ to: plan.leadSession,
1109
+ });
981
1110
  appendObserved(cwd, {
982
1111
  type: "fanout.planned",
983
1112
  actor: input.actor,
984
1113
  fanout: plan.name,
985
1114
  trunk: plan.trunk,
1115
+ fanIn: plan.fanIn,
986
1116
  slices: plan.slices.length,
987
1117
  ids: plan.slices.slice(0, 20).map((slice) => slice.id),
988
1118
  });
989
1119
 
990
1120
  const grouped = waves(plan.slices);
991
1121
  const notes: string[] = [];
1122
+ const warnings: string[] = [];
1123
+ if (!input.takeLead && previous?.leadSession && session && session !== previous.leadSession)
1124
+ notes.push(
1125
+ `the lead stays ${previous.leadSession}; this session is ${session}. Leading from this session now? workit fanout plan <plan.json> --take-lead`,
1126
+ );
1127
+ if (previous === null) {
1128
+ const ledger = readLedger(cwd);
1129
+ const inForce = ledger.ok ? activeStanding(ledger.value.rows, plan.name, planLeads(plan)) : [];
1130
+ if (inForce.length)
1131
+ warnings.push(
1132
+ `${inForce.length} standing order${inForce.length === 1 ? " is" : "s are"} already in force for fanout ${plan.name}, and every brief will carry ${inForce.length === 1 ? "it" : "them"}: ${inForce.map((row) => `"${String(row.what)}"`).join("; ")}. From an earlier run? workit ledger standing clear --fanout ${plan.name}`,
1133
+ );
1134
+ }
992
1135
  if (!ref)
993
1136
  notes.push(
994
1137
  `trunk ${plan.trunk} does not resolve here; only sample paths of each glob were compared`,
@@ -1009,6 +1152,7 @@ export function planFanout(
1009
1152
  data: {
1010
1153
  name: plan.name,
1011
1154
  trunk: plan.trunk,
1155
+ fanIn: plan.fanIn,
1012
1156
  file: written.data,
1013
1157
  created: previous === null,
1014
1158
  slices: plan.slices.map(({ id, branch, base, worktree, tier, dependsOn, scope, owns }) => ({
@@ -1025,6 +1169,7 @@ export function planFanout(
1025
1169
  waves: grouped,
1026
1170
  landingOrder: landingOrder(plan.slices),
1027
1171
  notes,
1172
+ warnings,
1028
1173
  },
1029
1174
  };
1030
1175
  }
package/src/ledger.ts CHANGED
@@ -130,8 +130,22 @@ export type VerdictRow = RowCommon & {
130
130
  evidenceRefs: string[];
131
131
  };
132
132
  export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
133
+ /** A lead's standing order for every worker of a fanout ("no new dependencies"). */
134
+ export type StandingRow = RowCommon & { type: "standing"; fanout: string; what: string };
135
+ /** Ends one standing order (`target`), or every one recorded before it (null). */
136
+ export type StandingClearedRow = RowCommon & {
137
+ type: "standing.cleared";
138
+ fanout: string;
139
+ target: string | null;
140
+ };
133
141
 
134
- export type LedgerRow = DecisionRow | RulingRow | VerdictRow | HandoffRow;
142
+ export type LedgerRow =
143
+ | DecisionRow
144
+ | RulingRow
145
+ | VerdictRow
146
+ | HandoffRow
147
+ | StandingRow
148
+ | StandingClearedRow;
135
149
  /** A row as read back: any known or future type, plus its position. */
136
150
  export type ReadRow = RowCommon &
137
151
  Record<string, unknown> & {
@@ -1076,6 +1090,142 @@ export function recordRuling(
1076
1090
  );
1077
1091
  }
1078
1092
 
1093
+ /**
1094
+ * May this row speak for the fanout's lead? With recorded leads (the current
1095
+ * one and any it took over from) only their rows count; a plan from before
1096
+ * leads (null) takes any session.
1097
+ */
1098
+ const fromLead = (row: ReadRow, leads: readonly string[] | null): boolean =>
1099
+ leads === null || (row.actor.session !== null && leads.includes(row.actor.session));
1100
+
1101
+ /**
1102
+ * The standing orders in force for `fanout`, oldest first (D17: odd rows are
1103
+ * skipped). Given the plan's lead session, orders and clears recorded by any
1104
+ * other session are ignored, so a worker can neither add nor clear one.
1105
+ */
1106
+ export function activeStanding(
1107
+ rows: readonly ReadRow[],
1108
+ fanout: string,
1109
+ leads: readonly string[] | null = null,
1110
+ ): ReadRow[] {
1111
+ const active: ReadRow[] = [];
1112
+ for (const row of rows) {
1113
+ if (row.fanout !== fanout || !fromLead(row, leads)) continue;
1114
+ if (row.type === "standing" && typeof row.what === "string" && row.what.trim()) {
1115
+ if (!row.superseded) active.push(row);
1116
+ } else if (row.type === "standing.cleared") {
1117
+ const target = str(row.target);
1118
+ if (target === null) active.length = 0;
1119
+ else {
1120
+ const index = active.findIndex((order) => order.id === target);
1121
+ if (index >= 0) active.splice(index, 1);
1122
+ }
1123
+ }
1124
+ }
1125
+ return active;
1126
+ }
1127
+
1128
+ /**
1129
+ * A worker or verifier session by the ids workit hands out: `fanout brief`'s
1130
+ * `<lead>-w-<slice>[+try<n>]`, a verifier's `<lead>-v<n>`, the Claude Code
1131
+ * hook's `<lead>:<agent id>` and `--as` ids (`<session>:<role>:<hex>`). Host
1132
+ * session ids carry no colon. Used where no recorded lead decides: plans that
1133
+ * predate `leadSession`, and `fanout plan --take-lead`.
1134
+ */
1135
+ export const isDelegateSession = (session: string | null): boolean =>
1136
+ session !== null &&
1137
+ (/-w-[a-z0-9._-]+(?:\+try\d)?$/u.test(session) ||
1138
+ /-v\d+$/u.test(session) ||
1139
+ session.includes(":"));
1140
+
1141
+ /**
1142
+ * Standing orders come from the fanout's lead: the session that first made
1143
+ * the plan (`lead`). A plan without one falls back to refusing the session
1144
+ * ids handed to workers and verifiers.
1145
+ */
1146
+ function leadOnly(context: RecordContext, lead: string | null): LedgerResult<void> {
1147
+ const session = context.actor.session;
1148
+ if (lead !== null ? session === lead : !isDelegateSession(session))
1149
+ return { ok: true, value: undefined };
1150
+ return err(
1151
+ "blocked",
1152
+ lead !== null
1153
+ ? `session ${session ?? "(none)"} is not the fanout's lead (${lead}): standing orders come from the lead`
1154
+ : `session ${session} is a worker or verifier: standing orders come from the lead`,
1155
+ lead !== null
1156
+ ? `the lead in a new session? workit fanout plan <plan.json> --take-lead from this session; a worker or verifier reports the order to the lead instead`
1157
+ : "a worker or verifier reports the order to the lead instead",
1158
+ );
1159
+ }
1160
+
1161
+ export function recordStanding(
1162
+ context: RecordContext,
1163
+ input: { fanout: string; lead: string | null; what?: string },
1164
+ ): LedgerResult<StandingRow> {
1165
+ const what = required(input.what, "<order>");
1166
+ if (!what.ok) return what;
1167
+ // One order per line of every brief: a newline or control character would
1168
+ // let an order forge other brief fields.
1169
+ if (/\p{Cc}/u.test(what.value))
1170
+ return err(
1171
+ "invalid_input",
1172
+ "a standing order is one line: no newlines or control characters",
1173
+ 'add each order separately: workit ledger standing add "<order>"',
1174
+ );
1175
+ const lead = leadOnly(context, input.lead);
1176
+ if (!lead.ok) return lead;
1177
+ const link = checkSupersede(context, "standing");
1178
+ if (!link.ok) return link;
1179
+ return appendRow<StandingRow>(
1180
+ context.cwd,
1181
+ {
1182
+ ...common(context, noteKey(context)),
1183
+ type: "standing",
1184
+ fanout: input.fanout,
1185
+ what: what.value,
1186
+ },
1187
+ { now: context.now },
1188
+ );
1189
+ }
1190
+
1191
+ /** Clear one standing order by id (it must be in force), or all of them; lead only. */
1192
+ export function clearStanding(
1193
+ context: RecordContext,
1194
+ input: {
1195
+ fanout: string;
1196
+ lead: string | null;
1197
+ leads?: readonly string[] | null;
1198
+ target: string | null;
1199
+ },
1200
+ ): LedgerResult<StandingClearedRow> {
1201
+ const lead = leadOnly(context, input.lead);
1202
+ if (!lead.ok) return lead;
1203
+ if (input.target !== null) {
1204
+ const ledger = readLedger(context.cwd);
1205
+ if (!ledger.ok) return ledger;
1206
+ if (
1207
+ !activeStanding(ledger.value.rows, input.fanout, input.leads ?? null).some(
1208
+ (row) => row.id === input.target,
1209
+ )
1210
+ )
1211
+ return err(
1212
+ "not_found",
1213
+ `no standing order ${input.target} in force for fanout ${input.fanout}`,
1214
+ `workit ledger standing list --fanout ${input.fanout}`,
1215
+ );
1216
+ }
1217
+ return appendRow<StandingClearedRow>(
1218
+ context.cwd,
1219
+ {
1220
+ ...common(context, noteKey(context)),
1221
+ type: "standing.cleared",
1222
+ fanout: input.fanout,
1223
+ target: input.target,
1224
+ },
1225
+ { now: context.now },
1226
+ );
1227
+ }
1228
+
1079
1229
  /**
1080
1230
  * Record an agent-asserted verdict on a branch's committed head. Refused on a
1081
1231
  * dirty worktree (the head would not be what was judged) and, for an author
@@ -1221,6 +1371,12 @@ export function summarizeRow(row: ReadRow): RowSummary {
1221
1371
  case "handoff":
1222
1372
  summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
1223
1373
  break;
1374
+ case "standing":
1375
+ summary = `${text("what")} (fanout ${text("fanout")})`;
1376
+ break;
1377
+ case "standing.cleared":
1378
+ summary = `cleared ${text("target") || "every standing order"} (fanout ${text("fanout")})`;
1379
+ break;
1224
1380
  case "commit.recorded":
1225
1381
  summary = `${(row.head ?? "?").slice(0, 12)} ${text("subject")}${row.actor.session ? "" : " (no session)"}`;
1226
1382
  break;