@brainervirus/workit-core 7.4.0 → 7.5.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.4.0",
3
+ "version": "7.5.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": [
@@ -14,30 +14,30 @@ ceremony; do it yourself.
14
14
  per slice an id, branch, TIER, the file-scope manifest (SCOPE globs),
15
15
  `owns` for shared files (lockfile, registry, barrels), `dependsOn` only for
16
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.
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.
22
23
  2. **Brief each worker** from its slice with the fixed template: GOAL, SCOPE,
23
24
  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.
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.
27
28
  3. **Spawn all workers in one message**, in the background, each in its own
28
- worktree (Claude Code: the `implementer` agent; elsewhere
29
- `git worktree add --detach ../<repo>-wt/<slug> origin/<base>`). The first
30
- command a worker runs is `workit git branch <branch> --base <base>`.
31
- 4. **Judge liveness by side effects only:** new commits and pushes
32
- (`git log <branch>`), PR and check changes (`workit pr status --branch <b>`).
33
- No progress past the timebox means stuck. Stop the old worker and observe
34
- that it exited (a timeout is not proof). `git worktree remove --force`
35
- drops its uncommitted changes, so first record `git -C <wt> status --short`
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):
38
- the new worker runs `git switch <branch>` in its fresh worktree instead of
39
- `workit git branch`. Never two live workers on one branch. Replace at most
40
- twice, then re-slice or report the gap. Never chain resumes.
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
41
  5. **Verify each slice independently.** A fresh agent that did not write it
42
42
  (Claude Code: the `verifier` agent) runs VERIFY and verify-<app>, then
43
43
  `workit ledger verdict <result> --branch <b> --how "<evidence>"` under the
@@ -47,12 +47,12 @@ ceremony; do it yourself.
47
47
  6. **Fan in** with `workit fanout check`: out-of-scope files (any file outside
48
48
  it stops the fan-in), and `git merge-tree` conflicts with trunk and between
49
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.
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 workit-ship.
56
56
 
57
57
  ## Example
58
58
 
@@ -14,6 +14,7 @@ ACCEPTANCE:
14
14
  VERIFY: <exact commands, e.g. `workit check test`, the verify-<app> feature to drive>
15
15
  TIER: <mundane | standard | hard: how much model the slice needs>
16
16
  TIMEBOX: <wall clock or turn budget; past it without a new commit you will be replaced>
17
+ SCRATCH: <your own temp dir: the one `fanout worktree create` printed, else `mktemp -d`; never a shared path>
17
18
  FORBIDDEN: <no edits outside SCOPE; no rebase, retarget, merge or force-push; no new dependencies; ...>
18
19
  REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
19
20
  each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
@@ -37,6 +38,7 @@ ACCEPTANCE:
37
38
  VERIFY: `workit check test`; verify-api feature "usage"
38
39
  TIER: standard
39
40
  TIMEBOX: 45 minutes
41
+ SCRATCH: ../app-wt/usage-endpoint/.workit-scratch
40
42
  FORBIDDEN: no edits outside SCOPE; no schema migration; no rebase or force-push; no new packages
41
43
  REPORT: as in the template
42
44
  STANDING: conventional commits; no comments that restate code; ask nothing, record rulings instead
@@ -50,7 +52,9 @@ verify, forbidden must be filled; tier is mundane, standard or hard). `base`
50
52
  defaults to the trunk, or to the branch of a single `dependsOn` slice (a
51
53
  stack); `worktree` defaults to `../<repo>-wt/<id>`. `owns` claims a shared
52
54
  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"`.
55
+ through a sample path. Use `/` as the separator; a backslash only escapes
56
+ literal brackets: `"app/\\[id\\]/page.tsx"`. A `timebox` such as `45 minutes`
57
+ is the slice's STUCK threshold in `workit fanout status`.
54
58
 
55
59
  ```json
56
60
  {
@@ -0,0 +1,27 @@
1
+ // One duration parser for CLI flags (`--timeout 20m`, `--stuck-after 1h30m`)
2
+ // and plan fields (a slice TIMEBOX such as `45 minutes`).
3
+
4
+ const UNITS: ReadonlyArray<[RegExp, number]> = [
5
+ [/^(?:ms|milliseconds?)$/u, 1],
6
+ [/^(?:s|secs?|seconds?)$/u, 1000],
7
+ [/^(?:m|mins?|minutes?)$/u, 60_000],
8
+ [/^(?:h|hrs?|hours?)$/u, 3_600_000],
9
+ ];
10
+
11
+ /**
12
+ * `20m`, `30s`, `1500ms`, `1h`, `1h30m`, `45 minutes`, `2 hours`, or bare
13
+ * seconds, in ms; null when it does not read as a duration.
14
+ */
15
+ export function parseDuration(value: string): number | null {
16
+ const text = value.trim().toLowerCase();
17
+ if (/^\d+(?:\.\d+)?$/u.test(text)) return Math.round(Number(text) * 1000);
18
+ const parts = [...text.matchAll(/(\d+(?:\.\d+)?)\s*([a-z]+)\s*/gu)];
19
+ if (parts.length === 0 || parts.map((part) => part[0]).join("") !== text) return null;
20
+ let total = 0;
21
+ for (const [, amount, unit] of parts) {
22
+ const factor = UNITS.find(([pattern]) => pattern.test(unit))?.[1];
23
+ if (factor === undefined) return null;
24
+ total += Number(amount) * factor;
25
+ }
26
+ return Math.round(total);
27
+ }
@@ -9,14 +9,22 @@
9
9
  // PRs that would conflict once the first lands are caught before either
10
10
  // does. A pairwise conflict is charged to the slice that lands later; the
11
11
  // earlier one stays landable.
12
+ // - case: a file or directory it adds that the trunk spells differently only
13
+ // in case (one path on macOS and Windows checkouts).
14
+ // A slice that already landed (fanout-landed.ts: its PR merged, squash
15
+ // merges included, or git shows its change on the trunk) is done: it is not
16
+ // checked, and slices that depend on it no longer wait for it.
12
17
  // The landing order puts dependencies first, then the plan's order. A slice
13
18
  // 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
19
+ // ready or landed. Verdicts are reported (`workit ledger check`) but gated by
15
20
  // `workit pr merge`, not here. Refs are read as they are locally: fetch first.
21
+ import type { ResolvedForge } from "./forge/resolve";
16
22
  import { checkVerdicts, readLedger, type ReadRow } from "./ledger";
17
23
  import {
18
24
  FANOUT_VERSION,
19
25
  branchRef,
26
+ caseClash,
27
+ caseIndex,
20
28
  fanoutFail,
21
29
  gitRun,
22
30
  inScope,
@@ -27,8 +35,15 @@ import {
27
35
  type FanoutResult,
28
36
  type Slice,
29
37
  } from "./fanout";
38
+ import {
39
+ detectLanding,
40
+ forgeBreaker,
41
+ sliceStart,
42
+ type LandingContext,
43
+ type SliceLanding,
44
+ } from "./fanout-landed";
30
45
 
31
- export type SliceStatus = "ready" | "blocked";
46
+ export type SliceStatus = "ready" | "blocked" | "landed";
32
47
 
33
48
  export type SliceCheck = {
34
49
  id: string;
@@ -38,10 +53,14 @@ export type SliceCheck = {
38
53
  status: SliceStatus;
39
54
  changed: number;
40
55
  outOfScope: string[];
56
+ /** Paths it adds that the trunk spells differently only in case: `ours ~ trunk's`. */
57
+ caseCollisions: string[];
41
58
  trunkConflicts: string[];
42
59
  siblingConflicts: Array<{ with: string; paths: string[] }>;
43
60
  waitsFor: string[];
44
61
  verdict: { accepted: boolean; basis: string };
62
+ /** How it landed, when it did (then nothing else was checked). */
63
+ landed: { how: string; pr: number | null } | null;
45
64
  reasons: string[];
46
65
  };
47
66
 
@@ -68,6 +87,13 @@ export type CheckOutcome = {
68
87
  notes: string[];
69
88
  };
70
89
 
90
+ export type CheckOptions = {
91
+ only?: readonly string[];
92
+ trunkRef?: string | null;
93
+ /** Asks the forge whether a slice's PR merged; null checks with git alone. */
94
+ forge?: ResolvedForge | null;
95
+ };
96
+
71
97
  const SAFE = (value: string | null): value is string => Boolean(value) && !value?.startsWith("-");
72
98
 
73
99
  /** Paths both sides change incompatibly, [] when clean, or an error. */
@@ -97,7 +123,7 @@ const isAncestor = (cwd: string, a: string, b: string): boolean =>
97
123
  export function checkFanout(
98
124
  cwd: string,
99
125
  plan: FanoutFile,
100
- options: { only?: readonly string[]; trunkRef?: string | null } = {},
126
+ options: CheckOptions = {},
101
127
  ): FanoutResult<CheckOutcome> {
102
128
  const unknown = (options.only ?? []).filter((id) => !plan.slices.some((s) => s.id === id));
103
129
  if (unknown.length)
@@ -119,10 +145,22 @@ export function checkFanout(
119
145
  const ledger = readLedger(cwd);
120
146
  const rows: readonly ReadRow[] = ledger.ok ? ledger.value.rows : [];
121
147
 
148
+ const breaker = forgeBreaker(options.forge ?? null);
149
+ const landings = sliceLandings(cwd, plan, trunk, tips, rows, breaker.forge);
150
+ const trunkCase = caseIndex(
151
+ nulList(gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", trunk]).stdout),
152
+ );
122
153
  const checks = new Map<string, SliceCheck>();
123
154
  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));
155
+ for (const slice of plan.slices) {
156
+ const landing = landings.get(slice.id) as SliceLanding;
157
+ checks.set(
158
+ slice.id,
159
+ landing.landed
160
+ ? landedCheck(slice, tips.get(slice.id) ?? null, landing)
161
+ : inspect(cwd, plan, slice, { trunk, trunkCase, tips, landings, rows }, changedBy),
162
+ );
163
+ }
126
164
 
127
165
  // Pairwise siblings: skip a pair where one contains the other (a stacked child).
128
166
  const conflicts: SiblingConflict[] = [];
@@ -132,6 +170,7 @@ export function checkFanout(
132
170
  const second = order[j];
133
171
  const a = tips.get(first);
134
172
  const b = tips.get(second);
173
+ if (landings.get(first)?.landed || landings.get(second)?.landed) continue;
135
174
  if (!a || !b || isAncestor(cwd, a, b) || isAncestor(cwd, b, a)) continue;
136
175
  const check = checks.get(second) as SliceCheck;
137
176
  const theirs = new Map(
@@ -173,8 +212,9 @@ export function checkFanout(
173
212
 
174
213
  for (const id of order) {
175
214
  const check = checks.get(id) as SliceCheck;
215
+ if (check.status === "landed") continue;
176
216
  check.waitsFor = (byId.get(id)?.dependsOn ?? []).filter(
177
- (dep) => checks.get(dep)?.status !== "ready",
217
+ (dep) => checks.get(dep)?.status !== "ready" && checks.get(dep)?.status !== "landed",
178
218
  );
179
219
  if (check.waitsFor.length) check.reasons.push(`waits for ${check.waitsFor.join(", ")}`);
180
220
  check.status = check.reasons.length ? "blocked" : "ready";
@@ -184,7 +224,7 @@ export function checkFanout(
184
224
  const slices = order
185
225
  .filter((id) => !selected || selected.has(id))
186
226
  .map((id) => checks.get(id) as SliceCheck);
187
- const ready = slices.every((check) => check.status === "ready");
227
+ const ready = slices.every((check) => check.status !== "blocked");
188
228
  const blocked = slices.find((check) => check.status === "blocked");
189
229
  const notes =
190
230
  plan.v === FANOUT_VERSION
@@ -192,9 +232,21 @@ export function checkFanout(
192
232
  : [
193
233
  `plan file version ${plan.v} is not ${FANOUT_VERSION}; read leniently (re-plan to rewrite it)`,
194
234
  ];
235
+ const forgeErrors = [...landings.values()].flatMap((landing) =>
236
+ landing.forgeError ? [landing.forgeError] : [],
237
+ );
238
+ if (!options.forge)
239
+ notes.push("landed slices detected with git only (no forge): a squash merge may be missed");
240
+ else if (breaker.down())
241
+ notes.push(`forge went down (${breaker.down()}); git alone decided the remaining slices`);
242
+ else if (forgeErrors.length)
243
+ notes.push(`forge lookups failed (${forgeErrors[0]}); git alone decided those slices`);
244
+ const toLand = slices.filter((check) => check.status === "ready").map((check) => check.id);
195
245
  const next = blocked
196
246
  ? 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)`;
247
+ : toLand.length
248
+ ? `land in this order: ${toLand.join(", ")} (workit pr merge each once verified; stacked slices with workit stack land)`
249
+ : "every slice has landed";
198
250
  return {
199
251
  ok: true,
200
252
  data: {
@@ -216,40 +268,132 @@ export function checkFanout(
216
268
  const resolves = (cwd: string, ref: string): boolean =>
217
269
  gitRun(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok;
218
270
 
219
- function inspect(
271
+ /** Landed state per slice, dependencies first (a child's base is its parent's merged head). */
272
+ export function sliceLandings(
220
273
  cwd: string,
221
274
  plan: FanoutFile,
222
- slice: Slice,
223
275
  trunk: string,
224
276
  tips: ReadonlyMap<string, string | null>,
225
277
  rows: readonly ReadRow[],
278
+ forge: ResolvedForge | null,
279
+ ): Map<string, SliceLanding> {
280
+ const ctx: LandingContext = { cwd, trunk, forge };
281
+ const out = new Map<string, SliceLanding>();
282
+ const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
283
+ for (const id of landingOrder(plan.slices)) {
284
+ const slice = byId.get(id) as Slice;
285
+ const parent = plan.slices.find((other) => other.branch === slice.base);
286
+ const parentHead = parent
287
+ ? (tips.get(parent.id) ?? out.get(parent.id)?.landedHead ?? null)
288
+ : null;
289
+ out.set(
290
+ id,
291
+ detectLanding(ctx, slice, {
292
+ head: tips.get(id) ?? null,
293
+ createdFrom: sliceStart(cwd, rows, plan.name, slice),
294
+ parentHead,
295
+ fanout: plan.name,
296
+ since: plan.createdAt || null,
297
+ rows,
298
+ }),
299
+ );
300
+ }
301
+ return out;
302
+ }
303
+
304
+ const blank = (slice: Slice, head: string | null): SliceCheck => ({
305
+ id: slice.id,
306
+ branch: slice.branch,
307
+ head,
308
+ base: slice.base,
309
+ status: "blocked",
310
+ changed: 0,
311
+ outOfScope: [],
312
+ caseCollisions: [],
313
+ trunkConflicts: [],
314
+ siblingConflicts: [],
315
+ waitsFor: [],
316
+ verdict: { accepted: false, basis: "missing" },
317
+ landed: null,
318
+ reasons: [],
319
+ });
320
+
321
+ function landedCheck(slice: Slice, head: string | null, landing: SliceLanding): SliceCheck {
322
+ return {
323
+ ...blank(slice, head),
324
+ status: "landed",
325
+ landed: { how: landing.how ?? "pr_merged", pr: landing.pr?.number ?? null },
326
+ };
327
+ }
328
+
329
+ type InspectContext = {
330
+ trunk: string;
331
+ trunkCase: ReadonlyMap<string, ReadonlySet<string>>;
332
+ tips: ReadonlyMap<string, string | null>;
333
+ landings: ReadonlyMap<string, SliceLanding>;
334
+ rows: readonly ReadRow[];
335
+ };
336
+
337
+ /** Files at `rev`, or null when it cannot be listed. */
338
+ const treeFiles = (cwd: string, rev: string | null): Set<string> | null => {
339
+ if (!rev) return null;
340
+ const run = gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", rev]);
341
+ return run.ok ? new Set(nulList(run.stdout)) : null;
342
+ };
343
+
344
+ /**
345
+ * Paths the slice adds or changes that the trunk spells differently only in
346
+ * case, unless the slice removed the trunk's spelling (a case rename).
347
+ */
348
+ function trunkCaseCollisions(
349
+ cwd: string,
350
+ ctx: InspectContext,
351
+ head: string,
352
+ changed: readonly string[],
353
+ ): string[] {
354
+ const headFiles = treeFiles(cwd, head);
355
+ const forkFiles = treeFiles(
356
+ cwd,
357
+ gitRun(cwd, ["merge-base", head, ctx.trunk]).stdout.trim() || null,
358
+ );
359
+ if (!headFiles) return [];
360
+ const present = (files: ReadonlySet<string> | null, prefix: string): boolean =>
361
+ Boolean(
362
+ files && (files.has(prefix) || [...files].some((file) => file.startsWith(`${prefix}/`))),
363
+ );
364
+ const out = new Set<string>();
365
+ for (const file of changed) {
366
+ if (!headFiles.has(file)) continue;
367
+ const clash = caseClash(ctx.trunkCase, file);
368
+ if (!clash) continue;
369
+ // The slice deleted the trunk's spelling it had: a deliberate case rename.
370
+ if (present(forkFiles, clash.trunk) && !present(headFiles, clash.trunk)) continue;
371
+ out.add(`${clash.path} ~ ${clash.trunk}`);
372
+ }
373
+ return [...out].toSorted();
374
+ }
375
+
376
+ function inspect(
377
+ cwd: string,
378
+ plan: FanoutFile,
379
+ slice: Slice,
380
+ ctx: InspectContext,
226
381
  changedBy: Map<string, string[]>,
227
382
  ): SliceCheck {
383
+ const { trunk, tips, rows } = ctx;
228
384
  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
- };
385
+ const check = blank(slice, head);
243
386
  if (!head) {
244
- check.reasons.push(`branch ${slice.branch} not found (not started, landed, or not fetched)`);
387
+ check.reasons.push(`branch ${slice.branch} not found (not started, or not fetched)`);
245
388
  return check;
246
389
  }
247
390
  const parent = plan.slices.find((other) => other.branch === slice.base);
391
+ // A landed parent whose branch is gone: diff against the head that merged.
248
392
  const baseRef =
249
393
  slice.base === plan.trunk
250
394
  ? trunk
251
395
  : parent
252
- ? (tips.get(parent.id) ?? null)
396
+ ? (tips.get(parent.id) ?? ctx.landings.get(parent.id)?.landedHead ?? null)
253
397
  : branchRef(cwd, slice.base);
254
398
  if (!SAFE(baseRef)) {
255
399
  check.reasons.push(`base ${slice.base} not found`);
@@ -267,6 +411,11 @@ function inspect(
267
411
  check.outOfScope = changed.filter((file) => !inScope(plan, slice, file)).toSorted();
268
412
  if (check.outOfScope.length)
269
413
  check.reasons.push(`edits outside its scope: ${check.outOfScope.join(", ")}`);
414
+ check.caseCollisions = trunkCaseCollisions(cwd, ctx, head, changed);
415
+ if (check.caseCollisions.length)
416
+ check.reasons.push(
417
+ `differs only in case from ${plan.trunk}: ${check.caseCollisions.join(", ")}`,
418
+ );
270
419
  const trunkResult = mergeConflicts(cwd, trunk, head);
271
420
  if (Array.isArray(trunkResult)) {
272
421
  check.trunkConflicts = trunkResult;
@@ -280,9 +429,11 @@ function inspect(
280
429
 
281
430
  function unblockFor(check: SliceCheck, plan: FanoutFile): string {
282
431
  if (!check.head)
283
- return `start or fetch ${check.branch}, then workit fanout check; if it landed, re-plan without slice ${check.id}`;
432
+ return `start or fetch ${check.branch}, then workit fanout check (a landed slice is detected through its merged PR when the forge is reachable)`;
284
433
  if (check.outOfScope.length)
285
434
  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`;
435
+ if (check.caseCollisions.length)
436
+ return `rename to ${plan.trunk}'s spelling on ${check.branch} (git mv), then workit fanout check`;
286
437
  if (check.trunkConflicts.length)
287
438
  return `rebase ${check.branch} onto ${plan.trunk} and resolve ${check.trunkConflicts.join(", ")}, then workit fanout check`;
288
439
  if (check.siblingConflicts.length) {