@brainervirus/workit-core 7.5.2 → 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.
@@ -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",