@erclx/canon 4.10.0 → 4.11.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/canon-feedback-file/SKILL.md +41 -11
- package/docs/agents/capture.md +2 -2
- package/docs/agents/commands.md +6 -1
- package/docs/ai-workflow.md +1 -1
- package/docs/target-projects.md +11 -0
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +69 -23
- package/src/commands/feedback-format.ts +74 -1
- package/src/commands/feedback.ts +23 -9
- package/src/commands/migrate.ts +173 -0
- package/src/design/base.css +3 -1
- package/src/design/tokens.ts +17 -3
- package/src/gate/measures.ts +50 -28
- package/src/gate/stages.ts +8 -4
- package/src/github-format.ts +62 -0
- package/src/github.ts +17 -6
- package/src/migrate/record-tree.ts +343 -0
- package/src/migrate/records.ts +6 -1
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The old-root citations left inside the record tree itself.
|
|
3
|
+
*
|
|
4
|
+
* `migrate records` sweeps a git listing, so it reaches every tracked file and
|
|
5
|
+
* none of the records, which are gitignored by construction. What that leaves
|
|
6
|
+
* behind is the tree's own citations: a pointer a session still follows, and a
|
|
7
|
+
* far larger body of prose that has to keep saying what it says. This module
|
|
8
|
+
* holds the scope that separates the two.
|
|
9
|
+
*
|
|
10
|
+
* Separate from `records.ts` because that module answers about a repository
|
|
11
|
+
* listing and this one answers about a directory walk. Folding them would give
|
|
12
|
+
* one file two enumeration models, and the predicates each needs are the
|
|
13
|
+
* inverse of the other's.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { RECORD_ONLY_ROOTS, rewriteText, scanText } from '@/migrate/records'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The folders inside the record root a session still follows a path into.
|
|
22
|
+
*
|
|
23
|
+
* Every one of them holds a live pointer: a task naming its plan, a review
|
|
24
|
+
* naming the branch it graded, a memory entry naming the folder that owns it.
|
|
25
|
+
* The corpora left out are closed trails and scratch, which are the same class
|
|
26
|
+
* of document the exclusion set in `records.ts` already refuses to rewrite, so
|
|
27
|
+
* this is that reasoning applied one level down rather than a new rule.
|
|
28
|
+
*
|
|
29
|
+
* `proposals` is listed though the tree does not carry it here. The scope is a
|
|
30
|
+
* statement about which folders are live rather than about what one machine
|
|
31
|
+
* holds, and a folder absent from disk costs a `readdir` that finds nothing.
|
|
32
|
+
*/
|
|
33
|
+
export const LIVE_FOLDERS: readonly string[] = [
|
|
34
|
+
'diagrams',
|
|
35
|
+
'memory',
|
|
36
|
+
'plans',
|
|
37
|
+
'proposals',
|
|
38
|
+
'review',
|
|
39
|
+
'tasks',
|
|
40
|
+
'teach',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The backup history, skipped by name rather than by content.
|
|
45
|
+
*
|
|
46
|
+
* It is a git object store, so a walk that does not know it by name reads
|
|
47
|
+
* binary object files whole and discards them. `runRecords` already names that
|
|
48
|
+
* cost for this tree at 9,744 files and 83M.
|
|
49
|
+
*/
|
|
50
|
+
export const OBJECT_STORE = '.records.git'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Segments that end the walk wherever they appear inside a live folder.
|
|
54
|
+
*
|
|
55
|
+
* `archive` is the substantive one: an archived plan or a retired memory entry
|
|
56
|
+
* describes work that closed, and a path inside that sentence is history rather
|
|
57
|
+
* than a pointer. It is pruned at any depth because the archives do not all sit
|
|
58
|
+
* at the same one, `review/memory/archive/` being two levels down.
|
|
59
|
+
*/
|
|
60
|
+
export const PRUNED_SEGMENTS: readonly string[] = [
|
|
61
|
+
'archive',
|
|
62
|
+
'node_modules',
|
|
63
|
+
'.git',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/** A corpus the sweep passes over, with what it holds. */
|
|
67
|
+
export interface ExcludedCorpus {
|
|
68
|
+
readonly path: string
|
|
69
|
+
readonly files: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the walk found, before anything is read. */
|
|
73
|
+
export interface RecordTreeWalk {
|
|
74
|
+
readonly files: readonly string[]
|
|
75
|
+
readonly excluded: readonly ExcludedCorpus[]
|
|
76
|
+
readonly skipped: readonly string[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One file the walk reached, carried with its text. */
|
|
80
|
+
export interface RecordTreeSource {
|
|
81
|
+
readonly path: string
|
|
82
|
+
readonly text: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Where a citation sits, so a reader can judge it before `--write` runs. */
|
|
86
|
+
export interface CitationLine {
|
|
87
|
+
readonly line: number
|
|
88
|
+
readonly text: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One record file whose citations move. */
|
|
92
|
+
export interface RecordTreeEntry {
|
|
93
|
+
readonly path: string
|
|
94
|
+
readonly text: string
|
|
95
|
+
readonly rewritten: number
|
|
96
|
+
readonly kept: number
|
|
97
|
+
readonly lines: readonly CitationLine[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RecordTreePlan {
|
|
101
|
+
readonly entries: readonly RecordTreeEntry[]
|
|
102
|
+
readonly excluded: readonly ExcludedCorpus[]
|
|
103
|
+
readonly skipped: readonly string[]
|
|
104
|
+
readonly rewritten: number
|
|
105
|
+
readonly kept: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where a path sits relative to the project root, always forward-slashed.
|
|
110
|
+
*
|
|
111
|
+
* `Bun.Glob` reports a platform separator on Windows and every path here is
|
|
112
|
+
* compared against a literal `/` or printed for a reader, so the separator is
|
|
113
|
+
* spelled rather than joined.
|
|
114
|
+
*/
|
|
115
|
+
function under(...segments: readonly string[]): string {
|
|
116
|
+
return segments.join('/')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** How many files sit under a directory, without reading any of them. */
|
|
120
|
+
async function countFiles(directory: string): Promise<number> {
|
|
121
|
+
const glob = new Bun.Glob('**/*')
|
|
122
|
+
let count = 0
|
|
123
|
+
|
|
124
|
+
for await (const _path of glob.scan({
|
|
125
|
+
cwd: directory,
|
|
126
|
+
onlyFiles: true,
|
|
127
|
+
dot: true,
|
|
128
|
+
})) {
|
|
129
|
+
count += 1
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return count
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The files inside one live folder, with each pruned subtree counted rather
|
|
137
|
+
* than swept.
|
|
138
|
+
*
|
|
139
|
+
* The prune is attributed to the shallowest pruned segment on the path, so an
|
|
140
|
+
* archive reports as one corpus instead of one per folder inside it.
|
|
141
|
+
*/
|
|
142
|
+
async function scanLiveFolder(
|
|
143
|
+
root: string,
|
|
144
|
+
relative: string,
|
|
145
|
+
): Promise<{ files: string[]; excluded: Map<string, number> }> {
|
|
146
|
+
const glob = new Bun.Glob('**/*')
|
|
147
|
+
const files: string[] = []
|
|
148
|
+
const excluded = new Map<string, number>()
|
|
149
|
+
|
|
150
|
+
for await (const path of glob.scan({
|
|
151
|
+
cwd: join(root, relative),
|
|
152
|
+
onlyFiles: true,
|
|
153
|
+
dot: true,
|
|
154
|
+
})) {
|
|
155
|
+
const segments = path.split(/[/\\]/)
|
|
156
|
+
const cut = segments.findIndex((segment) =>
|
|
157
|
+
PRUNED_SEGMENTS.includes(segment),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if (cut === -1) {
|
|
161
|
+
files.push(under(relative, ...segments))
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const corpus = under(relative, ...segments.slice(0, cut + 1))
|
|
166
|
+
excluded.set(corpus, (excluded.get(corpus) ?? 0) + 1)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { files, excluded }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every file in the live record surface, and a count for each corpus left out.
|
|
174
|
+
*
|
|
175
|
+
* The scope is root-directed rather than an inversion of `isRecordArtifact`.
|
|
176
|
+
* That predicate is true for the new root whole and for each record entry under
|
|
177
|
+
* the old one, since the sweep it serves skips both, so inverting it would
|
|
178
|
+
* reach a project the move has not run in, where the old spelling is correct.
|
|
179
|
+
*/
|
|
180
|
+
export async function walkRecordTree(root: string): Promise<RecordTreeWalk> {
|
|
181
|
+
const files: string[] = []
|
|
182
|
+
const excluded: ExcludedCorpus[] = []
|
|
183
|
+
const skipped: string[] = []
|
|
184
|
+
|
|
185
|
+
for (const recordRoot of RECORD_ONLY_ROOTS) {
|
|
186
|
+
const entries = await readdir(join(root, recordRoot), {
|
|
187
|
+
withFileTypes: true,
|
|
188
|
+
}).catch(() => undefined)
|
|
189
|
+
if (entries === undefined) continue
|
|
190
|
+
|
|
191
|
+
// Each top-level entry is a separate subtree, so the scans are independent
|
|
192
|
+
// and run together. Nothing downstream depends on the order they settle in,
|
|
193
|
+
// since both lists are sorted once the whole root has been read.
|
|
194
|
+
const scans = entries.map(async (entry) => {
|
|
195
|
+
const relative = under(recordRoot, entry.name)
|
|
196
|
+
|
|
197
|
+
if (entry.name === OBJECT_STORE) {
|
|
198
|
+
skipped.push(relative)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!entry.isDirectory()) {
|
|
203
|
+
excluded.push({ path: relative, files: 1 })
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!LIVE_FOLDERS.includes(entry.name)) {
|
|
208
|
+
excluded.push({
|
|
209
|
+
path: relative,
|
|
210
|
+
files: await countFiles(join(root, relative)),
|
|
211
|
+
})
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const folder = await scanLiveFolder(root, relative)
|
|
216
|
+
files.push(...folder.files)
|
|
217
|
+
for (const [path, count] of folder.excluded) {
|
|
218
|
+
excluded.push({ path, files: count })
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
await Promise.all(scans)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
files.sort()
|
|
226
|
+
excluded.sort((left, right) => left.path.localeCompare(right.path))
|
|
227
|
+
skipped.sort()
|
|
228
|
+
|
|
229
|
+
return { files, excluded, skipped }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Reads what the walk found.
|
|
234
|
+
*
|
|
235
|
+
* A file whose bytes carry a NUL is skipped outright rather than carried as
|
|
236
|
+
* empty text, which is what `readSources` does for the tracked sweep. There is
|
|
237
|
+
* no path to move here, so a binary record has nothing left to contribute.
|
|
238
|
+
*/
|
|
239
|
+
export async function readRecordTree(
|
|
240
|
+
root: string,
|
|
241
|
+
paths: readonly string[],
|
|
242
|
+
): Promise<RecordTreeSource[]> {
|
|
243
|
+
const sources: RecordTreeSource[] = []
|
|
244
|
+
|
|
245
|
+
for (const path of paths) {
|
|
246
|
+
const bytes = await readFile(join(root, path)).catch(() => undefined)
|
|
247
|
+
if (bytes === undefined || bytes.includes(0)) continue
|
|
248
|
+
|
|
249
|
+
sources.push({ path, text: bytes.toString('utf8') })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return sources
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Which lines the rewrite would change, read off the rewrite rather than off a
|
|
257
|
+
* second expression.
|
|
258
|
+
*
|
|
259
|
+
* A marked line is unchanged by `rewriteText`, so it never lands here, which is
|
|
260
|
+
* what keeps the report and the write agreeing about what is in scope. The
|
|
261
|
+
* count of marked citations comes from `scanText` instead, since a protected
|
|
262
|
+
* line is invisible in a diff and the reader needs to know the markers fired.
|
|
263
|
+
*/
|
|
264
|
+
function citationLines(before: string, after: string): CitationLine[] {
|
|
265
|
+
const original = before.split('\n')
|
|
266
|
+
const rewritten = after.split('\n')
|
|
267
|
+
|
|
268
|
+
return original.flatMap((line, index) =>
|
|
269
|
+
rewritten[index] === line ? [] : [{ line: index + 1, text: line.trim() }],
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* What the sweep would rewrite, without writing it.
|
|
275
|
+
*
|
|
276
|
+
* Pure over the sources it is handed, the way `planRecordsMove` is, so a caller
|
|
277
|
+
* reports and applies from one value. A file whose text does not change is
|
|
278
|
+
* dropped rather than carried as a no-op.
|
|
279
|
+
*/
|
|
280
|
+
export function planRecordTree(
|
|
281
|
+
sources: readonly RecordTreeSource[],
|
|
282
|
+
excluded: readonly ExcludedCorpus[],
|
|
283
|
+
skipped: readonly string[],
|
|
284
|
+
): RecordTreePlan {
|
|
285
|
+
const entries: RecordTreeEntry[] = []
|
|
286
|
+
let kept = 0
|
|
287
|
+
|
|
288
|
+
for (const source of sources) {
|
|
289
|
+
const counts = scanText(source.text)
|
|
290
|
+
kept += counts.kept
|
|
291
|
+
if (counts.rewritten === 0) continue
|
|
292
|
+
|
|
293
|
+
const text = rewriteText(source.text)
|
|
294
|
+
entries.push({
|
|
295
|
+
path: source.path,
|
|
296
|
+
text,
|
|
297
|
+
rewritten: counts.rewritten,
|
|
298
|
+
kept: counts.kept,
|
|
299
|
+
lines: citationLines(source.text, text),
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
entries,
|
|
305
|
+
excluded,
|
|
306
|
+
skipped,
|
|
307
|
+
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
308
|
+
kept,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface RecordTreeResult {
|
|
313
|
+
readonly written: number
|
|
314
|
+
readonly failed: readonly string[]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Writes the plan.
|
|
319
|
+
*
|
|
320
|
+
* The write lives here rather than beside the two in `apply.ts` because this
|
|
321
|
+
* verb has no folder half: the citations are the whole of it, and the module
|
|
322
|
+
* that decided which files are in scope is the one that should be trusted to
|
|
323
|
+
* name them again. A rejected write is recorded rather than thrown, since this
|
|
324
|
+
* runs inside a commander action that would unwind past the report.
|
|
325
|
+
*/
|
|
326
|
+
export async function applyRecordTree(
|
|
327
|
+
root: string,
|
|
328
|
+
plan: RecordTreePlan,
|
|
329
|
+
): Promise<RecordTreeResult> {
|
|
330
|
+
let written = 0
|
|
331
|
+
const failed: string[] = []
|
|
332
|
+
|
|
333
|
+
for (const entry of plan.entries) {
|
|
334
|
+
const done = await writeFile(join(root, entry.path), entry.text)
|
|
335
|
+
.then(() => true)
|
|
336
|
+
.catch(() => false)
|
|
337
|
+
|
|
338
|
+
if (done) written += 1
|
|
339
|
+
else failed.push(entry.path)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { written, failed }
|
|
343
|
+
}
|
package/src/migrate/records.ts
CHANGED
|
@@ -119,8 +119,13 @@ export function isExcludedPath(path: string): boolean {
|
|
|
119
119
|
* about. The old root is the one that cannot take a whole-root reading, and it
|
|
120
120
|
* is derived by exclusion rather than named, so a third root added later reads
|
|
121
121
|
* as records-only unless someone says otherwise.
|
|
122
|
+
*
|
|
123
|
+
* Exported because `record-tree.ts` sweeps inside these roots and has to name
|
|
124
|
+
* them rather than invert `isRecordArtifact`. That predicate is true for the old
|
|
125
|
+
* root's record entries as well, so an inversion would sweep a project the move
|
|
126
|
+
* has not run in, where the old spelling is the correct one.
|
|
122
127
|
*/
|
|
123
|
-
const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
|
|
128
|
+
export const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
|
|
124
129
|
(root) => root !== FROM_ROOT,
|
|
125
130
|
)
|
|
126
131
|
|