@erclx/canon 4.73.0 → 4.75.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 (52) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/create-skill/SKILL.md +0 -1
  3. package/claude/skills/docs-fold/SKILL.md +1 -1
  4. package/claude/skills/git-followup/SKILL.md +9 -5
  5. package/claude/skills/git-pr/SKILL.md +21 -0
  6. package/claude/skills/markdown-propose/REQUIREMENT.md +1 -1
  7. package/claude/skills/markdown-propose/SKILL.md +9 -7
  8. package/claude/skills/markdown-propose/references/format.md +5 -3
  9. package/claude/skills/plan-feature/SKILL.md +3 -3
  10. package/claude/skills/plan-groundwork/SKILL.md +6 -5
  11. package/claude/skills/plan-intake/SKILL.md +1 -1
  12. package/claude/skills/review-pr/SKILL.md +2 -2
  13. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
  14. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -1
  15. package/claude/skills/role-orchestrator/scripts/poll.sh +16 -10
  16. package/claude/skills/task-board/SKILL.md +37 -3
  17. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -6
  18. package/docs/agents/commands.md +2 -0
  19. package/docs/agents/index.md +2 -1
  20. package/docs/agents/install-and-sync.md +7 -4
  21. package/docs/agents/pr-evidence.md +108 -0
  22. package/docs/agents/records.md +29 -9
  23. package/docs/agents/tasks.md +2 -0
  24. package/docs/target-projects.md +4 -0
  25. package/governance/rules/ui/440-surface-capture.md +1 -0
  26. package/package.json +1 -1
  27. package/src/commands/design.ts +10 -2
  28. package/src/commands/pr.ts +220 -0
  29. package/src/commands/records.ts +138 -0
  30. package/src/commands/transcripts.ts +20 -3
  31. package/src/design/base.css +59 -0
  32. package/src/design/components.ts +80 -50
  33. package/src/design/fonts.ts +21 -0
  34. package/src/init/plan.ts +8 -1
  35. package/src/init/steps.ts +17 -0
  36. package/src/intake/folder.ts +1 -1
  37. package/src/pr/evidence.ts +171 -0
  38. package/src/records/backup.ts +110 -38
  39. package/src/records/ordinal.ts +243 -0
  40. package/src/records/validate.ts +28 -0
  41. package/src/tasks/answers.ts +10 -1
  42. package/src/teach/fonts.ts +9 -11
  43. package/src/transcripts/fetch.ts +31 -1
  44. package/standards/architecture.md +1 -0
  45. package/standards/figures.md +51 -0
  46. package/standards/groundwork.md +2 -1
  47. package/standards/index.md +1 -0
  48. package/standards/intake.md +2 -1
  49. package/standards/plan.md +1 -0
  50. package/standards/skill.md +1 -1
  51. package/tooling/base/configs/.husky/post-merge +27 -0
  52. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +5 -4
@@ -0,0 +1,171 @@
1
+ /** The path segment that marks an image as evidence a pull request should compare. */
2
+ const EVIDENCE_SEGMENT = 'evidence'
3
+
4
+ /** Prefix of the trailing marker this module writes and reads back. */
5
+ const MARKER_PREFIX = '<!-- pr-evidence:'
6
+
7
+ /** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
8
+ const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
9
+
10
+ /**
11
+ * The image extensions this module embeds. An `evidence/` folder holds
12
+ * whatever else a project keeps beside its captures, such as a README, a
13
+ * capture script, or a raw data file, and none of those render as
14
+ * `![](url)` without producing a broken embed.
15
+ */
16
+ const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|avif|svg)$/i
17
+
18
+ export interface EvidenceItem {
19
+ readonly path: string
20
+ readonly stem: string
21
+ /** True when `path` did not exist at the comparison's base commit. */
22
+ readonly added: boolean
23
+ }
24
+
25
+ export interface EvidenceState {
26
+ /** The remainder of the path's directory under the `evidence/` segment. Empty when the image sits directly inside it. */
27
+ readonly state: string
28
+ readonly items: readonly EvidenceItem[]
29
+ }
30
+
31
+ export type EvidenceRefusal = 'no-evidence'
32
+
33
+ export type EvidenceReading =
34
+ | { readonly kind: 'read'; readonly states: readonly EvidenceState[] }
35
+ | { readonly kind: 'refused'; readonly reason: EvidenceRefusal }
36
+
37
+ /** Whether a path existed at the comparison's base commit, read however the caller resolves it. */
38
+ export type ExistsAtBase = (path: string) => Promise<boolean>
39
+
40
+ function evidencePosition(path: string): number {
41
+ return path.split('/').indexOf(EVIDENCE_SEGMENT)
42
+ }
43
+
44
+ export function isEvidencePath(path: string): boolean {
45
+ return evidencePosition(path) !== -1 && IMAGE_EXTENSION.test(path)
46
+ }
47
+
48
+ function splitEvidencePath(path: string): {
49
+ readonly state: string
50
+ readonly stem: string
51
+ } {
52
+ const segments = path.split('/')
53
+ const rest = segments.slice(evidencePosition(path) + 1)
54
+ const filename = rest[rest.length - 1] ?? ''
55
+ return {
56
+ state: rest.slice(0, -1).join('/'),
57
+ stem: filename.replace(/\.[^./]+$/, ''),
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Groups every evidence path in `changed` by its state, keying each entry
63
+ * inside a state by filename stem so a before/after pair sharing a name
64
+ * across two states can be read as one case.
65
+ */
66
+ export async function groupEvidence(
67
+ changed: readonly string[],
68
+ existsAtBase: ExistsAtBase,
69
+ ): Promise<EvidenceReading> {
70
+ const paths = changed.filter(isEvidencePath)
71
+ if (paths.length === 0) return { kind: 'refused', reason: 'no-evidence' }
72
+
73
+ const resolved = await Promise.all(
74
+ paths.map(async (path) => {
75
+ const { state, stem } = splitEvidencePath(path)
76
+ const added = !(await existsAtBase(path))
77
+ return { path, state, stem, added }
78
+ }),
79
+ )
80
+
81
+ const byState = new Map<string, EvidenceItem[]>()
82
+ for (const { path, state, stem, added } of resolved) {
83
+ const item: EvidenceItem = { path, stem, added }
84
+ const items = byState.get(state)
85
+ if (items === undefined) byState.set(state, [item])
86
+ else items.push(item)
87
+ }
88
+
89
+ const states = [...byState.entries()]
90
+ .sort(([a], [b]) => a.localeCompare(b))
91
+ .map(([state, items]) => ({
92
+ state,
93
+ items: [...items].sort((a, b) => a.stem.localeCompare(b.stem)),
94
+ }))
95
+
96
+ return { kind: 'read', states }
97
+ }
98
+
99
+ function rawUrl(repo: string, sha: string, path: string): string {
100
+ return `https://raw.githubusercontent.com/${repo}/${sha}/${path}`
101
+ }
102
+
103
+ export function evidenceMarker(head: string): string {
104
+ return `${MARKER_PREFIX} head=${head} -->`
105
+ }
106
+
107
+ /**
108
+ * Renders the whole comment body: one collapsed `<details>` block per state,
109
+ * a Base/Head row per case, and the trailing marker naming the head this body
110
+ * describes. Every image URL is pinned to a commit sha rather than a branch,
111
+ * so the comment keeps showing what it claimed even after the branch moves.
112
+ */
113
+ export function renderEvidenceBody(
114
+ states: readonly EvidenceState[],
115
+ repo: string,
116
+ base: string,
117
+ head: string,
118
+ ): string {
119
+ const sections = states.map((entry) => {
120
+ const rows = entry.items.map((item) => {
121
+ const before = item.added
122
+ ? '*(new)*'
123
+ : `![](${rawUrl(repo, base, item.path)})`
124
+ const after = `![](${rawUrl(repo, head, item.path)})`
125
+ return `| ${item.stem} | ${before} | ${after} |`
126
+ })
127
+
128
+ return [
129
+ '<details>',
130
+ `<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
131
+ '',
132
+ '| Case | Base | Head |',
133
+ '| --- | --- | --- |',
134
+ ...rows,
135
+ '',
136
+ '</details>',
137
+ ].join('\n')
138
+ })
139
+
140
+ return ['## Evidence', '', ...sections, '', evidenceMarker(head)].join('\n')
141
+ }
142
+
143
+ export interface EvidenceComment {
144
+ readonly url?: string
145
+ readonly body: string
146
+ }
147
+
148
+ function hasEvidenceMarker(body: string): boolean {
149
+ const lines = body.split('\n')
150
+ let index = lines.length - 1
151
+ while (index >= 0 && (lines[index] ?? '').trim() === '') index -= 1
152
+ if (index < 0) return false
153
+ return (lines[index] ?? '').trim().startsWith(MARKER_PREFIX)
154
+ }
155
+
156
+ /**
157
+ * The REST id of the comment this module already posted, read off its `url`
158
+ * field, since `gh pr view --json comments` reports only a GraphQL id there
159
+ * and the REST id is what a later `PATCH` needs.
160
+ */
161
+ export function findEvidenceCommentId(
162
+ comments: readonly EvidenceComment[],
163
+ ): number | undefined {
164
+ for (const comment of comments) {
165
+ if (!hasEvidenceMarker(comment.body)) continue
166
+ const match =
167
+ comment.url === undefined ? null : ISSUE_COMMENT_ID.exec(comment.url)
168
+ if (match?.[1] !== undefined) return Number(match[1])
169
+ }
170
+ return undefined
171
+ }
@@ -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
+ }