@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-frames-read/REQUIREMENT.md +34 -0
- package/claude/skills/canon-frames-read/SKILL.md +46 -0
- package/claude/skills/claude-worktree/SKILL.md +18 -4
- package/docs/agents/commands.md +4 -1
- package/docs/agents/demo.md +17 -1
- package/docs/agents/index.md +1 -1
- package/docs/target-projects.md +19 -0
- package/docs/workflow/ai-workflow.md +9 -5
- package/package.json +1 -1
- package/src/claude/cases/claude-workflow.ts +5 -0
- package/src/commands/claude.ts +15 -1
- package/src/commands/demo.ts +97 -1
- package/src/commands/design.ts +12 -3
- package/src/commands/docs.ts +4 -1
- package/src/commands/gov.ts +24 -4
- package/src/commands/migrate.ts +94 -14
- package/src/commands/sandbox.ts +7 -1
- package/src/commands/snippets.ts +8 -2
- package/src/commands/tooling.ts +10 -8
- package/src/demo/container.ts +95 -0
- package/src/design/components.ts +1157 -4
- package/src/design/css.ts +44 -17
- package/src/exec.ts +5 -1
- package/src/migrate/plan.ts +13 -5
- package/src/migrate/rename.ts +209 -94
- package/src/migrate/skill-names.ts +89 -0
- package/src/project-root.ts +17 -0
- package/src/sync/engine.ts +4 -0
- package/src/target.ts +5 -1
- package/src/teach/fonts.ts +28 -0
- package/src/teach/nav.ts +11 -1
- package/src/teach/workspace.ts +4 -1
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
|
|
118
|
-
(
|
|
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
|
-
|
|
125
|
+
)
|
|
126
|
+
.join('\n\n')
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
/**
|
|
126
|
-
* `@font-face` rules carrying
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
132
|
-
(
|
|
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
|
-
|
|
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
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/exec.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
3
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
4
|
+
import { logWarn } from '@/ui'
|
|
4
5
|
|
|
5
6
|
export async function execScript(
|
|
6
7
|
script: string,
|
|
7
8
|
args: string[],
|
|
8
9
|
): Promise<void> {
|
|
10
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
11
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
12
|
+
|
|
9
13
|
const scriptPath = join(PROJECT_ROOT, 'scripts', script)
|
|
10
14
|
const result = await execa(scriptPath, args, {
|
|
11
15
|
stdio: 'inherit',
|
package/src/migrate/plan.ts
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/migrate/rename.ts
CHANGED
|
@@ -1,98 +1,153 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The token rewrite behind
|
|
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
|
-
*
|
|
13
|
-
* ahead of the token itself so they pass through untouched.
|
|
18
|
+
* An article whose agreement the rename breaks.
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
40
|
-
`(${PROTECTED.join('|')})|(${Object.keys(REPLACEMENT).join('|')})`,
|
|
41
|
-
'g',
|
|
42
|
-
)
|
|
84
|
+
const NEVER_MATCHES = '(?!)'
|
|
43
85
|
|
|
44
86
|
/**
|
|
45
|
-
*
|
|
87
|
+
* What may not follow a token when a preset asks for whole tokens.
|
|
46
88
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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
|
|
165
|
+
const AITK_ARTICLE = /\b([Aa])n(\s+`?)(canon|CANON|Canon)(?![A-Za-z])/g
|
|
114
166
|
|
|
115
167
|
/**
|
|
116
|
-
*
|
|
168
|
+
* The `aitk` to `canon` rename.
|
|
117
169
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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(
|
|
144
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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(
|
|
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
|
+
})
|
package/src/project-root.ts
CHANGED
|
@@ -51,3 +51,20 @@ export function findCheckoutMismatch(startDir: string): string | undefined {
|
|
|
51
51
|
dir = parent
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The warning line every verb that answers from `PROJECT_ROOT` emits when the
|
|
57
|
+
* caller's cwd sits inside a second checkout, or `undefined` when it does not.
|
|
58
|
+
*
|
|
59
|
+
* It returns the string rather than logging it so this module keeps no `@/ui`
|
|
60
|
+
* dependency and the wording is unit-testable on its own. The wording names
|
|
61
|
+
* both roots and no subcommand: the four chokepoints that carry it stand under
|
|
62
|
+
* eight-plus verbs each, so a per-verb suffix would cost a parameter at every
|
|
63
|
+
* call site to buy back one verb's exact string.
|
|
64
|
+
*/
|
|
65
|
+
export function checkoutMismatchWarning(cwd: string): string | undefined {
|
|
66
|
+
const mismatch = findCheckoutMismatch(cwd)
|
|
67
|
+
if (mismatch === undefined) return undefined
|
|
68
|
+
|
|
69
|
+
return `Resolved via ${PROJECT_ROOT}, not the checkout at ${mismatch}. Run \`bun ${mismatch}/src/cli.ts ...\` to answer from that checkout instead.`
|
|
70
|
+
}
|