@erclx/canon 4.108.0 → 4.110.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/README.md +15 -30
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/{canon-feedback-file → canon-feedback}/REQUIREMENT.md +7 -6
- package/claude/skills/{canon-feedback-file → canon-feedback}/SKILL.md +9 -19
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback-triage/SKILL.md +1 -1
- package/claude/skills/draft-wiki/REQUIREMENT.md +42 -0
- package/claude/skills/draft-wiki/SKILL.md +83 -0
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +4 -3
- package/claude/skills/task-board/REQUIREMENT.md +1 -1
- package/claude/skills/task-board/SKILL.md +2 -2
- package/docs/agents/capture.md +3 -3
- package/docs/agents/commands.md +2 -2
- package/docs/agents/pr-reads.md +2 -0
- package/docs/agents/tasks.md +10 -8
- package/docs/agents/teach.md +4 -2
- package/docs/workflow/ai-workflow.md +2 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +2 -34
- package/src/claude/cases/authoring.ts +5 -0
- package/src/claude/cases/setup.ts +1 -1
- package/src/cli.ts +2 -161
- package/src/commands/pr.ts +29 -4
- package/src/commands/tasks.ts +22 -7
- package/src/design/components.ts +382 -90
- package/src/help.ts +196 -0
- package/src/pr/checks.ts +29 -0
- package/src/tasks/label.ts +136 -29
- package/src/teach/nav.ts +406 -56
- package/standards/readme.md +1 -0
package/src/help.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { palette } from '@/ui'
|
|
2
|
+
|
|
3
|
+
const HELP_WIDTH = 80
|
|
4
|
+
const SIGNATURE_WIDTH = 19
|
|
5
|
+
const SANDBOX_SIGNATURE_WIDTH = 26
|
|
6
|
+
const ROW_INDENT = '│ '.length
|
|
7
|
+
|
|
8
|
+
type CommandRow = [signature: string, description: string]
|
|
9
|
+
type HelpStream = { isTTY?: boolean }
|
|
10
|
+
|
|
11
|
+
// The grouping mirrors the README's domain table and has no other source, so a
|
|
12
|
+
// command added to the program needs a row here or help-option.test.ts fails.
|
|
13
|
+
export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
14
|
+
{
|
|
15
|
+
title: 'Project',
|
|
16
|
+
rows: [
|
|
17
|
+
['init [path]', 'Bootstrap a project with toolkit domains'],
|
|
18
|
+
['sync [path]', 'Sync all installed domains in a project'],
|
|
19
|
+
['upgrade', 'Reinstall the CLI with the manager that installed it'],
|
|
20
|
+
['migrate [cmd]', 'Move a project off a retired name or layout'],
|
|
21
|
+
['targets [cmd]', 'Projects this toolkit installed into (list, pulls)'],
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
title: 'Domains',
|
|
26
|
+
rows: [
|
|
27
|
+
['gov [cmd]', 'Governance rules (install, sync, list)'],
|
|
28
|
+
['standards [cmd]', 'Standards (install, sync, list, <name>)'],
|
|
29
|
+
['snippets [cmd]', 'Prompt snippets (create, list)'],
|
|
30
|
+
['tooling [cmd]', 'Tooling stacks (sync, ref, create)'],
|
|
31
|
+
['claude [cmd]', 'Claude workflow (init, sync, setup)'],
|
|
32
|
+
['wiki [cmd]', 'Wiki pages (init)'],
|
|
33
|
+
['indexes [cmd]', 'Regenerate index.md files (regen)'],
|
|
34
|
+
['docs [cmd|topic]', 'Emit toolkit reference docs (list, <topic>)'],
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: 'Author and render',
|
|
39
|
+
rows: [
|
|
40
|
+
['design [cmd]', 'Design system (render, board)'],
|
|
41
|
+
['slides [cmd]', 'Slide decks (render, list)'],
|
|
42
|
+
['capture [source]', 'Render HTML capture sources to PNG'],
|
|
43
|
+
['serve [dir]', 'Serve a directory on localhost, print the link'],
|
|
44
|
+
['demo [cmd]', 'Record a running app (compile, run)'],
|
|
45
|
+
['inventory [subj]', 'Report one computed property across every route'],
|
|
46
|
+
['drive <url> <run>', 'Walk a page through interactions, measure each'],
|
|
47
|
+
['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
|
|
48
|
+
['teach [cmd]', 'Learning workspaces (list, open, resource, glossary)'],
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
title: 'Session and ship',
|
|
53
|
+
rows: [
|
|
54
|
+
['sandbox [cat:cmd]', 'Provision and run sandbox scenarios'],
|
|
55
|
+
['tasks [cmd]', 'Task board (validate, archive)'],
|
|
56
|
+
['intake [cmd]', 'Intake folders under .canon/intake/ (list, answer)'],
|
|
57
|
+
['records [cmd]', 'Session records (validate, size, push, pull)'],
|
|
58
|
+
['sessions [cmd]', 'Resolve live sessions to worktree and branch'],
|
|
59
|
+
['worktrees [cmd]', 'Reclaim worktrees whose branches merged'],
|
|
60
|
+
['autoship [cmd]', 'Decide whether a changed set needs review'],
|
|
61
|
+
['pr [cmd]', 'Read a pull request (key-changes, head, checks)'],
|
|
62
|
+
['feedback', 'Write toolkit feedback from stdin'],
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: 'Repo reports',
|
|
67
|
+
rows: [
|
|
68
|
+
['audits [cmd]', 'Run every health check as one set (run, list)'],
|
|
69
|
+
['gate [cmd]', 'Run the merge gate stage by stage (run)'],
|
|
70
|
+
['secrets [cmd]', 'Scan the shipped tree for credentials (scan)'],
|
|
71
|
+
['deps [cmd]', 'Read the dependency set for advisories (audit)'],
|
|
72
|
+
['labels [cmd]', 'Read a changed set against the label map (audit)'],
|
|
73
|
+
['comments [cmd]', 'Measure comment density and trend (scan)'],
|
|
74
|
+
['context [cmd]', 'Report context folder health (audit)'],
|
|
75
|
+
['markdown [cmd]', 'Report markdown against the standards (audit)'],
|
|
76
|
+
['repo [cmd]', 'Remote metadata (metadata propose, apply)'],
|
|
77
|
+
['census [path]', 'Report file count, extensions, and line totals'],
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const SANDBOX_ROWS: CommandRow[] = [
|
|
83
|
+
['canon sandbox', 'Interactive scenario picker'],
|
|
84
|
+
['canon sandbox git:commit', 'Run specific scenario'],
|
|
85
|
+
['canon sandbox reset', 'Reset sandbox to baseline'],
|
|
86
|
+
['canon sandbox clean', 'Wipe the sandbox'],
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
const EXAMPLES = [
|
|
90
|
+
'canon sync ../my-app',
|
|
91
|
+
'canon sandbox git:commit',
|
|
92
|
+
'canon gov install react',
|
|
93
|
+
'canon gov sync ../my-app',
|
|
94
|
+
'canon gov restated --json',
|
|
95
|
+
'canon standards markdown',
|
|
96
|
+
'canon snippets list',
|
|
97
|
+
'canon init ../my-app',
|
|
98
|
+
'canon tooling sync base',
|
|
99
|
+
'canon tooling create',
|
|
100
|
+
'canon claude init',
|
|
101
|
+
'canon indexes regen',
|
|
102
|
+
'canon indexes regen --dry-run --json',
|
|
103
|
+
'canon docs list --json',
|
|
104
|
+
'canon docs agents',
|
|
105
|
+
'canon design render',
|
|
106
|
+
'canon design board',
|
|
107
|
+
'canon slides render',
|
|
108
|
+
'canon slides list --json',
|
|
109
|
+
'canon capture assets/captures/install.html --selector .window --out assets/evidence',
|
|
110
|
+
'canon serve .canon/teach',
|
|
111
|
+
'canon inventory focus --json',
|
|
112
|
+
'canon drive http://localhost:4173 run.json --json',
|
|
113
|
+
'pbpaste | canon feedback',
|
|
114
|
+
'canon transcripts https://youtu.be/VIDEO_ID',
|
|
115
|
+
'canon tasks archive --pull-request 673 --json',
|
|
116
|
+
'canon intake list toolkit-overview --unread --json',
|
|
117
|
+
'canon teach list --json',
|
|
118
|
+
'canon comments scan src --json',
|
|
119
|
+
'canon context audit --json',
|
|
120
|
+
'canon markdown audit .claude/rules --json',
|
|
121
|
+
'canon records validate plans',
|
|
122
|
+
'canon records size --json',
|
|
123
|
+
'canon records push --json',
|
|
124
|
+
'canon sessions list --json',
|
|
125
|
+
'canon worktrees list --json',
|
|
126
|
+
'canon worktrees reclaim --dry-run',
|
|
127
|
+
'canon secrets scan --json',
|
|
128
|
+
'canon deps audit --json',
|
|
129
|
+
'canon labels audit --json',
|
|
130
|
+
'canon repo metadata propose --json',
|
|
131
|
+
'canon census --json',
|
|
132
|
+
'canon audits run --json',
|
|
133
|
+
'canon gate run --all --no-write',
|
|
134
|
+
'canon upgrade --json',
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
// The frame follows stdout's TTY state rather than `palette`, whose blank
|
|
138
|
+
// palette keeps every glyph so the stderr frames a captured run quotes stay
|
|
139
|
+
// framed. A glyph is rendering rather than color, so NO_COLOR keeps it.
|
|
140
|
+
function isFramed(stream: HelpStream): boolean {
|
|
141
|
+
return stream.isTTY === true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function gutter(stream: HelpStream): string {
|
|
145
|
+
if (!isFramed(stream)) return ''
|
|
146
|
+
const { GREY, NC } = palette(stream)
|
|
147
|
+
return `${GREY}│${NC}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A row past the width is cut rather than wrapped, since a wrapped row breaks
|
|
151
|
+
// the alignment the grouping exists to buy. The cut is computed from the framed
|
|
152
|
+
// prefix in both forms, so a pipe carries the same text as a terminal.
|
|
153
|
+
export function commandRow(
|
|
154
|
+
signature: string,
|
|
155
|
+
description: string,
|
|
156
|
+
stream: HelpStream,
|
|
157
|
+
): string {
|
|
158
|
+
const { GREY, NC } = palette(stream)
|
|
159
|
+
const room = HELP_WIDTH - ROW_INDENT - SIGNATURE_WIDTH - '# '.length
|
|
160
|
+
const text =
|
|
161
|
+
description.length > room
|
|
162
|
+
? `${description.slice(0, room - 1)}…`
|
|
163
|
+
: description
|
|
164
|
+
return `${gutter(stream)} ${signature.padEnd(SIGNATURE_WIDTH)}${GREY}# ${text}${NC}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function renderHelp(stream: HelpStream): string {
|
|
168
|
+
const { GREY, NC, WHITE } = palette(stream)
|
|
169
|
+
const bar = gutter(stream)
|
|
170
|
+
const usage = `${WHITE}Usage:${NC} canon [command]`
|
|
171
|
+
const lines = [
|
|
172
|
+
...(isFramed(stream)
|
|
173
|
+
? [`${GREY}┌${NC}`, `${GREY}├${NC} ${usage}`]
|
|
174
|
+
: [usage]),
|
|
175
|
+
bar,
|
|
176
|
+
`${bar} ${WHITE}Commands:${NC}`,
|
|
177
|
+
...COMMAND_GROUPS.flatMap((group) => [
|
|
178
|
+
bar,
|
|
179
|
+
`${bar} ${WHITE}${group.title}${NC}`,
|
|
180
|
+
...group.rows.map(([signature, description]) =>
|
|
181
|
+
commandRow(signature, description, stream),
|
|
182
|
+
),
|
|
183
|
+
]),
|
|
184
|
+
bar,
|
|
185
|
+
`${bar} ${WHITE}Sandbox:${NC}`,
|
|
186
|
+
...SANDBOX_ROWS.map(
|
|
187
|
+
([signature, description]) =>
|
|
188
|
+
`${bar} ${signature.padEnd(SANDBOX_SIGNATURE_WIDTH)}${GREY}# ${description}${NC}`,
|
|
189
|
+
),
|
|
190
|
+
bar,
|
|
191
|
+
`${bar} ${WHITE}Examples:${NC}`,
|
|
192
|
+
...EXAMPLES.map((example) => `${bar} ${example}`),
|
|
193
|
+
...(isFramed(stream) ? [`${GREY}└${NC}`] : []),
|
|
194
|
+
]
|
|
195
|
+
return lines.join('\n')
|
|
196
|
+
}
|
package/src/pr/checks.ts
CHANGED
|
@@ -29,6 +29,35 @@ export interface ChecksReading {
|
|
|
29
29
|
readonly collapsed: number
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export interface ConflictedReading extends ChecksReading {
|
|
33
|
+
/** The branch conflicts with its base, so no run will ever start for the tip. */
|
|
34
|
+
readonly conflicted: boolean
|
|
35
|
+
/** The pull request's `mergeStateStatus`, absent when the read carried none. */
|
|
36
|
+
readonly mergeState?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tells a conflicted pull request from a tip whose runs have not started.
|
|
41
|
+
*
|
|
42
|
+
* A branch that conflicts with its base gets no merge ref, so no
|
|
43
|
+
* `pull_request` workflow runs and the listing stays empty, which
|
|
44
|
+
* `collapseChecks` can only call pending. Only `DIRTY` separates the two, and
|
|
45
|
+
* only while no run belongs to the tip: a conflict arising after runs
|
|
46
|
+
* completed leaves those runs standing as a real answer. `UNKNOWN` is
|
|
47
|
+
* transient while GitHub computes it, so it reads as not conflicted and costs
|
|
48
|
+
* a caller one more poll.
|
|
49
|
+
*/
|
|
50
|
+
export function withMergeState(
|
|
51
|
+
mergeState: string | undefined,
|
|
52
|
+
reading: ChecksReading,
|
|
53
|
+
): ConflictedReading {
|
|
54
|
+
return {
|
|
55
|
+
...reading,
|
|
56
|
+
conflicted: mergeState === 'DIRTY' && reading.matched === 0,
|
|
57
|
+
...(mergeState !== undefined && { mergeState }),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
/**
|
|
33
62
|
* Puts a check-run row into the shape `rollup` reads.
|
|
34
63
|
*
|
package/src/tasks/label.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readdir } from 'node:fs/promises'
|
|
2
3
|
import { relative } from 'node:path'
|
|
4
|
+
import { recordDir } from '@/record-root'
|
|
3
5
|
import {
|
|
4
6
|
archiveDir,
|
|
5
7
|
declinedDir,
|
|
@@ -21,6 +23,12 @@ const FIRST_LABEL: Label = { major: 1, minor: 0 }
|
|
|
21
23
|
*/
|
|
22
24
|
export const LABEL_PATTERN = /^v(\d+)\.(\d+)-/
|
|
23
25
|
|
|
26
|
+
/** A reservation is named by the bare label, with no slug behind it. */
|
|
27
|
+
const RESERVATION_PATTERN = /^v(\d+)\.(\d+)$/
|
|
28
|
+
|
|
29
|
+
/** Bounds the claim loop. A fifth loss in a row is not a race. */
|
|
30
|
+
const MAX_ATTEMPTS = 5
|
|
31
|
+
|
|
24
32
|
interface Label {
|
|
25
33
|
readonly major: number
|
|
26
34
|
readonly minor: number
|
|
@@ -31,18 +39,23 @@ export interface NextLabel {
|
|
|
31
39
|
readonly label: string
|
|
32
40
|
/** The label this run was derived from, absent when neither folder holds one. */
|
|
33
41
|
readonly highest: string | undefined
|
|
42
|
+
/** Whether this label was reserved on disk rather than only read. */
|
|
43
|
+
readonly claimed: boolean
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
export interface LabelRefused {
|
|
37
47
|
readonly ok: false
|
|
38
|
-
readonly reason: 'no-board'
|
|
48
|
+
readonly reason: 'no-board' | 'label-contended'
|
|
39
49
|
readonly message: string
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
export type LabelOutcome = NextLabel | LabelRefused
|
|
43
53
|
|
|
44
|
-
function parseLabel(
|
|
45
|
-
|
|
54
|
+
function parseLabel(
|
|
55
|
+
stem: string,
|
|
56
|
+
pattern: RegExp = LABEL_PATTERN,
|
|
57
|
+
): Label | undefined {
|
|
58
|
+
const match = pattern.exec(stem)
|
|
46
59
|
if (!match) return undefined
|
|
47
60
|
|
|
48
61
|
return { major: Number(match[1]), minor: Number(match[2]) }
|
|
@@ -71,6 +84,54 @@ function next(label: Label): Label {
|
|
|
71
84
|
: { major: label.major, minor: label.minor + 1 }
|
|
72
85
|
}
|
|
73
86
|
|
|
87
|
+
function noBoard(root: string, dir: string): LabelRefused {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: 'no-board',
|
|
91
|
+
message: `No task board at ${relative(root, dir)}.`,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function reservationsDir(root: string): string {
|
|
96
|
+
return recordDir(root, 'ordinal-locks')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function listReservations(root: string): Promise<string[]> {
|
|
100
|
+
try {
|
|
101
|
+
const entries = await readdir(reservationsDir(root), {
|
|
102
|
+
withFileTypes: true,
|
|
103
|
+
})
|
|
104
|
+
return entries.filter((entry) => entry.isDirectory()).map((e) => e.name)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function highestOf(
|
|
114
|
+
names: readonly string[],
|
|
115
|
+
pattern: RegExp,
|
|
116
|
+
): Label | undefined {
|
|
117
|
+
return names
|
|
118
|
+
.map((name) => parseLabel(name, pattern))
|
|
119
|
+
.filter((label): label is Label => label !== undefined)
|
|
120
|
+
.reduce<Label | undefined>(
|
|
121
|
+
(max, label) => (max === undefined || isHigher(label, max) ? label : max),
|
|
122
|
+
undefined,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readStems(root: string): Promise<string[]> {
|
|
127
|
+
const settled = [archiveDir(root), declinedDir(root)].filter((candidate) =>
|
|
128
|
+
existsSync(candidate),
|
|
129
|
+
)
|
|
130
|
+
const dirs = [tasksDir(root), ...settled]
|
|
131
|
+
|
|
132
|
+
return (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
|
|
133
|
+
}
|
|
134
|
+
|
|
74
135
|
/**
|
|
75
136
|
* Reports the next unused phase label, read off the true maximum across
|
|
76
137
|
* `.canon/tasks/` and its `archive/` and `declined/` siblings together. A scan
|
|
@@ -78,41 +139,87 @@ function next(label: Label): Label {
|
|
|
78
139
|
* spent, which is what let two sessions hand out the same label within
|
|
79
140
|
* minutes of each other.
|
|
80
141
|
*
|
|
81
|
-
* It reports and never writes.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* already sitting in the tree, and a gap left by a renumbering, both fold into
|
|
86
|
-
* the same max scan without needing a dedicated check.
|
|
142
|
+
* It reports and never writes. `claimLabel` is the variant that reserves. A
|
|
143
|
+
* duplicate label already sitting in the tree, and a gap left by a
|
|
144
|
+
* renumbering, both fold into the same max scan without needing a dedicated
|
|
145
|
+
* check.
|
|
87
146
|
*/
|
|
88
147
|
export async function nextLabel(root: string): Promise<LabelOutcome> {
|
|
89
148
|
const dir = tasksDir(root)
|
|
149
|
+
if (!existsSync(dir)) return noBoard(root, dir)
|
|
90
150
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
151
|
+
const highest = highestOf(await readStems(root), LABEL_PATTERN)
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
label: formatLabel(highest === undefined ? FIRST_LABEL : next(highest)),
|
|
156
|
+
highest: highest === undefined ? undefined : formatLabel(highest),
|
|
157
|
+
claimed: false,
|
|
97
158
|
}
|
|
159
|
+
}
|
|
98
160
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
|
|
161
|
+
interface ClaimOptions {
|
|
162
|
+
/** Takes the reservation, resolving false when another claim holds it. */
|
|
163
|
+
readonly reserve?: (root: string, label: string) => Promise<boolean>
|
|
164
|
+
}
|
|
104
165
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
166
|
+
async function reserveLabel(root: string, label: string): Promise<boolean> {
|
|
167
|
+
await mkdir(reservationsDir(root), { recursive: true })
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await mkdir(recordDir(root, 'ordinal-locks', label), { recursive: false })
|
|
171
|
+
return true
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Reserves the next label through an exclusive `mkdir` under
|
|
182
|
+
* `.canon/ordinal-locks/`, so a second claim lands on a different one.
|
|
183
|
+
*
|
|
184
|
+
* The scan counts every reservation beside the task stems. The skill writes
|
|
185
|
+
* the task file after this returns, so a reservation with no file behind it is
|
|
186
|
+
* the ordinary state for a few seconds, and a scan blind to it would offer the
|
|
187
|
+
* same label to every retry. Reservations are never expired: a claim that died
|
|
188
|
+
* leaves a gap, and `standards/versioning.md` permits gaps.
|
|
189
|
+
*/
|
|
190
|
+
export async function claimLabel(
|
|
191
|
+
root: string,
|
|
192
|
+
options: ClaimOptions = {},
|
|
193
|
+
): Promise<LabelOutcome> {
|
|
194
|
+
const dir = tasksDir(root)
|
|
195
|
+
if (!existsSync(dir)) return noBoard(root, dir)
|
|
196
|
+
|
|
197
|
+
const reserve = options.reserve ?? reserveLabel
|
|
198
|
+
|
|
199
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
|
200
|
+
const stems = highestOf(await readStems(root), LABEL_PATTERN)
|
|
201
|
+
const held = highestOf(await listReservations(root), RESERVATION_PATTERN)
|
|
202
|
+
const highest =
|
|
203
|
+
stems !== undefined && (held === undefined || isHigher(stems, held))
|
|
204
|
+
? stems
|
|
205
|
+
: held
|
|
206
|
+
const label = formatLabel(
|
|
207
|
+
highest === undefined ? FIRST_LABEL : next(highest),
|
|
111
208
|
)
|
|
112
209
|
|
|
210
|
+
if (await reserve(root, label)) {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
label,
|
|
214
|
+
highest: highest === undefined ? undefined : formatLabel(highest),
|
|
215
|
+
claimed: true,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
113
220
|
return {
|
|
114
|
-
ok:
|
|
115
|
-
|
|
116
|
-
|
|
221
|
+
ok: false,
|
|
222
|
+
reason: 'label-contended',
|
|
223
|
+
message: `Could not reserve a label after ${MAX_ATTEMPTS} attempts. Re-run the claim.`,
|
|
117
224
|
}
|
|
118
225
|
}
|