@erclx/canon 4.65.0 → 4.67.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.
@@ -5,6 +5,7 @@ import { listRepositoryFiles } from '@/git-files'
5
5
  import { indexSourceRules } from '@/gov/adapter'
6
6
  import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
7
7
  import { isToolkitOwned, planRename, type RenamePlan } from '@/migrate/plan'
8
+ import { AITK_RULES, type RenameRules } from '@/migrate/rename'
8
9
  import {
9
10
  applyRecordTree,
10
11
  planRecordTree,
@@ -24,6 +25,7 @@ import {
24
25
  type RuleLayoutPlan,
25
26
  walkFlatRules,
26
27
  } from '@/migrate/rule-layout'
28
+ import { SKILL_NAME_MAP, SKILL_NAME_RULES } from '@/migrate/skill-names'
27
29
  import {
28
30
  applyScratchEvidence,
29
31
  planScratchEvidence,
@@ -31,14 +33,17 @@ import {
31
33
  type ScratchEvidencePlan,
32
34
  walkScratchEvidenceCorpus,
33
35
  } from '@/migrate/scratch-evidence'
34
- import { PROJECT_ROOT } from '@/project-root'
36
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
35
37
  import { readStamp, stampedHashes } from '@/sync/stamp'
36
- import { logError, logInfo, logStep, logWarn, pipeOutput, plural } from '@/ui'
38
+ import { logError, logInfo, logStep, logWarn, plural } from '@/ui'
37
39
 
38
- interface RenameOptions {
40
+ interface SweepOptions {
39
41
  readonly json?: boolean
40
42
  readonly write?: boolean
41
43
  readonly root?: string
44
+ }
45
+
46
+ interface RenameOptions extends SweepOptions {
42
47
  readonly scope?: string
43
48
  }
44
49
 
@@ -51,12 +56,11 @@ function isScope(value: string): value is Scope {
51
56
  }
52
57
 
53
58
  /**
54
- * Reports rather than writes without `--write`, matching `canon records
55
- * migrate`. A rename touching this many files has no undo short of the branch
56
- * it ran on, so the safe outcome sits on the default path.
59
+ * The `aitk` sweep, which is the one rename with a second population to name.
60
+ * A target holds toolkit-owned folders whose content came from here beside
61
+ * prose that project wrote, and the scope is what separates them.
57
62
  */
58
63
  async function runRename(opts: RenameOptions): Promise<number> {
59
- const root = opts.root ?? process.cwd()
60
64
  const scope = opts.scope ?? 'self'
61
65
 
62
66
  if (!isScope(scope)) {
@@ -64,6 +68,33 @@ async function runRename(opts: RenameOptions): Promise<number> {
64
68
  return 1
65
69
  }
66
70
 
71
+ return runSweep(opts, AITK_RULES, scope)
72
+ }
73
+
74
+ /**
75
+ * The skill rename owns no scope. Every folder it moves is authored here, and
76
+ * no target holds a copy of this repository's skill catalog, so there is no
77
+ * second population for a caller to name.
78
+ */
79
+ async function runSkillNames(opts: SweepOptions): Promise<number> {
80
+ return runSweep(opts, SKILL_NAME_RULES, undefined)
81
+ }
82
+
83
+ /**
84
+ * One sweep over a repository's tracked files under whichever rules it was
85
+ * handed.
86
+ *
87
+ * Reports rather than writes without `--write`, matching `canon records
88
+ * migrate`. A rename touching this many files has no undo short of the branch
89
+ * it ran on, so the safe outcome sits on the default path.
90
+ */
91
+ async function runSweep(
92
+ opts: SweepOptions,
93
+ rules: RenameRules,
94
+ scope: Scope | undefined,
95
+ ): Promise<number> {
96
+ const root = opts.root ?? process.cwd()
97
+
67
98
  const files = await listRepositoryFiles(root)
68
99
  if (files === undefined) {
69
100
  logError(`Could not list files under ${root}. Is it a git repository?`)
@@ -72,7 +103,7 @@ async function runRename(opts: RenameOptions): Promise<number> {
72
103
 
73
104
  const scoped = scope === 'target' ? files.filter(isToolkitOwned) : files
74
105
  const sources = await readSources(root, scoped)
75
- const plan = planRename(sources)
106
+ const plan = planRename(sources, rules)
76
107
 
77
108
  // A target scope reports the citations it did not rewrite, since prose the
78
109
  // project wrote is theirs to change and a sweep editing it underneath them
@@ -84,12 +115,15 @@ async function runRename(opts: RenameOptions): Promise<number> {
84
115
  root,
85
116
  files.filter((f) => !isToolkitOwned(f)),
86
117
  ),
118
+ rules,
87
119
  ).entries.length
88
120
  : 0
89
121
 
122
+ // stdout, so the record pipes clean. `pipeOutput` frames to stderr, which is
123
+ // where this command's report belongs and where a JSON record does not.
90
124
  if (opts.json) {
91
- pipeOutput(
92
- JSON.stringify(toRecord(plan, scope, citations, opts.write), null, 2),
125
+ process.stdout.write(
126
+ `${JSON.stringify(toRecord(plan, scope, citations, opts.write), null, 2)}\n`,
93
127
  )
94
128
  }
95
129
 
@@ -115,8 +149,12 @@ async function runRename(opts: RenameOptions): Promise<number> {
115
149
  return 0
116
150
  }
117
151
 
118
- function report(plan: RenamePlan, scope: Scope, citations: number): void {
119
- logInfo(`Scope ${scope}.`)
152
+ function report(
153
+ plan: RenamePlan,
154
+ scope: Scope | undefined,
155
+ citations: number,
156
+ ): void {
157
+ if (scope !== undefined) logInfo(`Scope ${scope}.`)
120
158
  logInfo(
121
159
  `${plural(plan.entries.length, 'file')} to change, ${plural(plan.renamed, 'occurrence')} to rewrite.`,
122
160
  )
@@ -138,12 +176,12 @@ function report(plan: RenamePlan, scope: Scope, citations: number): void {
138
176
 
139
177
  function toRecord(
140
178
  plan: RenamePlan,
141
- scope: Scope,
179
+ scope: Scope | undefined,
142
180
  citations: number,
143
181
  wrote: boolean | undefined,
144
182
  ): unknown {
145
183
  return {
146
- scope,
184
+ ...(scope === undefined ? {} : { scope }),
147
185
  wrote: wrote === true,
148
186
  files: plan.entries.length,
149
187
  renamed: plan.renamed,
@@ -584,6 +622,9 @@ interface RuleLayoutOptions {
584
622
  async function runRuleLayout(opts: RuleLayoutOptions): Promise<number> {
585
623
  const root = opts.root ?? process.cwd()
586
624
 
625
+ const mismatch = checkoutMismatchWarning(process.cwd())
626
+ if (mismatch !== undefined) logWarn(mismatch)
627
+
587
628
  const files = await walkFlatRules(root)
588
629
  const hashes = stampedHashes(readStamp(root), 'governance')
589
630
  const catalog = new Set(indexSourceRules(PROJECT_ROOT).keys())
@@ -848,6 +889,45 @@ export function register(program: Command): void {
848
889
  process.exitCode = await runRename(opts)
849
890
  })
850
891
 
892
+ migrate
893
+ .command('skill-names')
894
+ .description('Move the prefixed skill folders onto their two-word names')
895
+ .helpOption('-h, --help', 'Show this help message')
896
+ .option('--json', 'Add a machine-readable record on stdout')
897
+ .option('--write', 'Apply the plan rather than reporting it')
898
+ .option(
899
+ '--root <path>',
900
+ 'Project root, defaulting to the working directory',
901
+ )
902
+ .addHelpText(
903
+ 'after',
904
+ [
905
+ '',
906
+ `Rewrites ${Object.keys(SKILL_NAME_MAP).length} skill names and moves the folders that carry them.`,
907
+ 'It takes no scope. The skill folders it moves are authored in the',
908
+ 'toolkit and no target holds a copy of that catalog, so in a target it',
909
+ 'rewrites citations of a renamed skill and moves nothing.',
910
+ '',
911
+ 'Exit codes:',
912
+ ' 0 nothing to rewrite, or --write applied the whole plan',
913
+ ' 1 refused, or a move failed',
914
+ ' 2 a plan exists and --write was not passed',
915
+ '',
916
+ 'The changelog is never rewritten, and neither is an eval transcript.',
917
+ 'Each records what shipped or what a session ran under the name that',
918
+ 'was current then.',
919
+ '',
920
+ 'Examples:',
921
+ ' canon migrate skill-names',
922
+ ' canon migrate skill-names --write',
923
+ ' canon migrate skill-names --json',
924
+ '',
925
+ ].join('\n'),
926
+ )
927
+ .action(async (opts: SweepOptions) => {
928
+ process.exitCode = await runSkillNames(opts)
929
+ })
930
+
851
931
  migrate
852
932
  .command('rule-layout')
853
933
  .description('Move installed rules from the flat layout onto canon/')
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { execScript } from '@/exec'
5
- import { PROJECT_ROOT } from '@/project-root'
5
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
6
6
  import {
7
7
  assertedPercent,
8
8
  collectCensus,
@@ -286,6 +286,9 @@ function runCoverage(options: CoverageOptions): void {
286
286
 
287
287
  intro('canon sandbox coverage')
288
288
 
289
+ const mismatch = checkoutMismatchWarning(process.cwd())
290
+ if (mismatch !== undefined) logWarn(mismatch)
291
+
289
292
  const report = collectCoverage(PROJECT_ROOT)
290
293
 
291
294
  // A broken `exempt.toml` reads as a caller error, not as a crash. The parser
@@ -341,6 +344,9 @@ function runCheck(
341
344
  ): void {
342
345
  intro('canon sandbox check')
343
346
 
347
+ const mismatch = checkoutMismatchWarning(process.cwd())
348
+ if (mismatch !== undefined) logWarn(mismatch)
349
+
344
350
  const parsed = parseTarget(target)
345
351
  if (parsed === undefined) {
346
352
  logError('Invalid target. Use <category>:<command>, e.g. claude:docs.')
@@ -1,9 +1,9 @@
1
1
  import type { Command } from 'commander'
2
2
  import { registerPassThroughVerbs } from '@/commands/pass-through'
3
- import { PROJECT_ROOT } from '@/project-root'
3
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
4
4
  import { BASE_CATEGORY } from '@/snippets/categories'
5
5
  import { buildSnippetsCatalog } from '@/snippets/list'
6
- import { intro, logInfo, logStep, outro } from '@/ui'
6
+ import { intro, logInfo, logStep, logWarn, outro } from '@/ui'
7
7
 
8
8
  const PASS_THROUGH_VERBS = ['create'] as const
9
9
 
@@ -42,14 +42,20 @@ export function register(program: Command): void {
42
42
  * emitted section headers and the pass-through above it owned the `┌`.
43
43
  */
44
44
  function runList(opts: ListOptions): number {
45
+ // The warning is a frame-interior line, so it goes out after `intro` on the
46
+ // path that opens one and bare on the `--json` path, which returns first and
47
+ // opens none. One position cannot serve both.
48
+ const mismatch = checkoutMismatchWarning(process.cwd())
45
49
  const catalog = buildSnippetsCatalog(PROJECT_ROOT)
46
50
 
47
51
  if (opts.json) {
52
+ if (mismatch !== undefined) logWarn(mismatch)
48
53
  process.stdout.write(`${JSON.stringify(catalog)}\n`)
49
54
  return 0
50
55
  }
51
56
 
52
57
  intro('canon snippets list')
58
+ if (mismatch !== undefined) logWarn(mismatch)
53
59
 
54
60
  const showCategories = opts.entries !== true
55
61
  const showEntries = opts.categories !== true
@@ -1,7 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { execScript } from '@/exec'
4
- import { findCheckoutMismatch, PROJECT_ROOT } from '@/project-root'
4
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
5
5
  import {
6
6
  injectConfigs,
7
7
  injectGitignore,
@@ -180,14 +180,20 @@ export function register(program: Command): void {
180
180
  * pass-through loop below skips the `intro` the shared helper carries.
181
181
  */
182
182
  function runList(opts: ListOptions): number {
183
+ // The warning is a frame-interior line, so it goes out after `intro` on the
184
+ // path that opens one and bare on the `--json` path, which returns first and
185
+ // opens none. One position cannot serve both.
186
+ const mismatch = checkoutMismatchWarning(process.cwd())
183
187
  const stacks = buildStackSummaries(PROJECT_ROOT)
184
188
 
185
189
  if (opts.json) {
190
+ if (mismatch !== undefined) logWarn(mismatch)
186
191
  process.stdout.write(`${JSON.stringify({ stacks })}\n`)
187
192
  return 0
188
193
  }
189
194
 
190
195
  intro('canon tooling list')
196
+ if (mismatch !== undefined) logWarn(mismatch)
191
197
  logStep('Stacks')
192
198
  for (const summary of stacks) logInfo(describeStack(summary))
193
199
  outro()
@@ -229,6 +235,9 @@ function printReference(stack: string): number {
229
235
  * stack the way it drove `merge_gitignore` before.
230
236
  */
231
237
  function prepare(stack: string, target: string, skip?: string): Prepared {
238
+ const mismatch = checkoutMismatchWarning(process.cwd())
239
+ if (mismatch !== undefined) logWarn(mismatch)
240
+
232
241
  if (!stackExists(PROJECT_ROOT, stack)) {
233
242
  return { ok: false, error: `Stack not found: ${stack}` }
234
243
  }
@@ -268,13 +277,6 @@ async function runSync(
268
277
  ): Promise<number> {
269
278
  intro('canon tooling sync')
270
279
 
271
- const mismatch = findCheckoutMismatch(process.cwd())
272
- if (mismatch !== undefined) {
273
- logWarn(
274
- `Resolved via ${PROJECT_ROOT}, not the checkout at ${mismatch}. Run \`bun ${mismatch}/src/cli.ts tooling sync ...\` to sync against that checkout instead.`,
275
- )
276
- }
277
-
278
280
  if (opts.check === true && opts.write === true) {
279
281
  logWarn('Pass --check or --write, not both.')
280
282
  outro()
@@ -1,3 +1,4 @@
1
+ import { existsSync, mkdirSync, rmSync } from 'node:fs'
1
2
  import { join, parse } from 'node:path'
2
3
  import { execa } from 'execa'
3
4
 
@@ -20,6 +21,15 @@ export type GifResult =
20
21
  | { status: 'skipped'; reason: 'converter-missing' }
21
22
  | { status: 'failed'; reason: string }
22
23
 
24
+ export type FramesResult =
25
+ | { status: 'extracted'; framePaths: string[] }
26
+ | { status: 'skipped'; reason: 'converter-missing' }
27
+ | { status: 'failed'; reason: string }
28
+
29
+ export interface ExtractFramesOptions {
30
+ readonly fps?: number
31
+ }
32
+
23
33
  /**
24
34
  * Writes mp4 beside the webm rather than instead of it, since both stated use
25
35
  * cases are a `<video>` tag on a page the operator controls, where webm
@@ -122,3 +132,88 @@ export async function convertToGif(
122
132
  }
123
133
  return { status: 'converted', gifPath }
124
134
  }
135
+
136
+ /**
137
+ * Written beside the video by default, so frames fall under the same
138
+ * `demos/*.png` gitignore entry the still already uses with no new rule
139
+ * needed. One frame a second by default, matched to the seconds-to-tens-of-
140
+ * seconds length a tuned recording runs, without a smarter sampling strategy
141
+ * nobody has asked for.
142
+ *
143
+ * The missing-binary and failure handling matches `convertToMp4` exactly: the
144
+ * recording already succeeded by the time this runs, so an optional step
145
+ * never fails the run.
146
+ *
147
+ * A prior extraction's frames are cleared before ffmpeg runs, not left for
148
+ * `-y` to overwrite. ffmpeg's `-y` only overwrites the indices this run
149
+ * produces, so a shorter re-extraction against the same video's stable
150
+ * output path (the plan's declared `output.video`, unchanged across
151
+ * re-records) would otherwise leave the previous run's tail in place,
152
+ * sorted into the returned list as if it were still current.
153
+ */
154
+ export async function extractFrames(
155
+ videoPath: string,
156
+ outDir?: string,
157
+ opts: ExtractFramesOptions = {},
158
+ bin: string = CONVERTER_BIN,
159
+ ): Promise<FramesResult> {
160
+ const { dir, name } = parse(videoPath)
161
+ const targetDir = outDir ?? dir
162
+ const fps = opts.fps ?? 1
163
+ if (outDir) mkdirSync(outDir, { recursive: true })
164
+
165
+ if (existsSync(targetDir)) {
166
+ for (const stale of new Bun.Glob(`${name}-frame-*.png`).scanSync({
167
+ cwd: targetDir,
168
+ onlyFiles: true,
169
+ })) {
170
+ rmSync(join(targetDir, stale))
171
+ }
172
+ }
173
+
174
+ const pattern = join(targetDir, `${name}-frame-%03d.png`)
175
+ const result = await execa(
176
+ bin,
177
+ ['-y', '-i', videoPath, '-vf', `fps=${fps}`, pattern],
178
+ { reject: false },
179
+ )
180
+
181
+ if (result.failed && result.code === 'ENOENT') {
182
+ return { status: 'skipped', reason: 'converter-missing' }
183
+ }
184
+ if (result.exitCode !== 0) {
185
+ return {
186
+ status: 'failed',
187
+ reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
188
+ }
189
+ }
190
+
191
+ return { status: 'extracted', framePaths: collectFrames(targetDir, name) }
192
+ }
193
+
194
+ /**
195
+ * The glob-and-sort step `extractFrames` reads its result from, pulled out so
196
+ * a test can drive it against a directory it wrote by hand rather than
197
+ * through a real extraction, which is the only way to reach the four-digit
198
+ * boundary below without an ffmpeg run long enough to produce one.
199
+ *
200
+ * ffmpeg's `%03d` pads to three characters and then widens rather than
201
+ * truncating, so a run producing 1000 or more frames writes `-1000.png`
202
+ * beside `-999.png`, and a lexicographic sort reads the wider name first.
203
+ * `frameIndex` sorts on the parsed number instead.
204
+ */
205
+ export function collectFrames(targetDir: string, name: string): string[] {
206
+ return [
207
+ ...new Bun.Glob(`${name}-frame-*.png`).scanSync({
208
+ cwd: targetDir,
209
+ onlyFiles: true,
210
+ }),
211
+ ]
212
+ .sort((a, b) => frameIndex(a) - frameIndex(b))
213
+ .map((frame) => join(targetDir, frame))
214
+ }
215
+
216
+ function frameIndex(filename: string): number {
217
+ const match = filename.match(/-frame-(\d+)\.png$/)
218
+ return match ? Number(match[1]) : 0
219
+ }