@erclx/canon 4.40.2 → 4.41.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/claude-teach/SKILL.md +9 -2
- package/claude/skills/claude-teach/references/lesson-craft.md +1 -3
- package/docs/agents/commands.md +7 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +9 -0
- package/docs/agents/teach.md +25 -4
- package/docs/target-projects.md +13 -0
- package/package.json +1 -1
- package/src/commands/migrate.ts +150 -0
- package/src/commands/teach.ts +91 -0
- package/src/migrate/rule-layout.ts +305 -0
- package/src/sync/layout.ts +9 -6
- package/src/teach/nav.ts +778 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The move of a target's installed rules from the flat `.claude/rules/<subdir>/`
|
|
3
|
+
* layout onto `.claude/rules/canon/<subdir>/`.
|
|
4
|
+
*
|
|
5
|
+
* `#1446` gave this repository's own installed tree a `canon/` wrapper and no
|
|
6
|
+
* way for an already-synced target to follow. `canon gov sync` narrowed its
|
|
7
|
+
* walk to that wrapper the same release, so a target still on the flat layout
|
|
8
|
+
* reads back as unstamped rather than as needing this move, and `canon gov
|
|
9
|
+
* install` writes a second copy beside the stale one rather than detecting it.
|
|
10
|
+
* This module is the mover neither of those verbs is.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { mkdir, readdir, rename, unlink } from 'node:fs/promises'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import {
|
|
17
|
+
type DomainHashes,
|
|
18
|
+
hashFile,
|
|
19
|
+
readStamp,
|
|
20
|
+
stampedHashes,
|
|
21
|
+
toStampKey,
|
|
22
|
+
writeStamp,
|
|
23
|
+
} from '@/sync/stamp'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The one rule `#1446` renumbered, keyed and valued as `<subdir>/<name>` so a
|
|
27
|
+
* future renumber that also changes subdirectory is representable without a
|
|
28
|
+
* second field. A name match cannot find this rule under its old name, since
|
|
29
|
+
* that name no longer exists in `governance/rules/`, so the table carries the
|
|
30
|
+
* one case a lookup cannot.
|
|
31
|
+
*/
|
|
32
|
+
export const RENUMBERED_RULES: Readonly<Record<string, string>> = {
|
|
33
|
+
'snippets/505-at-references': 'snippets/600-at-references',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Band folders the walk skips by name rather than by content, so the
|
|
38
|
+
* exclusion holds even for a band the toolkit has not shipped yet.
|
|
39
|
+
* `canon/` and `internal/` are already-migrated destinations, and
|
|
40
|
+
* `project/` is a target's own rules, which this verb never touches.
|
|
41
|
+
*/
|
|
42
|
+
const EXCLUDED_BANDS: readonly string[] = ['project', 'canon', 'internal']
|
|
43
|
+
|
|
44
|
+
/** One flat rule file, before classification. */
|
|
45
|
+
export interface FlatRuleFile {
|
|
46
|
+
readonly rel: string
|
|
47
|
+
readonly subdir: string
|
|
48
|
+
readonly name: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function relPath(...segments: readonly string[]): string {
|
|
52
|
+
return segments.join('/')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function flatRulesRoot(target: string): string {
|
|
56
|
+
return join(target, '.claude', 'rules')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every rule file sitting directly under a flat band folder. One level for
|
|
61
|
+
* the band and one glob below it for `*.md`, since `governance/rules/` never
|
|
62
|
+
* nests a rule deeper than its band, and the flat layout this verb migrates
|
|
63
|
+
* off of mirrors that depth.
|
|
64
|
+
*/
|
|
65
|
+
export async function walkFlatRules(target: string): Promise<FlatRuleFile[]> {
|
|
66
|
+
const root = flatRulesRoot(target)
|
|
67
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(
|
|
68
|
+
() => undefined,
|
|
69
|
+
)
|
|
70
|
+
if (entries === undefined) return []
|
|
71
|
+
|
|
72
|
+
const bands = entries.filter(
|
|
73
|
+
(entry) => entry.isDirectory() && !EXCLUDED_BANDS.includes(entry.name),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const files: FlatRuleFile[] = []
|
|
77
|
+
for (const band of bands) {
|
|
78
|
+
const glob = new Bun.Glob('*.md')
|
|
79
|
+
for (const name of glob.scanSync({
|
|
80
|
+
cwd: join(root, band.name),
|
|
81
|
+
onlyFiles: true,
|
|
82
|
+
})) {
|
|
83
|
+
files.push({
|
|
84
|
+
rel: relPath('.claude', 'rules', band.name, name),
|
|
85
|
+
subdir: band.name,
|
|
86
|
+
name: name.slice(0, -'.md'.length),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
files.sort((left, right) => left.rel.localeCompare(right.rel))
|
|
92
|
+
return files
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type RuleStatus = 'clean' | 'edited'
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where a stamp entry exists, an exact hash match is `clean` and any other
|
|
99
|
+
* value is `edited`. Where none exists, a name recognized by the current
|
|
100
|
+
* catalog or the renumber table is read as `clean` too, since nothing on
|
|
101
|
+
* this path suggests an edit, only that a hash cannot confirm one. A name
|
|
102
|
+
* neither source recognizes is `unclaimed`, on the reasoning
|
|
103
|
+
* `misplacedOrphan` in `src/sync/engine.ts` already states for why a mover
|
|
104
|
+
* must not guess who authored a file.
|
|
105
|
+
*/
|
|
106
|
+
export function classifyRule(
|
|
107
|
+
root: string,
|
|
108
|
+
file: FlatRuleFile,
|
|
109
|
+
hashes: DomainHashes,
|
|
110
|
+
catalog: ReadonlySet<string>,
|
|
111
|
+
): RuleStatus | 'unclaimed' {
|
|
112
|
+
const stamped = hashes[toStampKey(file.rel)]
|
|
113
|
+
if (stamped !== undefined) {
|
|
114
|
+
return stamped === hashFile(join(root, file.rel)) ? 'clean' : 'edited'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const renumberKey = relPath(file.subdir, file.name)
|
|
118
|
+
return catalog.has(file.name) || renumberKey in RENUMBERED_RULES
|
|
119
|
+
? 'clean'
|
|
120
|
+
: 'unclaimed'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Where a classified file lands. The renumbered rule reads its destination
|
|
125
|
+
* from the table instead of its own current name, and every other file keeps
|
|
126
|
+
* its current subdirectory with `canon/` inserted ahead of it, rather than
|
|
127
|
+
* recomputing one from the toolkit's current tree, since a subdirectory
|
|
128
|
+
* reconciliation independent of this move is an ordinary sync concern.
|
|
129
|
+
*/
|
|
130
|
+
export function destinationRel(file: FlatRuleFile): string {
|
|
131
|
+
const key = relPath(file.subdir, file.name)
|
|
132
|
+
const [subdir, name] = (RENUMBERED_RULES[key] ?? key).split('/')
|
|
133
|
+
return relPath('.claude', 'rules', 'canon', subdir, `${name}.md`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sameBytes(left: string, right: string): boolean {
|
|
137
|
+
return readFileSync(left).equals(readFileSync(right))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface MoveEntry {
|
|
141
|
+
readonly from: string
|
|
142
|
+
readonly to: string
|
|
143
|
+
readonly status: RuleStatus
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A flat file whose destination already holds identical bytes. */
|
|
147
|
+
export interface DuplicateEntry {
|
|
148
|
+
readonly path: string
|
|
149
|
+
readonly destination: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A flat file whose destination already holds different bytes. Neither side moves. */
|
|
153
|
+
export interface CollisionEntry {
|
|
154
|
+
readonly path: string
|
|
155
|
+
readonly destination: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface RuleLayoutPlan {
|
|
159
|
+
readonly moves: readonly MoveEntry[]
|
|
160
|
+
readonly duplicates: readonly DuplicateEntry[]
|
|
161
|
+
readonly collisions: readonly CollisionEntry[]
|
|
162
|
+
readonly unclaimed: readonly string[]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What the move would do, without doing it.
|
|
167
|
+
*
|
|
168
|
+
* A collision is reported by name with neither file touched, and every other
|
|
169
|
+
* planned move proceeds independently rather than the whole run refusing, per
|
|
170
|
+
* the concurrency standard's rule for partial failure in a batched operation.
|
|
171
|
+
*/
|
|
172
|
+
export function planRuleLayout(
|
|
173
|
+
root: string,
|
|
174
|
+
files: readonly FlatRuleFile[],
|
|
175
|
+
hashes: DomainHashes,
|
|
176
|
+
catalog: ReadonlySet<string>,
|
|
177
|
+
): RuleLayoutPlan {
|
|
178
|
+
const moves: MoveEntry[] = []
|
|
179
|
+
const duplicates: DuplicateEntry[] = []
|
|
180
|
+
const collisions: CollisionEntry[] = []
|
|
181
|
+
const unclaimed: string[] = []
|
|
182
|
+
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
const status = classifyRule(root, file, hashes, catalog)
|
|
185
|
+
if (status === 'unclaimed') {
|
|
186
|
+
unclaimed.push(file.rel)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const destination = destinationRel(file)
|
|
191
|
+
|
|
192
|
+
if (existsSync(join(root, destination))) {
|
|
193
|
+
const entry = { path: file.rel, destination }
|
|
194
|
+
if (sameBytes(join(root, file.rel), join(root, destination))) {
|
|
195
|
+
duplicates.push(entry)
|
|
196
|
+
} else {
|
|
197
|
+
collisions.push(entry)
|
|
198
|
+
}
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
moves.push({ from: file.rel, to: destination, status })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { moves, duplicates, collisions, unclaimed }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The stamp's hashes, re-keyed for every entry this run actually moved and
|
|
210
|
+
* otherwise carried forward only where the file they name still exists. A
|
|
211
|
+
* key surviving neither test is dropped, so the stamp does not accumulate
|
|
212
|
+
* entries for rules that no longer exist.
|
|
213
|
+
*
|
|
214
|
+
* A file that moved on the catalog or renumber-table fallback carries no old
|
|
215
|
+
* key to re-key, since `classifyRule` never required a stamp entry to call it
|
|
216
|
+
* `clean`, so it lands under `canon/` still absent from the map. Hashing it
|
|
217
|
+
* here to fill the gap would record an assumption the stamp never verified as
|
|
218
|
+
* though a sync had confirmed it. That is no regression: the same file was
|
|
219
|
+
* outside the walked root and invisible to `canon gov sync` before this move,
|
|
220
|
+
* so it goes from unattributed to unattributed rather than from attributed to
|
|
221
|
+
* not.
|
|
222
|
+
*/
|
|
223
|
+
function nextHashes(
|
|
224
|
+
root: string,
|
|
225
|
+
hashes: DomainHashes,
|
|
226
|
+
moved: readonly MoveEntry[],
|
|
227
|
+
): DomainHashes {
|
|
228
|
+
const renamed = new Map(
|
|
229
|
+
moved.map((entry) => [toStampKey(entry.from), toStampKey(entry.to)]),
|
|
230
|
+
)
|
|
231
|
+
const next: Record<string, string> = {}
|
|
232
|
+
|
|
233
|
+
for (const [key, hash] of Object.entries(hashes)) {
|
|
234
|
+
const movedTo = renamed.get(key)
|
|
235
|
+
if (movedTo !== undefined) {
|
|
236
|
+
next[movedTo] = hash
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (existsSync(join(root, key))) next[key] = hash
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return next
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface RuleLayoutResult {
|
|
247
|
+
readonly moved: number
|
|
248
|
+
readonly deleted: number
|
|
249
|
+
readonly failed: readonly string[]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Writes the plan: every move, then every byte-identical duplicate deleted,
|
|
254
|
+
* then the stamp rewritten from whichever moves actually landed.
|
|
255
|
+
*
|
|
256
|
+
* The stamp keeps the hash the file was recorded with rather than re-hashing
|
|
257
|
+
* current content, which is what keeps an `edited` file reporting `edited` on
|
|
258
|
+
* the next `canon gov sync` instead of silently reading `stale` and losing
|
|
259
|
+
* its own signal that it carries a local change.
|
|
260
|
+
*/
|
|
261
|
+
export async function applyRuleLayout(
|
|
262
|
+
root: string,
|
|
263
|
+
plan: RuleLayoutPlan,
|
|
264
|
+
toolkitRoot: string,
|
|
265
|
+
): Promise<RuleLayoutResult> {
|
|
266
|
+
let moved = 0
|
|
267
|
+
let deleted = 0
|
|
268
|
+
const failed: string[] = []
|
|
269
|
+
const succeeded: MoveEntry[] = []
|
|
270
|
+
|
|
271
|
+
for (const entry of plan.moves) {
|
|
272
|
+
await mkdir(dirname(join(root, entry.to)), { recursive: true })
|
|
273
|
+
const done = await rename(join(root, entry.from), join(root, entry.to))
|
|
274
|
+
.then(() => true)
|
|
275
|
+
.catch(() => false)
|
|
276
|
+
|
|
277
|
+
if (done) {
|
|
278
|
+
moved += 1
|
|
279
|
+
succeeded.push(entry)
|
|
280
|
+
} else {
|
|
281
|
+
failed.push(entry.from)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const duplicate of plan.duplicates) {
|
|
286
|
+
const done = await unlink(join(root, duplicate.path))
|
|
287
|
+
.then(() => true)
|
|
288
|
+
.catch(() => false)
|
|
289
|
+
|
|
290
|
+
if (done) deleted += 1
|
|
291
|
+
else failed.push(duplicate.path)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (succeeded.length > 0 || deleted > 0) {
|
|
295
|
+
const hashes = stampedHashes(readStamp(root), 'governance')
|
|
296
|
+
await writeStamp(
|
|
297
|
+
root,
|
|
298
|
+
{ domain: 'governance', toolkitRoot },
|
|
299
|
+
nextHashes(root, hashes, succeeded),
|
|
300
|
+
new Date(),
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { moved, deleted, failed }
|
|
305
|
+
}
|
package/src/sync/layout.ts
CHANGED
|
@@ -8,12 +8,15 @@ const CLAUDE_DIR = '.claude'
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Domains an older toolkit installed at the project root, each with the source
|
|
11
|
-
* folder naming what it owns. Governance is absent because its
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* folder naming what it owns. Governance is absent because its move stayed
|
|
12
|
+
* inside `.claude/rules/`, from a flat `<subdir>/` layout to a `canon/`-wrapped
|
|
13
|
+
* one, and this shape represents a domain stranded at the project root rather
|
|
14
|
+
* than one stranded a folder short of where it belongs. `canon migrate
|
|
15
|
+
* rule-layout` is the separate command that move needed instead. Standards and
|
|
16
|
+
* snippets are absent because no copy installs into a target at all now, so a
|
|
17
|
+
* root `standards/` or `snippets/` folder there is the project's own authoring
|
|
18
|
+
* surface and reporting it as unmigrated would propose moving files nothing
|
|
19
|
+
* installed.
|
|
17
20
|
*
|
|
18
21
|
* A tuple array rather than a partial record, so the domain key stays typed
|
|
19
22
|
* without asserting an `Object.entries` result back into the union. Empty
|