@erclx/canon 4.9.0 → 4.9.1
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/docs/agents/commands.md
CHANGED
|
@@ -142,7 +142,9 @@ Common patterns:
|
|
|
142
142
|
|
|
143
143
|
`migrate rename` moves a project off the retired `aitk` name. It reports until `--write` is passed, and `--scope target` rewrites the toolkit-owned folders alone, reporting every other citation as one the project owns rather than editing prose somebody wrote. A project installing `canon` fresh never needs it.
|
|
144
144
|
|
|
145
|
-
`migrate records` moves a project's session records from `.claude/` to `.canon/` and rewrites every tracked file that cites one. It reports until `--write` is passed, and refuses outright when the project does not already ignore `.canon/`, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. Take the ignore entry with `canon tooling sync --write` first.
|
|
145
|
+
`migrate records` moves a project's session records from `.claude/` to `.canon/` and rewrites every tracked file that cites one. It reports until `--write` is passed, and refuses outright when the project does not already ignore `.canon/`, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. Take the ignore entry with `canon tooling sync --write` first.
|
|
146
|
+
|
|
147
|
+
A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
|
|
146
148
|
|
|
147
149
|
## Version skew
|
|
148
150
|
|
package/docs/target-projects.md
CHANGED
|
@@ -128,15 +128,17 @@ When the toolkit updates, target projects pull changes per domain. There is one
|
|
|
128
128
|
|
|
129
129
|
Session records moved out of `.claude/` and into a root of their own. What is committed stays where it is, and everything gitignored, being the task board, the plans, the memory pen, the review reports, and the scratch folder, now lives under `.canon/`, which a single ignore entry covers.
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
`4.7.0` carries `canon migrate records`, so run these three lines from inside the project. Run `canon upgrade` first regardless of what you hold, since the sweep learned to pass over the records themselves after that release and a `4.7.0` binary rewrites them.
|
|
132
132
|
|
|
133
133
|
```bash
|
|
134
134
|
canon tooling sync claude . --write
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
canon migrate records --json
|
|
136
|
+
canon migrate records --write --json
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
The first line takes the `.canon/` ignore entry, and the verb refuses until the project has it, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. The second reports the plan and the third applies it, moving the folders and repointing every tracked file that cites one.
|
|
140
|
+
|
|
141
|
+
Order matters between the first line and the two under it. The sync prunes the twelve old `.claude/` ignore entries down to the one `.canon/` line, which leaves every record still at the old root visible to git and therefore to the verb. The sweep passes over them on purpose, reporting a count of what it left alone rather than reading the memory pen and the groundwork trails as source.
|
|
140
142
|
|
|
141
143
|
Read the `ok` field out of the `--json` record rather than the exit code. A shell profile that wraps `canon` in a function takes its status from whatever the function runs last, so an absent subcommand and a clean run can both exit 0, and a reader watching the exit alone concludes the move happened.
|
|
142
144
|
|
package/package.json
CHANGED
package/src/commands/migrate.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
|
|
|
6
6
|
import { isToolkitOwned, planRename, type RenamePlan } from '@/migrate/plan'
|
|
7
7
|
import {
|
|
8
8
|
ignoresDestination,
|
|
9
|
+
isRecordArtifact,
|
|
9
10
|
planRecordsMove,
|
|
10
11
|
type RecordsPlan,
|
|
11
12
|
} from '@/migrate/records'
|
|
@@ -176,17 +177,24 @@ async function runRecords(opts: RecordsOptions): Promise<number> {
|
|
|
176
177
|
return 1
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
|
|
180
|
+
// Filtered before the read rather than inside the planner, because the
|
|
181
|
+
// records are the largest thing in the tree and `readSources` awaits one file
|
|
182
|
+
// at a time. This repository's own record tree is 9,744 files at 83M, and the
|
|
183
|
+
// backup history under it is object files read whole and discarded as binary.
|
|
184
|
+
const toSweep = files.filter((path) => !isRecordArtifact(path))
|
|
185
|
+
const records = files.length - toSweep.length
|
|
186
|
+
|
|
187
|
+
const plan = planRecordsMove(root, await readSources(root, toSweep))
|
|
180
188
|
|
|
181
189
|
// stdout, so the record pipes clean. `pipeOutput` frames to stderr, which is
|
|
182
190
|
// where this command's report belongs and where a JSON record does not.
|
|
183
191
|
if (opts.json) {
|
|
184
192
|
process.stdout.write(
|
|
185
|
-
`${JSON.stringify(toRecordsRecord(plan, opts.write))}\n`,
|
|
193
|
+
`${JSON.stringify(toRecordsRecord(plan, records, opts.write))}\n`,
|
|
186
194
|
)
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
reportRecords(plan)
|
|
197
|
+
reportRecords(plan, records)
|
|
190
198
|
|
|
191
199
|
if (plan.collisions.length > 0) {
|
|
192
200
|
logError(
|
|
@@ -229,7 +237,7 @@ function readGitignore(root: string): string | undefined {
|
|
|
229
237
|
}
|
|
230
238
|
}
|
|
231
239
|
|
|
232
|
-
function reportRecords(plan: RecordsPlan): void {
|
|
240
|
+
function reportRecords(plan: RecordsPlan, records: number): void {
|
|
233
241
|
logInfo(`${plural(plan.moves.length, 'folder')} to move.`)
|
|
234
242
|
for (const move of plan.moves) logInfo(` ${move.from} -> ${move.to}`)
|
|
235
243
|
logInfo(
|
|
@@ -237,6 +245,14 @@ function reportRecords(plan: RecordsPlan): void {
|
|
|
237
245
|
)
|
|
238
246
|
logInfo(`${plural(plan.kept, 'citation')} marked to keep the old root.`)
|
|
239
247
|
|
|
248
|
+
// A count rather than a list, and its own line rather than a place in
|
|
249
|
+
// `excluded`. That field exists so a reader can go and check a handful by
|
|
250
|
+
// hand, and a record tree would bury them. The count is what answers the
|
|
251
|
+
// question a target actually has, which is where the rest of the files went.
|
|
252
|
+
if (records > 0) {
|
|
253
|
+
logInfo(`${plural(records, 'file')} under a record root, left alone.`)
|
|
254
|
+
}
|
|
255
|
+
|
|
240
256
|
if (plan.excluded.length > 0) {
|
|
241
257
|
logInfo(`${plural(plan.excluded.length, 'file')} excluded from the sweep.`)
|
|
242
258
|
}
|
|
@@ -244,6 +260,7 @@ function reportRecords(plan: RecordsPlan): void {
|
|
|
244
260
|
|
|
245
261
|
function toRecordsRecord(
|
|
246
262
|
plan: RecordsPlan,
|
|
263
|
+
records: number,
|
|
247
264
|
wrote: boolean | undefined,
|
|
248
265
|
): unknown {
|
|
249
266
|
return {
|
|
@@ -255,6 +272,7 @@ function toRecordsRecord(
|
|
|
255
272
|
rewritten: plan.rewritten,
|
|
256
273
|
kept: plan.kept,
|
|
257
274
|
excluded: plan.excluded.length,
|
|
275
|
+
records,
|
|
258
276
|
paths: plan.entries.map((entry) => ({
|
|
259
277
|
path: entry.path,
|
|
260
278
|
rewritten: entry.rewritten,
|
|
@@ -294,6 +312,11 @@ export function register(program: Command): void {
|
|
|
294
312
|
'A line carrying canon-keep-record-root, or the line below it, keeps the',
|
|
295
313
|
'old root. Prose that dates a decision needs it; a live path does not.',
|
|
296
314
|
'',
|
|
315
|
+
'The records themselves are never swept. Everything under .canon/ and',
|
|
316
|
+
'every .claude/ record folder is left alone and reported as a count, so',
|
|
317
|
+
'a run after the ignore entries collapse touches the same files as one',
|
|
318
|
+
'before it.',
|
|
319
|
+
'',
|
|
297
320
|
'Examples:',
|
|
298
321
|
' canon migrate records',
|
|
299
322
|
' canon migrate records --write',
|
package/src/migrate/records.ts
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { join } from 'node:path'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type RecordRoot,
|
|
15
|
+
RECORD_ENTRIES,
|
|
16
|
+
RECORD_ROOTS,
|
|
17
|
+
spell,
|
|
18
|
+
} from '@/record-root'
|
|
14
19
|
|
|
15
20
|
/** The root the entries below leave, exported so the writer can prune it. */
|
|
16
21
|
export const FROM_ROOT: RecordRoot = '.claude'
|
|
@@ -104,6 +109,66 @@ export function isExcludedPath(path: string): boolean {
|
|
|
104
109
|
return EXCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix))
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The roots holding nothing but records, so a path under one is a record
|
|
114
|
+
* whatever it is named.
|
|
115
|
+
*
|
|
116
|
+
* `.canon/` qualifies by construction. `.claude/ARCHITECTURE.md` fixes the rule
|
|
117
|
+
* that every gitignored session record moves there and nothing tracked ever
|
|
118
|
+
* lands there, which covers a record folder `RECORD_ENTRIES` has yet to learn
|
|
119
|
+
* about. The old root is the one that cannot take a whole-root reading, and it
|
|
120
|
+
* is derived by exclusion rather than named, so a third root added later reads
|
|
121
|
+
* as records-only unless someone says otherwise.
|
|
122
|
+
*/
|
|
123
|
+
const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
|
|
124
|
+
(root) => root !== FROM_ROOT,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Every prefix under which a path is a record rather than a file to sweep.
|
|
129
|
+
*
|
|
130
|
+
* The asymmetry is the point. A whole-root prefix is correct for the new root
|
|
131
|
+
* and wrong for the old one, which is mixed: this repository tracks 163 files
|
|
132
|
+
* under `.claude/`, and a target's installed `.claude/rules/core/035-tasks.md`
|
|
133
|
+
* is the file the sweep exists to repoint, so a bare `.claude/` prefix strands
|
|
134
|
+
* it silently. The old root is therefore entry-scoped, through `spell` so the
|
|
135
|
+
* one naming variant stays decided in `record-root.ts`.
|
|
136
|
+
*
|
|
137
|
+
* Joined with a literal separator rather than through `join`, the way
|
|
138
|
+
* `EXCLUDED_PREFIXES` already is. These are matched against what `git ls-files`
|
|
139
|
+
* returns, which is forward-slashed on every platform, where `join` would spell
|
|
140
|
+
* a backslash on Windows and match nothing.
|
|
141
|
+
*/
|
|
142
|
+
const RECORD_PREFIXES: readonly string[] = [
|
|
143
|
+
...RECORD_ONLY_ROOTS,
|
|
144
|
+
...RECORD_ENTRIES.map((entry) => `${FROM_ROOT}/${spell(FROM_ROOT, entry)}`),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether a path is a record artifact, which the sweep passes over entirely.
|
|
149
|
+
*
|
|
150
|
+
* Separate from `isExcludedPath`, which reports what it skips because a reader
|
|
151
|
+
* has to check those by hand. A record artifact is never something to check,
|
|
152
|
+
* and a target's record tree is large enough that reporting each one would bury
|
|
153
|
+
* the handful of exclusions that matter.
|
|
154
|
+
*
|
|
155
|
+
* A record folder becomes visible to the sweep at the moment `canon tooling
|
|
156
|
+
* sync claude` prunes the twelve old ignore entries down to one `.canon/` line,
|
|
157
|
+
* which is the step the documented first-run order puts immediately before this
|
|
158
|
+
* verb. Without this predicate the run that follows reads the memory pen and
|
|
159
|
+
* the groundwork trails as source and rewrites them.
|
|
160
|
+
*
|
|
161
|
+
* The three retired flat archives stay outside this, the way `CITATION` already
|
|
162
|
+
* leaves them alone: `.claude/plans-archive/x.md` does not start with
|
|
163
|
+
* `.claude/plans/`, and widening the entry list to catch it would change what
|
|
164
|
+
* `MOVED_ENTRIES` means for the folder half of the verb.
|
|
165
|
+
*/
|
|
166
|
+
export function isRecordArtifact(path: string): boolean {
|
|
167
|
+
return RECORD_PREFIXES.some(
|
|
168
|
+
(prefix) => path === prefix || path.startsWith(`${prefix}/`),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
107
172
|
/**
|
|
108
173
|
* Marks a line naming the old root on purpose.
|
|
109
174
|
*
|
|
@@ -244,6 +309,11 @@ export function planRecordsMove(
|
|
|
244
309
|
let kept = 0
|
|
245
310
|
|
|
246
311
|
for (const source of sources) {
|
|
312
|
+
// Silently, and ahead of the exclusion test. The command boundary filters
|
|
313
|
+
// these out before it reads them, so this is what keeps the pure function
|
|
314
|
+
// correct under a direct call rather than what the verb relies on.
|
|
315
|
+
if (isRecordArtifact(source.path)) continue
|
|
316
|
+
|
|
247
317
|
if (isExcludedPath(source.path)) {
|
|
248
318
|
// Only an excluded file that actually carries a citation is reported. The
|
|
249
319
|
// predicate covers every test file in the tree, so counting them all would
|