@erclx/canon 4.107.0 → 4.109.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/document-health/REQUIREMENT.md +46 -0
- package/claude/skills/document-health/SKILL.md +88 -0
- package/claude/skills/document-health/references/axes.md +78 -0
- 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/workflow/ai-workflow.md +1 -0
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +2 -34
- package/src/claude/cases/workflow.ts +10 -0
- package/src/cli.ts +2 -161
- package/src/commands/pr.ts +29 -4
- package/src/commands/tasks.ts +22 -7
- package/src/help.ts +196 -0
- package/src/pr/checks.ts +29 -0
- package/src/tasks/label.ts +136 -29
- package/standards/readme.md +1 -0
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
|
}
|
package/standards/readme.md
CHANGED
|
@@ -63,6 +63,7 @@ The opening of a root README is a block of up to six elements in a fixed order.
|
|
|
63
63
|
The description of 2-3 sentences follows the block and expands the claim rather than repeating it.
|
|
64
64
|
|
|
65
65
|
- A project with no page omits the link and the screenshot. A library or a CLI reads as a complete block with a title, badges, and a claim.
|
|
66
|
+
- A project whose own page carries the screenshot omits it here and keeps the live link, which answers the slot rather than skipping it. Say so in the pull request that drops it.
|
|
66
67
|
- The order is the block's rather than the page's. The screenshot sits last inside the block, which puts it in the first row of content a reader meets below the title, badges, and claim.
|
|
67
68
|
- A themed light and dark pair counts as one element in the slot rather than two. The block does not require a pair and leaves the handling of themes to the project.
|
|
68
69
|
- Centering the block in an HTML wrapper is optional. Plain markdown reaches every host a README renders on, and a wrapper is styling a project can add.
|