@erclx/canon 4.78.0 → 4.80.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/README.md +9 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-operator/SKILL.md +1 -1
- package/claude/skills/canon-rollout/SKILL.md +1 -1
- package/claude/skills/create-standard/SKILL.md +1 -1
- package/claude/skills/design-extract/REQUIREMENT.md +4 -4
- package/claude/skills/design-extract/SKILL.md +11 -11
- package/claude/skills/docs-fold/SKILL.md +14 -14
- package/claude/skills/docs-fold/references/anchor-sweep.md +1 -1
- package/claude/skills/docs-fold/references/wireframe-sweep.md +4 -4
- package/claude/skills/draft-context/REQUIREMENT.md +6 -6
- package/claude/skills/draft-context/SKILL.md +8 -6
- package/claude/skills/draft-diagram/SKILL.md +5 -5
- package/claude/skills/draft-identity/SKILL.md +3 -3
- package/claude/skills/draft-readme/REQUIREMENT.md +1 -1
- package/claude/skills/draft-screencast/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +7 -7
- package/claude/skills/draft-wireframes/SKILL.md +7 -5
- package/claude/skills/git-pr/SKILL.md +3 -3
- package/claude/skills/git-pr/references/labels.md +1 -1
- package/claude/skills/memory-capture/REQUIREMENT.md +1 -1
- package/claude/skills/memory-capture/SKILL.md +6 -6
- package/claude/skills/memory-review/SKILL.md +1 -1
- package/claude/skills/migration-claude-md/REQUIREMENT.md +3 -3
- package/claude/skills/migration-claude-md/SKILL.md +7 -5
- package/claude/skills/migration-context/REQUIREMENT.md +3 -3
- package/claude/skills/migration-context/SKILL.md +11 -9
- package/claude/skills/plan-feature/SKILL.md +4 -4
- package/claude/skills/plan-groundwork/SKILL.md +2 -2
- package/claude/skills/plan-intake/SKILL.md +2 -2
- package/claude/skills/project-commands/SKILL.md +2 -2
- package/claude/skills/review-branch/SKILL.md +3 -3
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/session-relay/REQUIREMENT.md +1 -1
- package/claude/skills/session-resume/SKILL.md +1 -1
- package/claude/skills/setup-gov/SKILL.md +1 -1
- package/claude/skills/setup-init/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/promotion.md +5 -5
- package/claude/skills/ux-audit/SKILL.md +5 -5
- package/claude/skills/ux-measure/SKILL.md +1 -1
- package/docs/agents/audits.md +2 -2
- package/docs/agents/commands.md +7 -2
- package/docs/agents/context-audit-checks.md +3 -3
- package/docs/agents/context-audit.md +2 -2
- package/docs/agents/counts.md +3 -3
- package/docs/agents/design-board.md +3 -3
- package/docs/agents/docs.md +3 -3
- package/docs/agents/indexes.md +1 -1
- package/docs/agents/install-and-sync.md +4 -4
- package/docs/agents/label-coverage.md +1 -1
- package/docs/agents/review-classification.md +1 -1
- package/docs/agents/routing.md +2 -2
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/scripting.md +1 -1
- package/docs/agents/skills-audit.md +1 -1
- package/docs/agents/skills-reach.md +4 -4
- package/docs/target-projects.md +11 -11
- package/docs/workflow/ai-workflow.md +15 -13
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/visual-design-workflow.md +8 -8
- package/governance/rules/claude/510-context.md +3 -2
- package/governance/rules/claude/520-wireframes.md +3 -2
- package/governance/rules/claude/530-requirements.md +2 -1
- package/governance/rules/claude/540-architecture.md +2 -1
- package/governance/rules/claude/550-design.md +2 -1
- package/governance/rules/core/025-indexes.md +1 -1
- package/governance/rules/core/045-memory.md +1 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +1 -1
- package/scripts/core/check-seed-independence.sh +5 -2
- package/scripts/core/install-check.sh +1 -1
- package/scripts/core/list-seed-roots.sh +3 -3
- package/scripts/core/regen-hero.sh +1 -1
- package/scripts/docs/list.sh +3 -3
- package/scripts/lib/sandbox-git.sh +1 -1
- package/scripts/lib/tooling.sh +6 -4
- package/snippets/claude/figma-steps.md +1 -1
- package/src/audits/baseline.ts +17 -5
- package/src/audits/catalog.ts +2 -2
- package/src/autoship/paths.ts +1 -1
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/setup.ts +1 -1
- package/src/claude/routing.ts +1 -1
- package/src/claude/seeds-list.ts +1 -1
- package/src/claude/seeds.ts +62 -28
- package/src/claude/skills-reach.ts +3 -3
- package/src/commands/claude.ts +1 -1
- package/src/commands/context.ts +8 -8
- package/src/commands/design.ts +1 -1
- package/src/commands/gov.ts +1 -1
- package/src/commands/migrate.ts +182 -1
- package/src/commands/sync.ts +1 -1
- package/src/context/audit.ts +1 -1
- package/src/context/citations.ts +7 -5
- package/src/context/folders.ts +2 -2
- package/src/counts/scan.ts +4 -4
- package/src/design/board.ts +5 -5
- package/src/design/components.ts +2 -2
- package/src/design/css.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +2 -2
- package/src/docs/read.ts +9 -3
- package/src/gate/measures.ts +14 -11
- package/src/gate/stages.ts +4 -4
- package/src/labels/map.ts +19 -5
- package/src/legacy-path.ts +15 -0
- package/src/markdown/scan.ts +1 -1
- package/src/migrate/plan.ts +1 -0
- package/src/migrate/surface-roots.ts +457 -0
- package/src/record-root.ts +6 -4
- package/src/sandbox/census.ts +1 -1
- package/src/shipped/references.ts +3 -3
- package/src/surface-root.ts +33 -8
- package/src/sync/layout.ts +10 -6
- package/src/sync/reverse.ts +1 -1
- package/src/sync/stamp.ts +22 -17
- package/src/targets/sweep.ts +6 -2
- package/src/tooling/inject.ts +5 -3
- package/src/tooling/scan.ts +4 -1
- package/standards/architecture.md +4 -4
- package/standards/context.md +7 -7
- package/standards/design.md +3 -3
- package/standards/diagrams.md +6 -6
- package/standards/index.md +5 -5
- package/standards/markdown.md +2 -2
- package/standards/publish.md +3 -3
- package/standards/readme.md +1 -1
- package/standards/requirements.md +4 -4
- package/standards/skill.md +2 -2
- package/standards/tasks.md +1 -1
- package/standards/wiki.md +1 -1
- package/standards/wireframes.md +8 -8
- package/tooling/astro/reference.md +3 -3
- package/tooling/base/reference.md +3 -3
- package/tooling/claude/reference.md +7 -3
- package/tooling/claude/seeds/CLAUDE.md +9 -9
- package/tooling/nextjs/reference.md +2 -2
- package/tooling/python/reference.md +1 -1
- package/tooling/vite-react/reference.md +2 -2
- package/tooling/web/reference.md +2 -2
- /package/tooling/base/seeds/{.claude → canon}/context/ci.md +0 -0
- /package/tooling/base/seeds/{.claude → canon}/context/development.md +0 -0
- /package/tooling/base/seeds/{.claude → canon}/context/index.md +0 -0
- /package/tooling/claude/seeds/{.claude → canon}/ARCHITECTURE.md +0 -0
- /package/tooling/claude/seeds/{.claude → canon}/DESIGN.md +0 -0
- /package/tooling/claude/seeds/{.claude → canon}/REQUIREMENTS.md +0 -0
- /package/tooling/claude/seeds/{.claude → canon}/wireframes/index.md +0 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The move of the tracked toolkit surfaces from `.claude/` to `canon/`.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the records move, every entry here is tracked, so a folder relocating
|
|
5
|
+
* is itself a citation-shaped event: the file's own path is a string every
|
|
6
|
+
* other file may quote. One planner therefore judges both halves per source
|
|
7
|
+
* file rather than moving folders and rewriting citations as two passes, so a
|
|
8
|
+
* file that both moves and cites another moved entry is decided once.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { SURFACE_ENTRIES, spell, type SurfaceRoot } from '@/surface-root'
|
|
13
|
+
|
|
14
|
+
/** The root the entries below leave. */
|
|
15
|
+
export const FROM_ROOT: SurfaceRoot = '.claude'
|
|
16
|
+
|
|
17
|
+
/** The root they arrive at. */
|
|
18
|
+
const TO_ROOT: SurfaceRoot = 'canon'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every entry this move relocates, at the name `.claude/` gives it.
|
|
22
|
+
*
|
|
23
|
+
* Read from `SURFACE_ENTRIES` rather than restated. `canon` names the install
|
|
24
|
+
* stamp folder and is the one entry whose name differs by root, which
|
|
25
|
+
* `sourcePrefix` and `destinationPrefix` already resolve through `spell`
|
|
26
|
+
* rather than restating the variant here.
|
|
27
|
+
*/
|
|
28
|
+
export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES
|
|
29
|
+
|
|
30
|
+
function sourcePrefix(entry: string): string {
|
|
31
|
+
return join(FROM_ROOT, spell(FROM_ROOT, entry))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function destinationPrefix(entry: string): string {
|
|
35
|
+
return join(TO_ROOT, spell(TO_ROOT, entry))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where a moved entry's own path lands, or `undefined` when `path` sits
|
|
40
|
+
* outside every moved entry.
|
|
41
|
+
*
|
|
42
|
+
* Tested against the file's own path rather than its content, so a context
|
|
43
|
+
* entry moves regardless of what it cites.
|
|
44
|
+
*/
|
|
45
|
+
export function movedPath(path: string): string | undefined {
|
|
46
|
+
for (const entry of MOVED_ENTRIES) {
|
|
47
|
+
const prefix = sourcePrefix(entry)
|
|
48
|
+
if (path === prefix) return destinationPrefix(entry)
|
|
49
|
+
if (path.startsWith(`${prefix}/`)) {
|
|
50
|
+
return destinationPrefix(entry) + path.slice(prefix.length)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A citation into a moved entry.
|
|
59
|
+
*
|
|
60
|
+
* The tail rejects a following name character rather than asking for a word
|
|
61
|
+
* boundary, which is what keeps `.claude/context` bare and `.claude/context/`
|
|
62
|
+
* both matching while `.claude/contexts/` does not. The alternation is ordered
|
|
63
|
+
* longest first so `ARCHITECTURE.md` is decided before any shorter entry could
|
|
64
|
+
* claim its prefix, and each entry is escaped because two of them carry a dot.
|
|
65
|
+
*/
|
|
66
|
+
const CITATION = new RegExp(
|
|
67
|
+
`${escape(FROM_ROOT)}/(${[...MOVED_ENTRIES]
|
|
68
|
+
.sort((left, right) => right.length - left.length)
|
|
69
|
+
.map(escape)
|
|
70
|
+
.join('|')})(?![A-Za-z0-9._-])`,
|
|
71
|
+
'g',
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
function escape(value: string): string {
|
|
75
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Files whose content is left alone entirely.
|
|
80
|
+
*
|
|
81
|
+
* The changelog is release history, and an eval result is a transcript of
|
|
82
|
+
* paths a session actually opened; rewriting either makes it testify to
|
|
83
|
+
* something that never happened. `src/surface-root.ts` and
|
|
84
|
+
* `src/record-root.ts` are the two sources that spell the old root as data on
|
|
85
|
+
* purpose, so sweeping either would turn the very literals this verb reads
|
|
86
|
+
* into their own replacement. A hook is the third such source, guarding on a
|
|
87
|
+
* `case` carrying an arm per root, so `.claude/hooks/` and the seed's copy of
|
|
88
|
+
* it are excluded for the same reason `records.ts` excludes them.
|
|
89
|
+
*
|
|
90
|
+
* No suffix-wide test exclusion. Most of the `src/` test files naming these
|
|
91
|
+
* paths use them as fixture data proving the fallback still resolves, not to
|
|
92
|
+
* pin one particular assertion, so a wholesale ban would leave the majority
|
|
93
|
+
* silently unswept. The two files that do pin a fallback carry `KEEP_MARKER`
|
|
94
|
+
* on the line that needs it instead.
|
|
95
|
+
*/
|
|
96
|
+
const EXCLUDED_PREFIXES: readonly string[] = [
|
|
97
|
+
'src/migrate/',
|
|
98
|
+
'scripts/eval/result-',
|
|
99
|
+
'.claude/hooks/',
|
|
100
|
+
'tooling/claude/seeds/.claude/hooks/',
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
const EXCLUDED_PATHS: readonly string[] = [
|
|
104
|
+
'CHANGELOG.md',
|
|
105
|
+
'src/surface-root.ts',
|
|
106
|
+
'src/record-root.ts',
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
export function isExcludedPath(path: string): boolean {
|
|
110
|
+
if (EXCLUDED_PATHS.includes(path)) return true
|
|
111
|
+
return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether `text` names an `EXCLUDED_PREFIXES` or `EXCLUDED_PATHS` entry as
|
|
116
|
+
* literal substring text, which is how a rewritten file can couple to one
|
|
117
|
+
* this module leaves alone.
|
|
118
|
+
*/
|
|
119
|
+
export function referencesExcluded(text: string): boolean {
|
|
120
|
+
return (
|
|
121
|
+
EXCLUDED_PATHS.some((path) => text.includes(path)) ||
|
|
122
|
+
EXCLUDED_PREFIXES.some((prefix) => text.includes(prefix))
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Marks a line naming the old root on purpose.
|
|
128
|
+
*
|
|
129
|
+
* The marker sits on the line itself or on the nearest non-blank line above
|
|
130
|
+
* it, walking past a run of blank markdown lines to reach a marker written on
|
|
131
|
+
* its own line above the paragraph it protects.
|
|
132
|
+
*/
|
|
133
|
+
const KEEP_MARKER = 'canon-keep-surface-root'
|
|
134
|
+
|
|
135
|
+
function isKept(lines: readonly string[], index: number): boolean {
|
|
136
|
+
if (lines[index]?.includes(KEEP_MARKER)) return true
|
|
137
|
+
|
|
138
|
+
let above = index - 1
|
|
139
|
+
while (above >= 0 && lines[above]?.trim() === '') above -= 1
|
|
140
|
+
|
|
141
|
+
return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The line index where a leading YAML frontmatter block closes, or
|
|
146
|
+
* `undefined` when the file does not open with a bare `---` on line one.
|
|
147
|
+
*/
|
|
148
|
+
function frontmatterEnd(lines: readonly string[]): number | undefined {
|
|
149
|
+
if (lines[0] !== '---') return undefined
|
|
150
|
+
const end = lines.indexOf('---', 1)
|
|
151
|
+
return end === -1 ? undefined : end
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Marks a line inside a rule's frontmatter `paths:` key: the key line itself,
|
|
156
|
+
* or an indented `- 'glob'` list item under it.
|
|
157
|
+
*/
|
|
158
|
+
function isFrontmatterPathsLine(
|
|
159
|
+
lines: readonly string[],
|
|
160
|
+
index: number,
|
|
161
|
+
frontmatterEndIndex: number | undefined,
|
|
162
|
+
): boolean {
|
|
163
|
+
if (frontmatterEndIndex === undefined) return false
|
|
164
|
+
if (index <= 0 || index >= frontmatterEndIndex) return false
|
|
165
|
+
|
|
166
|
+
let top = index
|
|
167
|
+
while (top > 0 && /^\s/.test(lines[top] ?? '')) top -= 1
|
|
168
|
+
|
|
169
|
+
return lines[top]?.trim().startsWith('paths:') ?? false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A path segment immediately before a citation match, shaped like another
|
|
174
|
+
* repository's own name: it carries a dot or it is itself preceded by a
|
|
175
|
+
* further `/`.
|
|
176
|
+
*/
|
|
177
|
+
const CROSS_REPO_PREFIX = /(?:[\w.-]+\.[\w.-]+|[\w.-]+\/[\w.-]+)\/$/
|
|
178
|
+
|
|
179
|
+
function isCrossRepoPrefix(lines: readonly string[], index: number): boolean {
|
|
180
|
+
const line = lines[index] ?? ''
|
|
181
|
+
const match = [...line.matchAll(CITATION)][0]
|
|
182
|
+
if (match === undefined) return false
|
|
183
|
+
|
|
184
|
+
return CROSS_REPO_PREFIX.test(line.slice(0, match.index ?? 0))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** An ISO date, marking a paragraph as recording what was true on that day. */
|
|
188
|
+
const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether the line's citation sits inside a blank-line-delimited paragraph
|
|
192
|
+
* that also carries an ISO date, read as dated prose rather than a live path.
|
|
193
|
+
*
|
|
194
|
+
* `.claude/ARCHITECTURE.md` is itself a moved entry and carries dozens of
|
|
195
|
+
* `Measured at <sha> on <date>` paragraphs naming the old root as what was
|
|
196
|
+
* true that day. Those stay as written rather than being rewritten into a
|
|
197
|
+
* root that did not exist yet on the date being recorded.
|
|
198
|
+
*/
|
|
199
|
+
function isDatedParagraph(lines: readonly string[], index: number): boolean {
|
|
200
|
+
let start = index
|
|
201
|
+
while (start > 0 && lines[start - 1]?.trim() !== '') start -= 1
|
|
202
|
+
|
|
203
|
+
let end = index
|
|
204
|
+
while (end < lines.length - 1 && lines[end + 1]?.trim() !== '') end += 1
|
|
205
|
+
|
|
206
|
+
for (let cursor = start; cursor <= end; cursor += 1) {
|
|
207
|
+
if (ISO_DATE.test(lines[cursor] ?? '')) return true
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
type LineClass = 'live' | 'kept' | 'glob' | 'crossRepo' | 'dated'
|
|
214
|
+
|
|
215
|
+
function classifyLine(
|
|
216
|
+
lines: readonly string[],
|
|
217
|
+
index: number,
|
|
218
|
+
frontmatterEndIndex: number | undefined,
|
|
219
|
+
): LineClass {
|
|
220
|
+
if (isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) return 'glob'
|
|
221
|
+
if (isKept(lines, index)) return 'kept'
|
|
222
|
+
if (isCrossRepoPrefix(lines, index)) return 'crossRepo'
|
|
223
|
+
if (isDatedParagraph(lines, index)) return 'dated'
|
|
224
|
+
return 'live'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Rewrites every unmarked, non-glob citation into a moved entry. */
|
|
228
|
+
export function rewriteText(text: string): string {
|
|
229
|
+
const lines = text.split('\n')
|
|
230
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
231
|
+
|
|
232
|
+
return lines
|
|
233
|
+
.map((line, index) =>
|
|
234
|
+
classifyLine(lines, index, frontmatterEndIndex) === 'live'
|
|
235
|
+
? line.replace(CITATION, (_match, entry: string) =>
|
|
236
|
+
destinationPrefix(entry),
|
|
237
|
+
)
|
|
238
|
+
: line,
|
|
239
|
+
)
|
|
240
|
+
.join('\n')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* How many citations `rewriteText` would rewrite, how many marked lines it
|
|
245
|
+
* left alone, how many sat inside a frontmatter `paths:` glob, how many sat
|
|
246
|
+
* behind a cross-repository-shaped prefix, and how many sat inside a dated
|
|
247
|
+
* paragraph.
|
|
248
|
+
*/
|
|
249
|
+
export function scanText(text: string): {
|
|
250
|
+
readonly rewritten: number
|
|
251
|
+
readonly kept: number
|
|
252
|
+
readonly globs: number
|
|
253
|
+
readonly crossRepo: number
|
|
254
|
+
readonly dated: number
|
|
255
|
+
} {
|
|
256
|
+
const lines = text.split('\n')
|
|
257
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
258
|
+
let rewritten = 0
|
|
259
|
+
let kept = 0
|
|
260
|
+
let globs = 0
|
|
261
|
+
let crossRepo = 0
|
|
262
|
+
let dated = 0
|
|
263
|
+
|
|
264
|
+
for (const [index, line] of lines.entries()) {
|
|
265
|
+
const matches = [...line.matchAll(CITATION)].length
|
|
266
|
+
if (matches === 0) continue
|
|
267
|
+
|
|
268
|
+
const kind = classifyLine(lines, index, frontmatterEndIndex)
|
|
269
|
+
if (kind === 'glob') globs += matches
|
|
270
|
+
else if (kind === 'kept') kept += matches
|
|
271
|
+
else if (kind === 'crossRepo') crossRepo += matches
|
|
272
|
+
else if (kind === 'dated') dated += matches
|
|
273
|
+
else rewritten += matches
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { rewritten, kept, globs, crossRepo, dated }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Where a citation sits, so a reader can judge it before `--write` runs. */
|
|
280
|
+
export interface CitationLine {
|
|
281
|
+
readonly line: number
|
|
282
|
+
readonly text: string
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function frontmatterGlobLines(text: string): CitationLine[] {
|
|
286
|
+
const lines = text.split('\n')
|
|
287
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
288
|
+
const held: CitationLine[] = []
|
|
289
|
+
|
|
290
|
+
for (const [index, line] of lines.entries()) {
|
|
291
|
+
if (!isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) continue
|
|
292
|
+
if ([...line.matchAll(CITATION)].length === 0) continue
|
|
293
|
+
|
|
294
|
+
held.push({ line: index + 1, text: line.trim() })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return held
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function crossRepoLines(text: string): CitationLine[] {
|
|
301
|
+
const lines = text.split('\n')
|
|
302
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
303
|
+
const held: CitationLine[] = []
|
|
304
|
+
|
|
305
|
+
for (const [index, line] of lines.entries()) {
|
|
306
|
+
if ([...line.matchAll(CITATION)].length === 0) continue
|
|
307
|
+
if (classifyLine(lines, index, frontmatterEndIndex) !== 'crossRepo')
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
held.push({ line: index + 1, text: line.trim() })
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return held
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function datedLines(text: string): CitationLine[] {
|
|
317
|
+
const lines = text.split('\n')
|
|
318
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
319
|
+
const held: CitationLine[] = []
|
|
320
|
+
|
|
321
|
+
for (const [index, line] of lines.entries()) {
|
|
322
|
+
if ([...line.matchAll(CITATION)].length === 0) continue
|
|
323
|
+
if (classifyLine(lines, index, frontmatterEndIndex) !== 'dated') continue
|
|
324
|
+
|
|
325
|
+
held.push({ line: index + 1, text: line.trim() })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return held
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** One tracked file, as the planner reads it. */
|
|
332
|
+
export interface SurfaceRootsSource {
|
|
333
|
+
readonly path: string
|
|
334
|
+
readonly text: string
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** One file the sweep touches, whether by moving, rewriting, or both. */
|
|
338
|
+
export interface SurfaceRootsEntry {
|
|
339
|
+
readonly path: string
|
|
340
|
+
readonly movesTo?: string
|
|
341
|
+
readonly text?: string
|
|
342
|
+
readonly rewritten: number
|
|
343
|
+
readonly kept: number
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface FrontmatterGlobEntry {
|
|
347
|
+
readonly path: string
|
|
348
|
+
readonly lines: readonly CitationLine[]
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface CrossRepoCitationEntry {
|
|
352
|
+
readonly path: string
|
|
353
|
+
readonly lines: readonly CitationLine[]
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface DatedCitationEntry {
|
|
357
|
+
readonly path: string
|
|
358
|
+
readonly lines: readonly CitationLine[]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface SurfaceRootsPlan {
|
|
362
|
+
readonly entries: readonly SurfaceRootsEntry[]
|
|
363
|
+
readonly excluded: readonly string[]
|
|
364
|
+
readonly coupled: readonly string[]
|
|
365
|
+
readonly frontmatterGlobs: readonly FrontmatterGlobEntry[]
|
|
366
|
+
readonly crossRepoCitations: readonly CrossRepoCitationEntry[]
|
|
367
|
+
readonly datedCitations: readonly DatedCitationEntry[]
|
|
368
|
+
readonly rewritten: number
|
|
369
|
+
readonly kept: number
|
|
370
|
+
readonly globs: number
|
|
371
|
+
readonly crossRepo: number
|
|
372
|
+
readonly dated: number
|
|
373
|
+
readonly moves: number
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* What the move would do, without doing it.
|
|
378
|
+
*
|
|
379
|
+
* A file is judged once for both halves: whether its own path sits under a
|
|
380
|
+
* moved entry, and whether its content cites one. Either alone is enough to
|
|
381
|
+
* produce an entry, and a file excluded from the citation rewrite still moves
|
|
382
|
+
* when its path itself is inside a moved entry, since the two entries excluded
|
|
383
|
+
* today, `src/surface-root.ts` and `src/record-root.ts`, both sit outside
|
|
384
|
+
* every moved entry and never take that branch in practice.
|
|
385
|
+
*/
|
|
386
|
+
export function planSurfaceRootsMove(
|
|
387
|
+
sources: readonly SurfaceRootsSource[],
|
|
388
|
+
): SurfaceRootsPlan {
|
|
389
|
+
const entries: SurfaceRootsEntry[] = []
|
|
390
|
+
const excluded: string[] = []
|
|
391
|
+
const coupled: string[] = []
|
|
392
|
+
const frontmatterGlobs: FrontmatterGlobEntry[] = []
|
|
393
|
+
const crossRepoCitations: CrossRepoCitationEntry[] = []
|
|
394
|
+
const datedCitations: DatedCitationEntry[] = []
|
|
395
|
+
let kept = 0
|
|
396
|
+
let globs = 0
|
|
397
|
+
let crossRepo = 0
|
|
398
|
+
let dated = 0
|
|
399
|
+
|
|
400
|
+
for (const source of sources) {
|
|
401
|
+
const movesTo = movedPath(source.path)
|
|
402
|
+
|
|
403
|
+
if (isExcludedPath(source.path)) {
|
|
404
|
+
if (scanText(source.text).rewritten > 0) excluded.push(source.path)
|
|
405
|
+
if (movesTo !== undefined) {
|
|
406
|
+
entries.push({ path: source.path, movesTo, rewritten: 0, kept: 0 })
|
|
407
|
+
}
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const counts = scanText(source.text)
|
|
412
|
+
kept += counts.kept
|
|
413
|
+
globs += counts.globs
|
|
414
|
+
crossRepo += counts.crossRepo
|
|
415
|
+
dated += counts.dated
|
|
416
|
+
|
|
417
|
+
const held = frontmatterGlobLines(source.text)
|
|
418
|
+
if (held.length > 0)
|
|
419
|
+
frontmatterGlobs.push({ path: source.path, lines: held })
|
|
420
|
+
|
|
421
|
+
const foreign = crossRepoLines(source.text)
|
|
422
|
+
if (foreign.length > 0)
|
|
423
|
+
crossRepoCitations.push({ path: source.path, lines: foreign })
|
|
424
|
+
|
|
425
|
+
const dates = datedLines(source.text)
|
|
426
|
+
if (dates.length > 0)
|
|
427
|
+
datedCitations.push({ path: source.path, lines: dates })
|
|
428
|
+
|
|
429
|
+
const changed = counts.rewritten > 0
|
|
430
|
+
if (!changed && movesTo === undefined) continue
|
|
431
|
+
|
|
432
|
+
if (changed && referencesExcluded(source.text)) coupled.push(source.path)
|
|
433
|
+
|
|
434
|
+
entries.push({
|
|
435
|
+
path: source.path,
|
|
436
|
+
...(movesTo !== undefined ? { movesTo } : {}),
|
|
437
|
+
...(changed ? { text: rewriteText(source.text) } : {}),
|
|
438
|
+
rewritten: counts.rewritten,
|
|
439
|
+
kept: counts.kept,
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
entries,
|
|
445
|
+
excluded,
|
|
446
|
+
coupled,
|
|
447
|
+
frontmatterGlobs,
|
|
448
|
+
crossRepoCitations,
|
|
449
|
+
datedCitations,
|
|
450
|
+
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
451
|
+
kept,
|
|
452
|
+
globs,
|
|
453
|
+
crossRepo,
|
|
454
|
+
dated,
|
|
455
|
+
moves: entries.filter((entry) => entry.movesTo !== undefined).length,
|
|
456
|
+
}
|
|
457
|
+
}
|
package/src/record-root.ts
CHANGED
|
@@ -52,10 +52,12 @@ const CANON_SCRATCH = 'tmp'
|
|
|
52
52
|
* writes back. `worktrees` is absent because the harness creates a worktree
|
|
53
53
|
* under `.claude/` and requires its target to sit there.
|
|
54
54
|
*
|
|
55
|
-
* Everything absent from this list is committed and
|
|
56
|
-
* the rule the move ran on. `
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* Everything absent from this list is committed and never lands under the
|
|
56
|
+
* record root, which is the rule the move ran on. `rules`, `skills`, and
|
|
57
|
+
* `hooks` stay under `.claude/` because the vendor reads them there, while
|
|
58
|
+
* `context`, `wireframes`, and the loose documents are tracked surfaces that
|
|
59
|
+
* `surface-root.ts` resolves under `canon/` instead. A seed and a
|
|
60
|
+
* superseded-layout report each ask this rather than assuming a root.
|
|
59
61
|
*/
|
|
60
62
|
export const RECORD_ENTRIES: readonly string[] = [
|
|
61
63
|
'.records.git',
|
package/src/sandbox/census.ts
CHANGED
|
@@ -69,7 +69,7 @@ export function listSkills(root: string): string[] {
|
|
|
69
69
|
/**
|
|
70
70
|
* Maps a scenario to the skill it drives, trying two spellings in order.
|
|
71
71
|
*
|
|
72
|
-
* `<category>-<command>` is the rule
|
|
72
|
+
* `<category>-<command>` is the rule `canon/context/sandbox/coverage.md` states, and it
|
|
73
73
|
* alone pairs 29 of 54 skills. The bare `<command>` fallback is what reaches the
|
|
74
74
|
* rest: `claude/setup-init.sh` drives the `setup-init` skill, not a
|
|
75
75
|
* `claude-setup-init` that does not exist. Stating one spelling and shipping two
|
|
@@ -139,10 +139,10 @@ const SAME_REPOSITORY =
|
|
|
139
139
|
* named a target's own tree, measured on 2026-09-03. Resolving against `root`
|
|
140
140
|
* is what turns that 124-hit pattern into the four-hit gate.
|
|
141
141
|
*
|
|
142
|
-
*
|
|
142
|
+
* `canon/context/` carries no equivalent pattern. The same resolution test
|
|
143
143
|
* cannot separate a path every scaffolded project holds, such as
|
|
144
|
-
*
|
|
145
|
-
* as
|
|
144
|
+
* `canon/context/index.md`, from this repository's own domain entry, such
|
|
145
|
+
* as `canon/context/indexes.md`, since both resolve here and only the
|
|
146
146
|
* second is a defect. That is a semantic read no pattern makes, so it stays
|
|
147
147
|
* with the rule and the review checklist rather than joining this reader.
|
|
148
148
|
*/
|
package/src/surface-root.ts
CHANGED
|
@@ -23,13 +23,13 @@ export type SurfaceRoot = (typeof SURFACE_ROOTS)[number]
|
|
|
23
23
|
/**
|
|
24
24
|
* The root a surface is created at when neither root carries it yet.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* It agrees with the head of the read precedence. While the two disagreed,
|
|
27
|
+
* creation stayed at `.claude/` so nothing wrote a fresh tracked file under a
|
|
28
|
+
* root a target's installed binary could not resolve. A release carries the
|
|
29
|
+
* read side now, so a fresh project scaffolds one root and an unmoved one keeps
|
|
30
|
+
* resolving its own surfaces through the fallback above.
|
|
31
31
|
*/
|
|
32
|
-
export const CREATION_ROOT: SurfaceRoot = '
|
|
32
|
+
export const CREATION_ROOT: SurfaceRoot = 'canon'
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Every tracked surface this module resolves, at the name `.claude/` gives it.
|
|
@@ -56,8 +56,13 @@ export function spell(root: SurfaceRoot, entry: string): string {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* The root a surface resolves at: the first that carries it, and
|
|
60
|
-
*
|
|
59
|
+
* The root a surface resolves at: the first that carries it, and
|
|
60
|
+
* `CREATION_ROOT` when neither does.
|
|
61
|
+
*
|
|
62
|
+
* The stamp folder used to take a carve-out here, staying at `.claude/` while
|
|
63
|
+
* the install stamp and the audits baseline still wrote there by a fixed path
|
|
64
|
+
* of their own. Both moved to `canon/config/` by a fixed path of their own, so
|
|
65
|
+
* every entry now shares the one default.
|
|
61
66
|
*/
|
|
62
67
|
function rootOf(root: string, entry: string): SurfaceRoot {
|
|
63
68
|
return (
|
|
@@ -82,6 +87,26 @@ export function surfaceDir(
|
|
|
82
87
|
return join(root, at, spell(at, entry), ...rest)
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Where a path authored at the `canon/` spelling lands under `root`.
|
|
92
|
+
*
|
|
93
|
+
* A seed tree authors every surface once, at the new root, while a target may
|
|
94
|
+
* still hold that surface under `.claude/`. Writing the literal path there
|
|
95
|
+
* would open a `canon/` copy that wins read precedence and hides every entry
|
|
96
|
+
* the target already wrote, so the entry resolves the way a read does. A path
|
|
97
|
+
* naming no surface entry is joined as written.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveSurfacePath(root: string, rel: string): string {
|
|
100
|
+
const [head, entry, ...rest] = rel.split(/[\\/]/)
|
|
101
|
+
|
|
102
|
+
if (head !== 'canon' || entry === undefined || entry === 'canon') {
|
|
103
|
+
return join(root, rel)
|
|
104
|
+
}
|
|
105
|
+
if (!SURFACE_ENTRIES.includes(entry)) return join(root, rel)
|
|
106
|
+
|
|
107
|
+
return surfaceDir(root, entry, ...rest)
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
/**
|
|
86
111
|
* Every root a surface would be read at, in precedence order, whether or not
|
|
87
112
|
* it is on disk.
|
package/src/sync/layout.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
|
-
import { basename, join } from 'node:path'
|
|
2
|
+
import { basename, join, relative } from 'node:path'
|
|
3
3
|
import { SUBDIRS } from '@/claude/seeds'
|
|
4
4
|
import { creationRel, isRecordEntry } from '@/record-root'
|
|
5
|
+
import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
|
|
5
6
|
import type { StampDomain } from '@/sync/stamp'
|
|
6
7
|
|
|
7
8
|
const CLAUDE_DIR = '.claude'
|
|
@@ -70,13 +71,16 @@ export function collectSuperseded(target: string): SupersededEntry[] {
|
|
|
70
71
|
const rel = join(CLAUDE_DIR, `${subdir.toUpperCase()}.md`)
|
|
71
72
|
if (!isFile(join(target, rel))) continue
|
|
72
73
|
|
|
73
|
-
// A record subdir
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// legacy file into a path nothing
|
|
74
|
+
// A record subdir lives under the record root and a tracked surface under
|
|
75
|
+
// the surface root, so the replacement this names is resolved against the
|
|
76
|
+
// target rather than fixed at `.claude/`. Naming a folder the target does
|
|
77
|
+
// not have sends a person to migrate their legacy file into a path nothing
|
|
78
|
+
// reads.
|
|
77
79
|
const replacedBy = isRecordEntry(subdir)
|
|
78
80
|
? creationRel(target, subdir)
|
|
79
|
-
:
|
|
81
|
+
: SURFACE_ENTRIES.includes(subdir)
|
|
82
|
+
? relative(target, surfaceDir(target, subdir))
|
|
83
|
+
: join(CLAUDE_DIR, subdir)
|
|
80
84
|
|
|
81
85
|
entries.push({ rel, replacedBy })
|
|
82
86
|
}
|
package/src/sync/reverse.ts
CHANGED
|
@@ -234,7 +234,7 @@ export function detectMigrations(target: string): MigrationCandidate[] {
|
|
|
234
234
|
if (docs > 0 && context === 0) {
|
|
235
235
|
found.push({
|
|
236
236
|
skill: 'migration-context',
|
|
237
|
-
reason: `${docs} markdown files under docs/ with no
|
|
237
|
+
reason: `${docs} markdown files under docs/ with no canon/context/ tier`,
|
|
238
238
|
})
|
|
239
239
|
}
|
|
240
240
|
|
package/src/sync/stamp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { dirname, join, sep } from 'node:path'
|
|
5
5
|
import { execa } from 'execa'
|
|
6
|
+
import { resolveExisting } from '@/legacy-path'
|
|
6
7
|
import { recordTarget } from '@/targets/registry'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -56,7 +57,21 @@ export interface Stamp {
|
|
|
56
57
|
readonly domains: Readonly<Partial<Record<StampDomain, DomainStamp>>>
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Where a stamp is written today, `canon/config/config.json`. Named
|
|
62
|
+
* `stampPath` rather than `surfaceStampPath` because the write side has only
|
|
63
|
+
* one current path, unlike the read side's fallback chain below.
|
|
64
|
+
*/
|
|
59
65
|
export function stampPath(target: string): string {
|
|
66
|
+
return join(target, 'canon', 'config', 'config.json')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where the stamp wrote before this move, `.claude/canon/config.json`. Kept
|
|
71
|
+
* for the same reason as the two spellings below: a target stamped under it
|
|
72
|
+
* still carries its config here, and `readStamp` falls back to it.
|
|
73
|
+
*/
|
|
74
|
+
export function claudeCanonStampPath(target: string): string {
|
|
60
75
|
return join(target, '.claude', 'canon', 'config.json')
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -84,18 +99,6 @@ export function retiredNameStampPath(target: string): string {
|
|
|
84
99
|
return join(target, '.claude', 'aitk', 'config.json')
|
|
85
100
|
}
|
|
86
101
|
|
|
87
|
-
/**
|
|
88
|
-
* The stamp path under the new surface root, `canon/config/config.json`.
|
|
89
|
-
*
|
|
90
|
-
* Read ahead of `stampPath`, extending the same mechanism rather than adding a
|
|
91
|
-
* new one: a target that has moved reads its config from the root it moved
|
|
92
|
-
* to, and one that has not falls through to the spellings below unchanged.
|
|
93
|
-
* The write destination does not move to it in this batch.
|
|
94
|
-
*/
|
|
95
|
-
function surfaceStampPath(target: string): string {
|
|
96
|
-
return join(target, 'canon', 'config', 'config.json')
|
|
97
|
-
}
|
|
98
|
-
|
|
99
102
|
/**
|
|
100
103
|
* Every spelling a stamp has been written under, current first. The order is
|
|
101
104
|
* the read order, so a target carrying more than one resolves to the newest.
|
|
@@ -105,10 +108,10 @@ function surfaceStampPath(target: string): string {
|
|
|
105
108
|
* second breaking change aimed at exactly the targets that were slowest to
|
|
106
109
|
* migrate the first time.
|
|
107
110
|
*/
|
|
108
|
-
export function stampPaths(target: string): readonly string[] {
|
|
111
|
+
export function stampPaths(target: string): readonly [string, ...string[]] {
|
|
109
112
|
return [
|
|
110
|
-
surfaceStampPath(target),
|
|
111
113
|
stampPath(target),
|
|
114
|
+
claudeCanonStampPath(target),
|
|
112
115
|
retiredNameStampPath(target),
|
|
113
116
|
legacyStampPath(target),
|
|
114
117
|
]
|
|
@@ -120,7 +123,10 @@ export function stampPaths(target: string): readonly string[] {
|
|
|
120
123
|
* there is nothing to migrate off of.
|
|
121
124
|
*/
|
|
122
125
|
export function isLegacyStamped(target: string): boolean {
|
|
123
|
-
if (
|
|
126
|
+
if (
|
|
127
|
+
existsSync(stampPath(target)) ||
|
|
128
|
+
existsSync(claudeCanonStampPath(target))
|
|
129
|
+
) {
|
|
124
130
|
return false
|
|
125
131
|
}
|
|
126
132
|
return (
|
|
@@ -158,8 +164,7 @@ export function toStampKey(rel: string): string {
|
|
|
158
164
|
* was read from.
|
|
159
165
|
*/
|
|
160
166
|
export function readStamp(target: string): Stamp | undefined {
|
|
161
|
-
|
|
162
|
-
return found === undefined ? undefined : readStampFile(found)
|
|
167
|
+
return readStampFile(resolveExisting(stampPaths(target)))
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
function readStampFile(path: string): Stamp | undefined {
|