@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/plan-feature/SKILL.md +2 -2
- package/claude/skills/plan-groundwork/SKILL.md +6 -5
- package/claude/skills/plan-intake/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -1
- package/claude/skills/session-relay/REQUIREMENT.md +2 -0
- package/claude/skills/session-relay/SKILL.md +1 -0
- package/claude/skills/task-board/SKILL.md +37 -3
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +10 -6
- package/docs/agents/records.md +29 -9
- package/docs/agents/tasks.md +2 -0
- package/docs/target-projects.md +5 -1
- package/governance/rules/core/091-channel.md +13 -0
- package/package.json +1 -1
- package/src/claude/skills-reach.ts +13 -3
- package/src/commands/context.ts +7 -4
- package/src/commands/design.ts +6 -1
- package/src/commands/records.ts +138 -0
- package/src/commands/transcripts.ts +20 -3
- package/src/context/architecture.ts +15 -8
- package/src/context/citations.ts +31 -6
- package/src/context/folders.ts +27 -8
- package/src/gate/measures.ts +17 -8
- package/src/init/plan.ts +8 -1
- package/src/init/steps.ts +17 -0
- package/src/intake/folder.ts +1 -1
- package/src/migrate/records.ts +11 -3
- package/src/records/backup.ts +110 -38
- package/src/records/ordinal.ts +243 -0
- package/src/records/validate.ts +28 -0
- package/src/surface-root.ts +101 -0
- package/src/sync/stamp.ts +20 -5
- package/src/tasks/answers.ts +10 -1
- package/src/transcripts/fetch.ts +31 -1
- package/standards/groundwork.md +1 -1
- package/standards/intake.md +1 -1
- package/standards/plan.md +1 -0
- package/tooling/base/configs/.husky/post-merge +27 -0
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +5 -4
package/src/records/backup.ts
CHANGED
|
@@ -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
|
|
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.
|
|
22
|
-
* disk loss would take, which is this list.
|
|
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.
|
|
26
|
-
* ignore entries rather than folders: the
|
|
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
|
-
/**
|
|
92
|
-
|
|
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
|
-
*
|
|
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
|
|
280
|
+
async function enclosingRemotes(
|
|
231
281
|
root: string,
|
|
232
|
-
): Promise<
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
.
|
|
245
|
-
|
|
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(
|
|
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)}.
|
|
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
|
|
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/${
|
|
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
|
|
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/${
|
|
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
|
+
}
|
package/src/records/validate.ts
CHANGED
|
@@ -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
|
+
}
|