@erclx/canon 4.66.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.
package/src/design/css.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import type { Component } from '@/design/components'
1
2
  import { COMPONENTS } from '@/design/components'
3
+ import type { FontFace } from '@/design/fonts'
2
4
  import { FONT_FACES } from '@/design/fonts'
3
5
  import type { DesignTokens } from '@/design/tokens'
4
6
  import { TOKENS } from '@/design/tokens'
@@ -113,39 +115,52 @@ ${pairs}
113
115
  }`
114
116
  }
115
117
 
116
- function componentBlock(): string {
117
- return COMPONENTS.map(
118
- (component) => `/* ${component.name}
118
+ function componentBlock(components: readonly Component[]): string {
119
+ return components
120
+ .map(
121
+ (component) => `/* ${component.name}
119
122
  ${component.note} */
120
123
 
121
124
  ${component.rules}`,
122
- ).join('\n\n')
125
+ )
126
+ .join('\n\n')
123
127
  }
124
128
 
125
129
  /**
126
- * `@font-face` rules carrying the mono stack's primary family as base64, so a
127
- * stylesheet renders in the same typeface everywhere regardless of what the
128
- * reader's machine has installed. Only the teach stylesheet opts in today.
130
+ * `@font-face` rules carrying a font list as base64, so a stylesheet renders
131
+ * in the same typeface everywhere regardless of what the reader's machine has
132
+ * installed. Defaults to the mono stack's primary family; teach passes its own
133
+ * three faces instead of widening this default for every consumer.
129
134
  */
130
- function fontFaceBlock(): string {
131
- return FONT_FACES.map(
132
- (face) => `@font-face {
135
+ function fontFaceBlock(faces: readonly FontFace[]): string {
136
+ return faces
137
+ .map(
138
+ (face) => `@font-face {
133
139
  font-family: '${face.family}';
134
140
  font-weight: ${face.weight};
135
141
  font-style: normal;
136
142
  font-display: swap;
137
143
  src: url(data:font/woff2;base64,${face.base64}) format('woff2');
138
144
  }`,
139
- ).join('\n\n')
145
+ )
146
+ .join('\n\n')
140
147
  }
141
148
 
142
149
  export interface CssOptions {
143
150
  /** Prepended as a comment, naming what wrote the file and from where. */
144
151
  readonly banner?: string
145
- /** Component rules ride along by default; a token-only consumer opts out. */
146
- readonly components?: boolean
147
- /** Off by default. Embeds the mono stack's faces as base64 `@font-face` rules. */
148
- readonly embedFonts?: boolean
152
+ /**
153
+ * Component rules ride along by default; a token-only consumer opts out
154
+ * with `false`. Pass an explicit list, such as teach's own chrome set, to
155
+ * emit those instead of the generic default.
156
+ */
157
+ readonly components?: boolean | readonly Component[]
158
+ /**
159
+ * Off by default. `true` embeds the mono stack's faces as base64
160
+ * `@font-face` rules. Pass an explicit list, such as teach's three faces,
161
+ * to embed those instead.
162
+ */
163
+ readonly embedFonts?: boolean | readonly FontFace[]
149
164
  }
150
165
 
151
166
  export function buildDesignCss(
@@ -155,10 +170,22 @@ export function buildDesignCss(
155
170
  const banner =
156
171
  options.banner === undefined ? '' : `/* ${options.banner} */\n\n`
157
172
  const root = [':root {', ...tokenProperties(tokens), '}'].join('\n')
158
- const parts = options.embedFonts ? [fontFaceBlock(), root] : [root]
173
+ const faces =
174
+ options.embedFonts === true
175
+ ? FONT_FACES
176
+ : Array.isArray(options.embedFonts)
177
+ ? options.embedFonts
178
+ : undefined
179
+ const parts = faces ? [fontFaceBlock(faces), root] : [root]
159
180
  parts.push(lightBlock(tokens))
160
181
 
161
- if (options.components !== false) parts.push(componentBlock())
182
+ const components =
183
+ options.components === false
184
+ ? undefined
185
+ : Array.isArray(options.components)
186
+ ? options.components
187
+ : COMPONENTS
188
+ if (components) parts.push(componentBlock(components))
162
189
 
163
190
  return `${banner}${parts.join('\n\n')}\n`
164
191
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  isExcludedPath,
3
3
  renamePath,
4
+ type RenameRules,
4
5
  renameText,
5
6
  scanText,
6
7
  } from '@/migrate/rename'
@@ -59,20 +60,27 @@ export function isToolkitOwned(path: string): boolean {
59
60
  * reported or applied. A file whose content and path both stay put is dropped
60
61
  * rather than carried as a no-op entry, which keeps the reported count equal
61
62
  * to the number of files the sweep actually changes.
63
+ *
64
+ * The rules arrive as an argument rather than being read from a module, since
65
+ * this planner serves every rename the engine compiles and the four calls
66
+ * below have no other way to say which one they mean.
62
67
  */
63
- export function planRename(sources: readonly RenameSource[]): RenamePlan {
68
+ export function planRename(
69
+ sources: readonly RenameSource[],
70
+ rules: RenameRules,
71
+ ): RenamePlan {
64
72
  const entries: RenameEntry[] = []
65
73
  const excluded: string[] = []
66
74
 
67
75
  for (const source of sources) {
68
- if (isExcludedPath(source.path)) {
76
+ if (isExcludedPath(source.path, rules)) {
69
77
  excluded.push(source.path)
70
78
  continue
71
79
  }
72
80
 
73
- const movesTo = renamePath(source.path)
74
- const rewritten = renameText(source.text)
75
- const counts = scanText(source.text)
81
+ const movesTo = renamePath(source.path, rules)
82
+ const rewritten = renameText(source.text, rules)
83
+ const counts = scanText(source.text, rules)
76
84
  const moved = movesTo !== source.path
77
85
  const changed = rewritten !== source.text
78
86
 
@@ -1,98 +1,153 @@
1
1
  /**
2
- * The token rewrite behind the `aitk` to `canon` rename.
2
+ * The token rewrite behind a mechanical rename.
3
3
  *
4
4
  * The sweep is mechanical and its danger is entirely in what it must not
5
5
  * touch, so the scanner is one pass with the protected forms tried first
6
6
  * rather than a chain of replacements. A chain reprocesses its own output,
7
7
  * which is how a protected form that contains the token gets rewritten by a
8
8
  * later rule that cannot see it was already decided.
9
+ *
10
+ * The token map, the protected forms, and the exclusion set are a parameter
11
+ * rather than module state, so one engine serves more than one rename. Two
12
+ * sweeps sharing a module constant would have to agree on a single map, and
13
+ * the second rename this repository needed shares nothing with the first
14
+ * except the scanning discipline above.
9
15
  */
10
16
 
11
17
  /**
12
- * Forms carrying the token that name something other than this tool, matched
13
- * ahead of the token itself so they pass through untouched.
18
+ * An article whose agreement the rename breaks.
14
19
  *
15
- * `aitk-sandbox` is a separate repository that is not being renamed. It has to
16
- * win against the bare token, and it also has to win against the owner-scoped
17
- * spelling, since `erclx/aitk-sandbox` would otherwise rewrite to
18
- * `erclx/canon-sandbox` and name a repository that does not exist.
20
+ * Stated as a pattern and a replacement rather than a function so a preset is
21
+ * data a test can read back. It runs against the already-rewritten line, which
22
+ * is what keeps it clear of the protected forms: a form the scanner passed
23
+ * through still spells the old token afterward, so it never matches here.
19
24
  */
20
- const PROTECTED = ['aitk-sandbox'] as const
25
+ export interface ArticleFixup {
26
+ readonly pattern: RegExp
27
+ readonly replacement: string
28
+ }
29
+
30
+ /** One rename's rules, as an author states them. */
31
+ export interface RenameRuleSpec {
32
+ /** Every spelling of the token, and what each becomes. */
33
+ readonly replacements: Readonly<Record<string, string>>
34
+ /** Marks a line that names a retired spelling on purpose. */
35
+ readonly keepMarker: string
36
+ /** Forms carrying a token that name something the rename leaves alone. */
37
+ readonly protectedForms?: readonly string[]
38
+ /** Files whose content is left alone entirely. */
39
+ readonly excludedPaths?: readonly string[]
40
+ /** Path prefixes whose files are left alone entirely. */
41
+ readonly excludedPrefixes?: readonly string[]
42
+ readonly articleFixups?: readonly ArticleFixup[]
43
+ /**
44
+ * Whether a token has to end where the word ends.
45
+ *
46
+ * A rename whose tokens are whole names wants this, and one whose tokens are
47
+ * word stems cannot have it. `aitk` is a stem that legitimately carries a
48
+ * suffix, as in `aitk-allow-superseded`, so requiring a boundary there would
49
+ * leave every hyphenated form behind. A skill name is not a stem, and
50
+ * `claude-worktree` inside `claude-worktrees` is a different subject: the
51
+ * wiki page about the harness feature, which the rename must not move.
52
+ */
53
+ readonly wholeToken?: boolean
54
+ }
21
55
 
22
56
  /**
23
- * Every spelling of the token, and what each becomes. Case is carried in the
24
- * map rather than derived, because the uppercase form is an environment
25
- * variable prefix and the title-case form is a heading word, and a derived
26
- * transform would have to guess which convention it was looking at.
57
+ * A spec with its scanner compiled and its alternatives ordered.
58
+ *
59
+ * `tokenOrder` is reported rather than kept private because the ordering is a
60
+ * correctness property a caller has to be able to assert. A token containing a
61
+ * shorter token has to be tried first, and reading that off the rewritten
62
+ * string only works when the two happen to share a destination, which is
63
+ * correctness by accident rather than by rule.
27
64
  */
28
- const REPLACEMENT: Readonly<Record<string, string>> = {
29
- aitk: 'canon',
30
- AITK: 'CANON',
31
- Aitk: 'Canon',
65
+ export interface RenameRules {
66
+ readonly replacements: Readonly<Record<string, string>>
67
+ readonly keepMarker: string
68
+ readonly protectedForms: readonly string[]
69
+ readonly tokenOrder: readonly string[]
70
+ readonly excludedPaths: readonly string[]
71
+ readonly excludedPrefixes: readonly string[]
72
+ readonly articleFixups: readonly ArticleFixup[]
73
+ readonly scan: RegExp
32
74
  }
33
75
 
34
76
  /**
35
- * One alternation so the engine decides each position once. Group 1 is a
36
- * protected form and group 2 is a token to rewrite, and the protected branch
37
- * sits first because a regex alternation is ordered.
77
+ * A branch that can never take, standing in for an empty protected list.
78
+ *
79
+ * The scanner reads a protected match off capture group 1, so a preset that
80
+ * protects nothing still has to emit that group or every later group shifts by
81
+ * one. An empty alternation would match the empty string at every position
82
+ * instead, which reports a protected hit on every character.
38
83
  */
39
- const SCAN = new RegExp(
40
- `(${PROTECTED.join('|')})|(${Object.keys(REPLACEMENT).join('|')})`,
41
- 'g',
42
- )
84
+ const NEVER_MATCHES = '(?!)'
43
85
 
44
86
  /**
45
- * Files whose content is left alone entirely.
87
+ * What may not follow a token when a preset asks for whole tokens.
46
88
  *
47
- * The changelog is release history. Its entries record what shipped under the
48
- * old name, so rewriting them falsifies the record, and the pull request links
49
- * it carries keep resolving because GitHub redirects a renamed repository's
50
- * old URLs.
51
- *
52
- * The sweep's own source is the other member, and it is not a preference. This
53
- * module states the token map as literal keys, so rewriting it turns every key
54
- * into its own replacement and leaves a rewriter that maps `canon` to `canon`
55
- * and matches nothing. Its tests name both spellings on purpose for the same
56
- * reason, and the command's help text documents the old name a caller is
57
- * migrating off. Whatever these four files should say after the rename is
58
- * written by hand, because the sweep cannot be the thing that decides it.
89
+ * A plain word boundary rejects a following letter and accepts a following
90
+ * hyphen, since `\b` reads a hyphen as the end of a word. That leaves
91
+ * `claude-intake` matching inside `claude-intake-answer`, with the ordering of
92
+ * the alternation the only thing standing between them. Naming the characters
93
+ * that continue an identifier holds on both, so the ordering and the boundary
94
+ * each cover what the other could miss, and a slash, a dot, or a backtick
95
+ * still ends a token.
59
96
  */
97
+ const TOKEN_TAIL = '(?![A-Za-z0-9_-])'
98
+
99
+ function escapeForPattern(value: string): string {
100
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
101
+ }
102
+
60
103
  /**
61
- * An eval result is a transcript. It records the commands a session actually
62
- * ran and the paths it actually opened, under whatever name was current when
63
- * the run happened, so rewriting one makes it testify to a session that never
64
- * took place. The changelog is excluded for the same reason and differs only
65
- * in living at a fixed path.
104
+ * Longest first, so a token is never consumed by a shorter token it contains.
105
+ *
106
+ * The sort is stable, so alternatives of equal length keep the order the
107
+ * author wrote them in and a preset whose tokens are all one length compiles
108
+ * to the alternation it already had.
66
109
  */
67
- const EXCLUDED_PREFIXES: readonly string[] = [
68
- 'src/migrate/',
69
- 'scripts/eval/result-',
70
- ]
110
+ function longestFirst(values: readonly string[]): readonly string[] {
111
+ return [...values].sort((left, right) => right.length - left.length)
112
+ }
71
113
 
72
114
  /**
73
- * The four test files below exist to prove the retired spellings still
74
- * resolve, so both names appear in each on purpose. Rewriting one is worse
75
- * than a broken test: the retired-variable case would collapse into a copy of
76
- * the current-variable case beside it and keep passing, reporting coverage for
77
- * a fallback nothing exercises any more.
115
+ * One alternation so the engine decides each position once. Group 1 is a
116
+ * protected form and group 2 is a token to rewrite, and the protected branch
117
+ * sits first because a regex alternation is ordered.
78
118
  */
79
- const EXCLUDED_PATHS: readonly string[] = [
80
- 'CHANGELOG.md',
81
- 'src/commands/migrate.ts',
82
- 'src/sync/stamp.test.ts',
83
- 'src/targets/registry.test.ts',
84
- 'src/targets/sweep.test.ts',
85
- 'src/ui.test.ts',
86
- ]
119
+ function compileScan(
120
+ protectedForms: readonly string[],
121
+ tokenOrder: readonly string[],
122
+ wholeToken: boolean,
123
+ ): RegExp {
124
+ const guarded =
125
+ protectedForms.length > 0
126
+ ? protectedForms.map(escapeForPattern).join('|')
127
+ : NEVER_MATCHES
87
128
 
88
- export interface ScanCount {
89
- readonly renamed: number
90
- readonly protectedCount: number
129
+ const tail = wholeToken ? TOKEN_TAIL : ''
130
+
131
+ return new RegExp(
132
+ `(${guarded})|(${tokenOrder.map(escapeForPattern).join('|')})${tail}`,
133
+ 'g',
134
+ )
91
135
  }
92
136
 
93
- export function isExcludedPath(path: string): boolean {
94
- if (EXCLUDED_PATHS.includes(path)) return true
95
- return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))
137
+ export function defineRenameRules(spec: RenameRuleSpec): RenameRules {
138
+ const protectedForms = longestFirst(spec.protectedForms ?? [])
139
+ const tokenOrder = longestFirst(Object.keys(spec.replacements))
140
+
141
+ return {
142
+ replacements: spec.replacements,
143
+ keepMarker: spec.keepMarker,
144
+ protectedForms,
145
+ tokenOrder,
146
+ excludedPaths: spec.excludedPaths ?? [],
147
+ excludedPrefixes: spec.excludedPrefixes ?? [],
148
+ articleFixups: spec.articleFixups ?? [],
149
+ scan: compileScan(protectedForms, tokenOrder, spec.wholeToken === true),
150
+ }
96
151
  }
97
152
 
98
153
  /**
@@ -100,54 +155,114 @@ export function isExcludedPath(path: string): boolean {
100
155
  * agrees with the replacement.
101
156
  *
102
157
  * The old name opens on a vowel sound and the new one does not, so every
103
- * `an aitk` in the corpus reads wrong the moment the token moves. This matches
104
- * against the already-rewritten text rather than the source, which is what
105
- * keeps it clear of the protected forms: `an aitk-sandbox` still says
106
- * `aitk-sandbox` afterward, so it never matches here.
158
+ * `an aitk` in the corpus reads wrong the moment the token moves.
107
159
  *
108
160
  * The tail rejects a following letter rather than asking for a word boundary,
109
161
  * which is what separates `an canonical` from `an CANON_STATE_DIR`. A boundary
110
162
  * treats the underscore as part of the word and declines the environment
111
163
  * variable, where the whole identifier is the token continuing.
112
164
  */
113
- const ARTICLE = /\b([Aa])n(\s+`?)(canon|CANON|Canon)(?![A-Za-z])/g
165
+ const AITK_ARTICLE = /\b([Aa])n(\s+`?)(canon|CANON|Canon)(?![A-Za-z])/g
114
166
 
115
167
  /**
116
- * Marks a line that names the retired spelling on purpose.
168
+ * The `aitk` to `canon` rename.
117
169
  *
118
- * A fallback path, a retired environment variable, and a dictionary entry
119
- * covering the record corpora all have to keep saying the old name, and a
120
- * second run over an already-renamed tree would otherwise strip exactly the
121
- * compatibility this rename shipped. The marker sits on the line itself or on
122
- * the one above it, which is the same placement `canon-allow-superseded`
123
- * already uses in this repository.
170
+ * `aitk-sandbox` is a separate repository that is not being renamed. It has to
171
+ * win against the bare token, and it also has to win against the owner-scoped
172
+ * spelling, since `erclx/aitk-sandbox` would otherwise rewrite to
173
+ * `erclx/canon-sandbox` and name a repository that does not exist.
174
+ *
175
+ * Case is carried in the map rather than derived, because the uppercase form
176
+ * is an environment variable prefix and the title-case form is a heading word,
177
+ * and a derived transform would have to guess which convention it was looking
178
+ * at.
179
+ *
180
+ * The changelog is release history. Its entries record what shipped under the
181
+ * old name, so rewriting them falsifies the record, and the pull request links
182
+ * it carries keep resolving because GitHub redirects a renamed repository's
183
+ * old URLs. An eval result is a transcript on the same argument, recording the
184
+ * commands a session actually ran under whatever name was current then.
185
+ *
186
+ * The sweep's own source is the other excluded member, and it is not a
187
+ * preference. This module states the `aitk` map as literal keys, so rewriting
188
+ * it turns every key into its own replacement and leaves a rewriter that maps
189
+ * `canon` to `canon` and matches nothing.
190
+ *
191
+ * The four test files below exist to prove the retired spellings still
192
+ * resolve, so both names appear in each on purpose. Rewriting one is worse
193
+ * than a broken test: the retired-variable case would collapse into a copy of
194
+ * the current-variable case beside it and keep passing, reporting coverage for
195
+ * a fallback nothing exercises any more.
124
196
  */
125
- const KEEP_MARKER = 'canon-keep-retired'
197
+ export const AITK_RULES: RenameRules = defineRenameRules({
198
+ replacements: {
199
+ aitk: 'canon',
200
+ AITK: 'CANON',
201
+ Aitk: 'Canon',
202
+ },
203
+ keepMarker: 'canon-keep-retired',
204
+ protectedForms: ['aitk-sandbox'],
205
+ excludedPrefixes: ['src/migrate/', 'scripts/eval/result-'],
206
+ excludedPaths: [
207
+ 'CHANGELOG.md',
208
+ 'src/commands/migrate.ts',
209
+ 'src/sync/stamp.test.ts',
210
+ 'src/targets/registry.test.ts',
211
+ 'src/targets/sweep.test.ts',
212
+ 'src/ui.test.ts',
213
+ ],
214
+ articleFixups: [{ pattern: AITK_ARTICLE, replacement: '$1$2$3' }],
215
+ })
216
+
217
+ export interface ScanCount {
218
+ readonly renamed: number
219
+ readonly protectedCount: number
220
+ }
221
+
222
+ export function isExcludedPath(path: string, rules: RenameRules): boolean {
223
+ if (rules.excludedPaths.includes(path)) return true
224
+ return rules.excludedPrefixes.some((prefix) => path.startsWith(prefix))
225
+ }
126
226
 
127
227
  /** Rewrites every unprotected spelling of the token. */
128
- export function renameText(text: string): string {
228
+ export function renameText(text: string, rules: RenameRules): string {
129
229
  const lines = text.split('\n')
130
230
  const rewritten = lines.map((line, index) =>
131
- isKept(lines, index) ? line : renameLine(line),
231
+ isKept(lines, index, rules) ? line : renameLine(line, rules),
132
232
  )
133
233
 
134
234
  return rewritten.join('\n')
135
235
  }
136
236
 
137
- function isKept(lines: readonly string[], index: number): boolean {
138
- if (lines[index]?.includes(KEEP_MARKER)) return true
139
- return index > 0 && (lines[index - 1]?.includes(KEEP_MARKER) ?? false)
237
+ /**
238
+ * Whether a line names a retired spelling on purpose.
239
+ *
240
+ * A fallback path, a retired environment variable, and a dictionary entry
241
+ * covering the record corpora all have to keep saying the old name, and a
242
+ * second run over an already-renamed tree would otherwise strip exactly the
243
+ * compatibility a rename shipped. The marker sits on the line itself or on the
244
+ * one above it, which is the same placement `canon-allow-superseded` already
245
+ * uses in this repository.
246
+ */
247
+ function isKept(
248
+ lines: readonly string[],
249
+ index: number,
250
+ rules: RenameRules,
251
+ ): boolean {
252
+ if (lines[index]?.includes(rules.keepMarker)) return true
253
+ return index > 0 && (lines[index - 1]?.includes(rules.keepMarker) ?? false)
140
254
  }
141
255
 
142
- function renameLine(line: string): string {
143
- const replaced = line.replace(SCAN, (match, guarded: string | undefined) =>
144
- guarded === undefined ? REPLACEMENT[match] : guarded,
256
+ function renameLine(line: string, rules: RenameRules): string {
257
+ const replaced = line.replace(
258
+ rules.scan,
259
+ (match, guarded: string | undefined) =>
260
+ guarded === undefined ? rules.replacements[match] : guarded,
145
261
  )
146
262
 
147
- return replaced.replace(
148
- ARTICLE,
149
- (_match, article: string, gap: string, token: string) =>
150
- `${article}${gap}${token}`,
263
+ return rules.articleFixups.reduce(
264
+ (text, fixup) => text.replace(fixup.pattern, fixup.replacement),
265
+ replaced,
151
266
  )
152
267
  }
153
268
 
@@ -156,15 +271,15 @@ function renameLine(line: string): string {
156
271
  * from a diff so a run can say how much it protected, which is the number a
157
272
  * reader needs to trust that the exclusions fired at all.
158
273
  */
159
- export function scanText(text: string): ScanCount {
274
+ export function scanText(text: string, rules: RenameRules): ScanCount {
160
275
  let renamed = 0
161
276
  let protectedCount = 0
162
277
  const lines = text.split('\n')
163
278
 
164
279
  for (const [index, line] of lines.entries()) {
165
- if (isKept(lines, index)) continue
280
+ if (isKept(lines, index, rules)) continue
166
281
 
167
- for (const [, guarded] of line.matchAll(SCAN)) {
282
+ for (const [, guarded] of line.matchAll(rules.scan)) {
168
283
  if (guarded === undefined) renamed += 1
169
284
  else protectedCount += 1
170
285
  }
@@ -178,6 +293,6 @@ export function scanText(text: string): ScanCount {
178
293
  * a protected form appearing in a path is protected there too and the two
179
294
  * halves of the sweep cannot disagree about what the token means.
180
295
  */
181
- export function renamePath(path: string): string {
182
- return renameText(path)
296
+ export function renamePath(path: string, rules: RenameRules): string {
297
+ return renameText(path, rules)
183
298
  }
@@ -0,0 +1,89 @@
1
+ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
2
+
3
+ /**
4
+ * The twenty-five shipped skills that carried a `claude-` prefix, and the
5
+ * two-word name each takes instead.
6
+ *
7
+ * The plugin namespace already resolves every one of them as `canon:<name>`,
8
+ * so the prefix bought grouping rather than uniqueness, and the grouping it
9
+ * bought was the wrong axis: it marked which surface a skill reads rather than
10
+ * what the skill does. The prefixes that replace it name a phase or a role, so
11
+ * a listing groups the review triple, the three planning skills, and the three
12
+ * session roles together.
13
+ *
14
+ * Every name takes two words. Ten of these would have landed as a bare single
15
+ * word under a plain strip, and a bare word such as `review` or `docs` is a
16
+ * substring of ordinary prose with no token left for a later sweep to find.
17
+ */
18
+ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
19
+ 'claude-address-review': 'review-address',
20
+ 'claude-autoship': 'auto-ship',
21
+ 'claude-design-extract': 'design-extract',
22
+ 'claude-diagram': 'draft-diagram',
23
+ 'claude-docs': 'docs-fold',
24
+ 'claude-feature': 'plan-feature',
25
+ 'claude-groundwork': 'plan-groundwork',
26
+ 'claude-intake': 'plan-intake',
27
+ 'claude-intake-answer': 'plan-intake-answer',
28
+ 'claude-markdown-propose': 'markdown-propose',
29
+ 'claude-memory-capture': 'memory-capture',
30
+ 'claude-memory-review': 'memory-review',
31
+ 'claude-orchestrate': 'role-orchestrator',
32
+ 'claude-planner': 'role-planner',
33
+ 'claude-pr-review': 'review-pr',
34
+ 'claude-review': 'review-branch',
35
+ 'claude-seed-sync': 'seed-sync',
36
+ 'claude-standards-audit': 'standards-audit',
37
+ 'claude-tasks': 'task-board',
38
+ 'claude-teach': 'teach-workspace',
39
+ 'claude-ui-test': 'ui-test',
40
+ 'claude-ux-audit': 'ux-audit',
41
+ 'claude-ux-measure': 'ux-measure',
42
+ 'claude-worker': 'role-worker',
43
+ 'claude-worktree': 'session-worktree',
44
+ }
45
+
46
+ /**
47
+ * The skill rename.
48
+ *
49
+ * It protects nothing. The `aitk` sweep had to guard a sibling repository
50
+ * whose name contained the token, and no string here contains a skill name
51
+ * while meaning something else: the one overlap in the map is
52
+ * `claude-intake` inside `claude-intake-answer`, which the longest-first
53
+ * ordering settles rather than a protected form. Reading that off the output
54
+ * would report a pass either way, since both rows land on a name opening with
55
+ * `plan-intake`, so the ordering is asserted directly.
56
+ *
57
+ * It matches whole tokens. A skill name is a complete name rather than a word
58
+ * stem, and the corpus holds one word that opens with a name and means
59
+ * something else: `wiki/claude/claude-worktrees.md` documents the harness
60
+ * feature, not the skill, and every sibling in that folder is named
61
+ * `claude-<topic>.md` for a Claude Code concept. Sixteen occurrences of it
62
+ * would have moved to a name the folder's own convention contradicts.
63
+ *
64
+ * No article fixup travels with it. `auto-ship` is the one destination opening
65
+ * on a vowel sound, and the corpus spells an article before it once, which is
66
+ * cheaper to repair by hand than to state as a rule the rest of the map never
67
+ * fires.
68
+ *
69
+ * The map's own module and its test state the rename rather than using the old
70
+ * names, so a sweep over them turns each key into its own replacement and
71
+ * leaves a rewriter that matches nothing. The changelog and an eval transcript
72
+ * are excluded on the argument the `aitk` preset already carries: each records
73
+ * what shipped or what a session ran under whatever name was current then, so
74
+ * rewriting one makes it testify to a release or a run that never happened.
75
+ *
76
+ * The record archives need no entry here. They are gitignored, so the tracked
77
+ * listing every sweep reads never reaches them.
78
+ */
79
+ export const SKILL_NAME_RULES: RenameRules = defineRenameRules({
80
+ replacements: SKILL_NAME_MAP,
81
+ keepMarker: 'canon-keep-retired',
82
+ wholeToken: true,
83
+ excludedPrefixes: ['scripts/eval/result-'],
84
+ excludedPaths: [
85
+ 'CHANGELOG.md',
86
+ 'src/migrate/skill-names.ts',
87
+ 'src/migrate/skill-names.test.ts',
88
+ ],
89
+ })