@erclx/canon 4.72.1 → 4.74.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.
Files changed (42) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/plan-feature/SKILL.md +2 -2
  3. package/claude/skills/plan-groundwork/SKILL.md +6 -5
  4. package/claude/skills/plan-intake/SKILL.md +1 -1
  5. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
  6. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -1
  7. package/claude/skills/session-relay/REQUIREMENT.md +2 -0
  8. package/claude/skills/session-relay/SKILL.md +1 -0
  9. package/claude/skills/task-board/SKILL.md +37 -3
  10. package/docs/agents/commands.md +1 -0
  11. package/docs/agents/index.md +1 -1
  12. package/docs/agents/install-and-sync.md +10 -6
  13. package/docs/agents/records.md +29 -9
  14. package/docs/agents/tasks.md +2 -0
  15. package/docs/target-projects.md +5 -1
  16. package/governance/rules/core/091-channel.md +13 -0
  17. package/package.json +1 -1
  18. package/src/claude/skills-reach.ts +13 -3
  19. package/src/commands/context.ts +7 -4
  20. package/src/commands/design.ts +6 -1
  21. package/src/commands/records.ts +138 -0
  22. package/src/commands/transcripts.ts +20 -3
  23. package/src/context/architecture.ts +15 -8
  24. package/src/context/citations.ts +31 -6
  25. package/src/context/folders.ts +27 -8
  26. package/src/gate/measures.ts +17 -8
  27. package/src/init/plan.ts +8 -1
  28. package/src/init/steps.ts +17 -0
  29. package/src/intake/folder.ts +1 -1
  30. package/src/migrate/records.ts +11 -3
  31. package/src/records/backup.ts +110 -38
  32. package/src/records/ordinal.ts +243 -0
  33. package/src/records/validate.ts +28 -0
  34. package/src/surface-root.ts +101 -0
  35. package/src/sync/stamp.ts +20 -5
  36. package/src/tasks/answers.ts +10 -1
  37. package/src/transcripts/fetch.ts +31 -1
  38. package/standards/groundwork.md +1 -1
  39. package/standards/intake.md +1 -1
  40. package/standards/plan.md +1 -0
  41. package/tooling/base/configs/.husky/post-merge +27 -0
  42. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +5 -4
@@ -1,12 +1,12 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { join, relative, resolve } from 'node:path'
2
+ import { basename, join, relative, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
5
  import { RECORD_ROOTS, recordRoot } from '@/record-root'
6
6
 
7
7
  /**
8
8
  * The folders a backup carries, relative to the record root `workTree` resolves
9
- * rather than to either root specifically, since the same nine names sit under
9
+ * rather than to either root specifically, since the same ten names sit under
10
10
  * whichever one a tree holds.
11
11
  *
12
12
  * Nothing bounds this list any more, and the move is what took the bound away.
@@ -18,12 +18,12 @@ import { RECORD_ROOTS, recordRoot } from '@/record-root'
18
18
  * a name is written here.
19
19
  *
20
20
  * Three counts describe this surface and each is right about a different
21
- * question, so they are stated apart rather than reconciled. Nine is what a
22
- * disk loss would take, which is this list. Eleven is what sat under `.claude/`
21
+ * question, so they are stated apart rather than reconciled. Ten is what a
22
+ * disk loss would take, which is this list. Twelve is what sat under `.claude/`
23
23
  * as an ignored folder before the move, which adds the scratch folder that is
24
24
  * deletable without loss and `worktrees/`, whose contents belong to the
25
- * enclosing repository already. Twelve is what the move relocated, which counts
26
- * ignore entries rather than folders: the eleven less `worktrees/`, which stayed,
25
+ * enclosing repository already. Thirteen is what the move relocated, which counts
26
+ * ignore entries rather than folders: the twelve less `worktrees/`, which stayed,
27
27
  * plus `.records.git/` and the `README.md` a records pull writes back.
28
28
  *
29
29
  * Each entry is a top-level record folder and every archive sits inside the one
@@ -46,6 +46,7 @@ export const BACKED_FOLDERS = [
46
46
  'review',
47
47
  'tasks',
48
48
  'teach',
49
+ 'transcripts',
49
50
  ] as const
50
51
 
51
52
  /**
@@ -88,8 +89,46 @@ function recordsGitDir(root: string): string {
88
89
  return join(workTree(root), RECORDS_GIT_NAME)
89
90
  }
90
91
 
91
- /** Both directions name the branch, so a machine whose `init.defaultBranch` differs still lands on it. */
92
- const RECORDS_BRANCH = 'main'
92
+ /**
93
+ * A run of characters outside what git accepts in a ref segment, replaced
94
+ * with a single dash, with a leading or trailing `/` or `.` trimmed after.
95
+ */
96
+ function sanitizeRefSegment(segment: string): string {
97
+ return segment
98
+ .replace(/[^a-z0-9_./-]+/g, '-')
99
+ .replace(/^[/.]+/, '')
100
+ .replace(/[/.]+$/, '')
101
+ }
102
+
103
+ /**
104
+ * The branch a records push or pull targets for the project at `root`,
105
+ * one branch per project on the shared records repository.
106
+ *
107
+ * Reduces the project's own `origin` through the same `remoteIdentity`
108
+ * reduction the shared-origin gate already applies, so two clones of the
109
+ * same project land on the same branch regardless of transport. Falls back
110
+ * to the project directory's own basename for a project with no `origin`,
111
+ * such as a fresh scaffold, which is no worse than the single shared branch
112
+ * this replaces: nothing else here distinguishes two such clones either.
113
+ *
114
+ * `enclosing` is `pushRecords`/`pullRecords`'s own already-fetched remote
115
+ * read, reused here rather than shelled out for a second time. A caller with
116
+ * none, such as a test reading this in isolation, gets one read of its own.
117
+ *
118
+ * A project's `origin` can change (rename, fork, transfer) between one push
119
+ * and the next, which silently starts writing to a new branch and orphans
120
+ * whatever was left on the old one. No migration handles that today.
121
+ */
122
+ export async function projectBranch(
123
+ root: string,
124
+ enclosing?: EnclosingRemotes,
125
+ ): Promise<string> {
126
+ const remotes = enclosing ?? (await enclosingRemotes(root))
127
+ const raw = remotes?.originUrl
128
+ ? remoteIdentity(remotes.originUrl)
129
+ : basename(resolve(root)).toLowerCase()
130
+ return sanitizeRefSegment(raw)
131
+ }
93
132
 
94
133
  /**
95
134
  * The records history is machine-written and nobody reads its authorship, so a
@@ -145,6 +184,7 @@ export type PullOutcome = PullReport | BackupRefused
145
184
 
146
185
  interface GitResult {
147
186
  readonly ok: boolean
187
+ readonly code: number
148
188
  readonly text: string
149
189
  readonly stderr: string
150
190
  }
@@ -180,6 +220,7 @@ async function records(root: string, args: string[]): Promise<GitResult> {
180
220
 
181
221
  return {
182
222
  ok: result.exitCode === 0,
223
+ code: result.exitCode,
183
224
  text: result.stdout.toString().trim(),
184
225
  stderr: result.stderr.toString().trim(),
185
226
  }
@@ -217,9 +258,18 @@ function remoteIdentity(url: string): string {
217
258
  .replace(/\/+$/, '')
218
259
  }
219
260
 
261
+ interface EnclosingRemotes {
262
+ readonly identities: readonly string[]
263
+ readonly originUrl: string | undefined
264
+ }
265
+
220
266
  /**
221
- * Lists every remote of the enclosing project, or undefined when git cannot
222
- * answer.
267
+ * Reads every remote of the enclosing project in one call, or undefined when
268
+ * git cannot answer.
269
+ *
270
+ * `resolveRemote`'s shared-origin gate needs every remote's identity and
271
+ * `projectBranch` needs specifically `origin`'s raw URL, so both read from
272
+ * this one call rather than each shelling out to git on its own.
223
273
  *
224
274
  * The caller refuses on undefined rather than smoothing it into an empty list.
225
275
  * An empty list clears the gate below for every URL, so a git that failed for
@@ -227,22 +277,27 @@ function remoteIdentity(url: string): string {
227
277
  * happens to name. A project with no remotes answers `0` with an exit of zero,
228
278
  * so the two states stay distinguishable.
229
279
  */
230
- async function enclosingRemoteUrls(
280
+ async function enclosingRemotes(
231
281
  root: string,
232
- ): Promise<string[] | undefined> {
282
+ ): Promise<EnclosingRemotes | undefined> {
233
283
  const result = await $`git -C ${root} remote -v`
234
284
  .env(gitEnv())
235
285
  .quiet()
236
286
  .nothrow()
237
287
  if (result.exitCode !== 0) return undefined
238
288
 
239
- return result.stdout
240
- .toString()
241
- .split('\n')
242
- .filter(Boolean)
243
- .map((line) => line.split(/\s+/)[1] ?? '')
244
- .filter(Boolean)
245
- .map(remoteIdentity)
289
+ const identities: string[] = []
290
+ let originUrl: string | undefined
291
+
292
+ for (const line of result.stdout.toString().split('\n')) {
293
+ if (!line) continue
294
+ const [name, url] = line.split(/\s+/)
295
+ if (!url) continue
296
+ identities.push(remoteIdentity(url))
297
+ if (name === 'origin' && originUrl === undefined) originUrl = url
298
+ }
299
+
300
+ return { identities, originUrl }
246
301
  }
247
302
 
248
303
  /**
@@ -255,16 +310,20 @@ async function enclosingRemoteUrls(
255
310
  * misconfigured `origin` from publishing them, and refusing when that list
256
311
  * cannot be read is what keeps a failed comparison from reading as a pass.
257
312
  */
258
- async function resolveRemote(root: string): Promise<string | BackupRefused> {
313
+ async function resolveRemote(
314
+ root: string,
315
+ enclosing: EnclosingRemotes | undefined,
316
+ ): Promise<string | BackupRefused> {
259
317
  const gitDir = recordsGitDir(root)
260
318
 
261
319
  if (!existsSync(gitDir)) {
262
320
  return refuse(
263
321
  'no-repository',
264
322
  [
265
- `No records history at ${relative(root, gitDir)}. Create it once, against a private repository:`,
323
+ `No records history at ${relative(root, gitDir)}. One private repository backs every project on this machine, each on its own branch, so create it once, against whichever project sets it up first:`,
266
324
  ` git --git-dir=${gitDir} init`,
267
325
  ` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
326
+ `A person commits a README to that repository's main branch once. It is never machine-written.`,
268
327
  ].join('\n'),
269
328
  )
270
329
  }
@@ -280,7 +339,6 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
280
339
  )
281
340
  }
282
341
 
283
- const enclosing = await enclosingRemoteUrls(root)
284
342
  if (!enclosing) {
285
343
  return refuse(
286
344
  'remote-unreadable',
@@ -289,7 +347,7 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
289
347
  }
290
348
 
291
349
  const url = remoteIdentity(remote.text)
292
- if (enclosing.includes(url)) {
350
+ if (enclosing.identities.includes(url)) {
293
351
  return refuse(
294
352
  'remote-shared',
295
353
  `The records origin ${remote.text} is a remote of this project. Records carry the memory pen and the groundwork trails, so they need a repository of their own.`,
@@ -396,7 +454,8 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
396
454
  const split = refuseSplitRoots(root)
397
455
  if (split) return split
398
456
 
399
- const remote = await resolveRemote(root)
457
+ const enclosing = await enclosingRemotes(root)
458
+ const remote = await resolveRemote(root, enclosing)
400
459
  if (typeof remote !== 'string') return remote
401
460
 
402
461
  const scope = await scopedFolders(root)
@@ -438,10 +497,11 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
438
497
  return { ok: true, root, folders, changed, pushed: false }
439
498
  }
440
499
 
500
+ const branch = await projectBranch(root, enclosing)
441
501
  const pushed = await records(root, [
442
502
  'push',
443
503
  'origin',
444
- `HEAD:refs/heads/${RECORDS_BRANCH}`,
504
+ `HEAD:refs/heads/${branch}`,
445
505
  ])
446
506
  if (!pushed.ok) return failed('push', pushed)
447
507
 
@@ -460,26 +520,38 @@ export async function pullRecords(root: string): Promise<PullOutcome> {
460
520
  const split = refuseSplitRoots(root)
461
521
  if (split) return split
462
522
 
463
- const remote = await resolveRemote(root)
523
+ const enclosing = await enclosingRemotes(root)
524
+ const remote = await resolveRemote(root, enclosing)
464
525
  if (typeof remote !== 'string') return remote
465
526
 
527
+ const branch = await projectBranch(root, enclosing)
528
+
529
+ // Checked ahead of the fetch rather than parsed out of a failed fetch's
530
+ // stderr, which is git's own message and translates on a localized
531
+ // machine. `--exit-code` answers through a code no locale changes: 2 for
532
+ // no matching ref, 0 for found, anything else for a remote git could not
533
+ // reach at all.
534
+ const remoteBranch = await records(root, [
535
+ 'ls-remote',
536
+ '--exit-code',
537
+ 'origin',
538
+ `refs/heads/${branch}`,
539
+ ])
540
+ if (remoteBranch.code === 2) {
541
+ return refuse(
542
+ 'no-remote-records',
543
+ `The records origin carries no ${branch} branch yet. Run canon records push from the machine holding the records.`,
544
+ )
545
+ }
546
+ if (!remoteBranch.ok) return failed('ls-remote', remoteBranch)
547
+
466
548
  const fetched = await records(root, [
467
549
  'fetch',
468
550
  '--quiet',
469
551
  'origin',
470
- `refs/heads/${RECORDS_BRANCH}`,
552
+ `refs/heads/${branch}`,
471
553
  ])
472
- if (!fetched.ok) {
473
- // A missing branch and an unreachable remote both fail the fetch, and only
474
- // the first is an ordinary state a person resolves by pushing once.
475
- if (fetched.stderr.includes("couldn't find remote ref")) {
476
- return refuse(
477
- 'no-remote-records',
478
- `The records origin carries no ${RECORDS_BRANCH} branch yet. Run canon records push from the machine holding the records.`,
479
- )
480
- }
481
- return failed('fetch', fetched)
482
- }
554
+ if (!fetched.ok) return failed('fetch', fetched)
483
555
 
484
556
  const target = await records(root, ['rev-parse', 'FETCH_HEAD'])
485
557
  if (!target.ok) return failed('rev-parse', target)
@@ -0,0 +1,243 @@
1
+ import { mkdir, readdir, rm, stat } from 'node:fs/promises'
2
+ import { extractOrdinal } from '@/intake/folder'
3
+ import { recordDir } from '@/record-root'
4
+
5
+ /**
6
+ * The two record folders that share one ordinal sequence, per
7
+ * `standards/intake.md` and `standards/groundwork.md`. A folder opened as
8
+ * either kind blocks the same number for the other, so the two read together
9
+ * rather than each keeping a count of its own.
10
+ */
11
+ export const ORDINAL_KINDS = ['intake', 'groundwork'] as const
12
+
13
+ export type OrdinalKind = (typeof ORDINAL_KINDS)[number]
14
+
15
+ export function isOrdinalKind(value: string): value is OrdinalKind {
16
+ return (ORDINAL_KINDS as readonly string[]).includes(value)
17
+ }
18
+
19
+ const ORDINAL_WIDTH = 2
20
+
21
+ /** Bounds the retry loop below. A fifth collision in a row is not a race. */
22
+ const MAX_ATTEMPTS = 5
23
+
24
+ /**
25
+ * How long a reservation with no leaf folder behind it stays live before
26
+ * `reserve` will clear it.
27
+ *
28
+ * An ordinary claim reserves the number and creates its leaf folder with
29
+ * nothing awaited in between, so it finishes in milliseconds. The threshold
30
+ * only has to clear that by a wide margin, not bound it tightly: reading a
31
+ * genuinely abandoned lock as live costs one retry, while reading one still
32
+ * in flight as abandoned reopens the exact duplicate this recovery exists to
33
+ * close, so the cost of guessing too short is the one that matters here.
34
+ */
35
+ const STALE_LOCK_MS = 5 * 60 * 1000
36
+
37
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
38
+ return error instanceof Error && 'code' in error
39
+ }
40
+
41
+ async function listNames(dir: string): Promise<string[]> {
42
+ try {
43
+ const entries = await readdir(dir, { withFileTypes: true })
44
+ return entries
45
+ .filter((entry) => entry.isDirectory())
46
+ .map((entry) => entry.name)
47
+ } catch (error) {
48
+ if (isErrnoException(error) && error.code === 'ENOENT') return []
49
+ throw error
50
+ }
51
+ }
52
+
53
+ /**
54
+ * The highest ordinal already claimed across both `.canon/intake/` and
55
+ * `.canon/groundwork/`, or `0` with neither folder holding an entry.
56
+ */
57
+ export async function highestOrdinal(root: string): Promise<number> {
58
+ const names = (
59
+ await Promise.all(
60
+ ORDINAL_KINDS.map((kind) => listNames(recordDir(root, kind))),
61
+ )
62
+ ).flat()
63
+
64
+ return names
65
+ .map((name) => extractOrdinal(name))
66
+ .filter((ordinal) => ordinal !== '')
67
+ .map(Number)
68
+ .reduce((carry, ordinal) => Math.max(carry, ordinal), 0)
69
+ }
70
+
71
+ function pad(ordinal: number): string {
72
+ return String(ordinal).padStart(ORDINAL_WIDTH, '0')
73
+ }
74
+
75
+ /**
76
+ * Where a number is reserved ahead of creating either kind's own folder.
77
+ *
78
+ * The two kinds share one sequence but not one directory, so a leaf `mkdir`
79
+ * under `intake/` and one under `groundwork/` never collide with each other
80
+ * even when both land on the same number in the same instant, which is the
81
+ * race this verb exists to close. Reserving the number itself, at a path both
82
+ * calls resolve to regardless of kind, is what makes the two contend on the
83
+ * same `mkdir`.
84
+ */
85
+ function lockPath(root: string, ordinal: string): string {
86
+ return recordDir(root, 'ordinal-locks', ordinal)
87
+ }
88
+
89
+ async function isBacked(root: string, ordinal: string): Promise<boolean> {
90
+ const names = (
91
+ await Promise.all(
92
+ ORDINAL_KINDS.map((kind) => listNames(recordDir(root, kind))),
93
+ )
94
+ ).flat()
95
+
96
+ return names.some((name) => extractOrdinal(name) === ordinal)
97
+ }
98
+
99
+ /**
100
+ * `undefined` when the lock is already gone, otherwise how long ago it was
101
+ * created. The lock directory is never written to after its `mkdir`, so its
102
+ * `mtime` is its creation time.
103
+ */
104
+ async function lockAgeMs(dir: string): Promise<number | undefined> {
105
+ try {
106
+ const info = await stat(dir)
107
+ return Date.now() - info.mtimeMs
108
+ } catch (error) {
109
+ if (isErrnoException(error) && error.code === 'ENOENT') return undefined
110
+ throw error
111
+ }
112
+ }
113
+
114
+ /**
115
+ * `true` when a lock carries no leaf folder in either kind and is old enough
116
+ * that an ordinary claim could not still be in flight.
117
+ *
118
+ * No backing folder alone is not enough: `claimOrdinal` awaits the leaf
119
+ * `mkdir` after `reserve` returns, so a second process can observe another's
120
+ * lock in that exact gap, with no folder behind it yet even though the first
121
+ * process is seconds from creating one. Reading that gap as abandoned lets
122
+ * both processes land a leaf folder at the same ordinal under their own
123
+ * kind, which never collide with each other, reopening the race this verb
124
+ * exists to close. Requiring the lock to also be older than `STALE_LOCK_MS`
125
+ * is what keeps that window from being read as abandoned.
126
+ */
127
+ async function isStale(root: string, ordinal: string): Promise<boolean> {
128
+ if (await isBacked(root, ordinal)) return false
129
+
130
+ const age = await lockAgeMs(lockPath(root, ordinal))
131
+ return age !== undefined && age >= STALE_LOCK_MS
132
+ }
133
+
134
+ /**
135
+ * `true` when this call won the reservation, `false` when another call holds
136
+ * a live one.
137
+ *
138
+ * A lock already held is read against the kind folders before it is read as
139
+ * contention. Reserving and creating the leaf folder are two acts rather than
140
+ * one, so a process killed in between leaves a lock with nothing behind it,
141
+ * and a caller walking that lock as ordinary contention would refuse every
142
+ * later claim as `ordinal-contended` on a cause that was never contention.
143
+ * Clearing a stale lock and retrying the same number is what keeps that dead
144
+ * reservation from costing every claim that follows it.
145
+ */
146
+ async function reserve(root: string, ordinal: string): Promise<boolean> {
147
+ const locks = recordDir(root, 'ordinal-locks')
148
+ await mkdir(locks, { recursive: true })
149
+
150
+ const dir = lockPath(root, ordinal)
151
+
152
+ try {
153
+ await mkdir(dir, { recursive: false })
154
+ return true
155
+ } catch (error) {
156
+ if (!isErrnoException(error) || error.code !== 'EEXIST') throw error
157
+ if (!(await isStale(root, ordinal))) return false
158
+
159
+ await rm(dir, { recursive: true, force: true })
160
+
161
+ try {
162
+ await mkdir(dir, { recursive: false })
163
+ return true
164
+ } catch (retryError) {
165
+ if (isErrnoException(retryError) && retryError.code === 'EEXIST') {
166
+ return false
167
+ }
168
+ throw retryError
169
+ }
170
+ }
171
+ }
172
+
173
+ export const ORDINAL_REFUSALS = ['ordinal-contended'] as const
174
+
175
+ export type OrdinalRefusal = (typeof ORDINAL_REFUSALS)[number]
176
+
177
+ export interface Claimed {
178
+ readonly ok: true
179
+ readonly kind: OrdinalKind
180
+ readonly slug: string
181
+ readonly ordinal: string
182
+ readonly name: string
183
+ readonly path: string
184
+ }
185
+
186
+ export interface ClaimRefused {
187
+ readonly ok: false
188
+ readonly reason: OrdinalRefusal
189
+ readonly message: string
190
+ readonly lastOrdinal: string
191
+ }
192
+
193
+ export type ClaimOutcome = Claimed | ClaimRefused
194
+
195
+ /**
196
+ * Computes the next ordinal and creates `.canon/<kind>/<nn>-<slug>/` in one
197
+ * act, so no window sits between the read and the create for a second session
198
+ * to land in, whichever kind that session is claiming.
199
+ *
200
+ * The reservation is what does the serializing: it is the one `mkdir` both an
201
+ * `intake` claim and a `groundwork` claim resolve to the same path for when
202
+ * they land on the same number, where the two kinds' own leaf folders never
203
+ * share a path and so never collide with each other directly. A losing
204
+ * reservation surfaces as `EEXIST`, `reserve` tells a live one from a stale
205
+ * one left by a process that died before its own leaf create ran, and the
206
+ * loser on a live one re-reads the highest ordinal and retries, bounded so a
207
+ * run of contention past what an ordinary race produces surfaces as a refusal
208
+ * instead of a longer silent retry.
209
+ *
210
+ * The kind's own leaf `mkdir` still runs with `recursive: false` once the
211
+ * reservation is won, since a recursive `mkdir` succeeds silently against a
212
+ * directory that already exists, and this call's own reservation ending up
213
+ * stale is exactly the case the staleness check above exists to recover.
214
+ */
215
+ export async function claimOrdinal(
216
+ root: string,
217
+ kind: OrdinalKind,
218
+ slug: string,
219
+ ): Promise<ClaimOutcome> {
220
+ const parent = recordDir(root, kind)
221
+ await mkdir(parent, { recursive: true })
222
+
223
+ let lastOrdinal = ''
224
+
225
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
226
+ const ordinal = pad((await highestOrdinal(root)) + 1)
227
+ lastOrdinal = ordinal
228
+
229
+ if (!(await reserve(root, ordinal))) continue
230
+
231
+ const name = `${ordinal}-${slug}`
232
+ const dir = recordDir(root, kind, name)
233
+ await mkdir(dir, { recursive: false })
234
+ return { ok: true, kind, slug, ordinal, name, path: dir }
235
+ }
236
+
237
+ return {
238
+ ok: false,
239
+ reason: 'ordinal-contended',
240
+ message: `${MAX_ATTEMPTS} claims collided in a row, last at ${lastOrdinal}. That is more than an ordinary race, so this stops rather than retrying further.`,
241
+ lastOrdinal,
242
+ }
243
+ }
@@ -73,6 +73,7 @@ export const FINDING_KINDS = [
73
73
  'item-incomplete',
74
74
  'category-mismatch',
75
75
  'operator-call-phrasing',
76
+ 'batch-unsplit',
76
77
  ] as const
77
78
 
78
79
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -235,6 +236,18 @@ async function listFolders(dir: string): Promise<string[]> {
235
236
 
236
237
  const PLAN_NAME = /^feature-[a-z0-9]+(-[a-z0-9]+)*\.md$/
237
238
  const PLAN_TITLE = /^#[ \t]+Feature:[ \t]+\S/
239
+ const BATCH_LABEL = /^\*\*Batch\s+\d+\b.*\*\*/i
240
+
241
+ /**
242
+ * Reads the raw text rather than `splitPlanSections`'s output, since a
243
+ * colon-bearing `**Batch 1: ...**` line is itself a `MARKER_LINE` and is
244
+ * already dropped from the split `Files to touch` section before any check
245
+ * sees it.
246
+ */
247
+ export function hasStagedBatches(text: string): boolean {
248
+ return linesOutsideFences(text).some((line) => BATCH_LABEL.test(line.trim()))
249
+ }
250
+
238
251
  /**
239
252
  * An entry names a file and says something about it. Both halves are tested as
240
253
  * facts rather than as a syntax: a backticked span anywhere, and prose left over
@@ -463,6 +476,21 @@ export function checkPlan(name: string, text: string): Finding[] {
463
476
 
464
477
  findings.push(...checkQuestionContract(name, sections.get('Questions') ?? []))
465
478
 
479
+ if (hasStagedBatches(text)) {
480
+ const staged = linesOutsideFences(text).find((line) =>
481
+ BATCH_LABEL.test(line.trim()),
482
+ )
483
+
484
+ findings.push(
485
+ finding(
486
+ 'batch-unsplit',
487
+ name,
488
+ shorten((staged ?? '').trim()),
489
+ "stages a batch inside one file, so the batch sharing this plan's branch has nothing left to open a pull request against once an earlier one merges. Split it into one plan file per batch.",
490
+ ),
491
+ )
492
+ }
493
+
466
494
  return findings
467
495
  }
468
496
 
@@ -0,0 +1,101 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * The roots a tracked toolkit surface is read at, in precedence order.
6
+ *
7
+ * These are not `RECORD_ROOTS`, and the difference is what each root is for
8
+ * rather than an oversight. A record folder moves into `.canon/` because it is
9
+ * gitignored, so nothing tracked ever lands there. A surface here is committed,
10
+ * so a single shared list would put a record folder under a root a tracked
11
+ * file also resolves against, which is a collision this list never has to
12
+ * consider on its own.
13
+ *
14
+ * `canon` wins because a tree that carries it has moved, and reading `.claude/`
15
+ * there would answer from the copy the move left behind. Read precedence
16
+ * agreeing with the eventual creation default means the flip a later batch
17
+ * takes changes one line rather than two.
18
+ */
19
+ export const SURFACE_ROOTS = ['canon', '.claude'] as const
20
+
21
+ export type SurfaceRoot = (typeof SURFACE_ROOTS)[number]
22
+
23
+ /**
24
+ * The root a surface is created at when neither root carries it yet.
25
+ *
26
+ * Disagreeing with the head of the read order for exactly one release: read
27
+ * precedence is new-first so a tree that has moved is never answered from the
28
+ * copy left behind, while creation stays at the old root so nothing writes a
29
+ * fresh tracked file under a root a target's installed binary may not resolve
30
+ * yet. A later batch flips this once a release carries the read side.
31
+ */
32
+ export const CREATION_ROOT: SurfaceRoot = '.claude'
33
+
34
+ /**
35
+ * Every tracked surface this module resolves, at the name `.claude/` gives it.
36
+ *
37
+ * `canon` names the stamp folder rather than the CLI itself, which is the one
38
+ * entry `spell` respells per root.
39
+ */
40
+ export const SURFACE_ENTRIES: readonly string[] = [
41
+ 'ARCHITECTURE.md',
42
+ 'REQUIREMENTS.md',
43
+ 'DESIGN.md',
44
+ 'context',
45
+ 'wireframes',
46
+ 'canon',
47
+ ]
48
+
49
+ /**
50
+ * How a root spells an entry name. Only the stamp folder differs, since a
51
+ * project under `canon/` reserves the bare name for the CLI's own install
52
+ * rather than for its config.
53
+ */
54
+ export function spell(root: SurfaceRoot, entry: string): string {
55
+ return root === 'canon' && entry === 'canon' ? 'config' : entry
56
+ }
57
+
58
+ /**
59
+ * The root a surface resolves at: the first that carries it, and the creation
60
+ * default when neither does.
61
+ */
62
+ function rootOf(root: string, entry: string): SurfaceRoot {
63
+ return (
64
+ SURFACE_ROOTS.find((candidate) =>
65
+ existsSync(join(root, candidate, spell(candidate, entry))),
66
+ ) ?? CREATION_ROOT
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Where a tracked surface is read.
72
+ *
73
+ * `entry` is the surface itself and `rest` is whatever sits inside it, so a
74
+ * caller spells no root and no per-root naming variant of its own.
75
+ */
76
+ export function surfaceDir(
77
+ root: string,
78
+ entry: string,
79
+ ...rest: string[]
80
+ ): string {
81
+ const at = rootOf(root, entry)
82
+ return join(root, at, spell(at, entry), ...rest)
83
+ }
84
+
85
+ /**
86
+ * Every root a surface would be read at, in precedence order, whether or not
87
+ * it is on disk.
88
+ *
89
+ * Containment tests take this rather than `surfaceDir`, since a path written
90
+ * against the root a tree no longer uses is still a path into that surface,
91
+ * and reading it as outside would report a live reference as stale.
92
+ */
93
+ export function surfaceDirs(
94
+ root: string,
95
+ entry: string,
96
+ ...rest: string[]
97
+ ): string[] {
98
+ return SURFACE_ROOTS.map((candidate) =>
99
+ join(root, candidate, spell(candidate, entry), ...rest),
100
+ )
101
+ }