@erclx/canon 4.79.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.79.0",
4
+ "version": "4.80.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -36,7 +36,7 @@ Every target in every wave takes the same branch and the same title, written fro
36
36
  - Title: `chore(agents): <what changed in that target>`.
37
37
  - Body: the sections `${CLAUDE_SKILL_DIR}/../../standards/pr.md` fixes, describing what that target received.
38
38
  - Body, one line: name this toolkit as the source of the changed files and where to change the content upstream. A reader in that repository months later has no other route to the fact that these files are toolkit-managed and that a local edit is lost at the next wave.
39
- - Body, no version number anywhere in it. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits a semver tag only where the pull request cuts a release, and `.claude/canon/config.json` in the target already records `syncedAt` and a sha256 per synced file. A version in the body is a second copy of a fact the target holds canonically, and it is the copy that goes stale while the pull request sits open.
39
+ - Body, no version number anywhere in it. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits a semver tag only where the pull request cuts a release, and `canon/config/config.json` in the target already records `syncedAt` and a sha256 per synced file. A version in the body is a second copy of a fact the target holds canonically, and it is the copy that goes stale while the pull request sits open.
40
40
 
41
41
  The title withholds the source and the body supplies it, because the two have different audiences. `agents` says what changed without saying where it came from, which is what a target's own history wants, and the source line is for a reader inside that repository rather than for whoever merges.
42
42
 
@@ -11,7 +11,7 @@ Read these files in parallel:
11
11
 
12
12
  - `${CLAUDE_SKILL_DIR}/../../standards/branch.md`: branch format, valid types, and constraints
13
13
  - `${CLAUDE_SKILL_DIR}/../../standards/pr.md`: structure, rules, and banned phrases
14
- - `${CLAUDE_SKILL_DIR}/references/labels.md`: label map format, matching, and the missing-label warning. Skip when the project has no `.claude/canon/pr-labels.toml`.
14
+ - `${CLAUDE_SKILL_DIR}/references/labels.md`: label map format, matching, and the missing-label warning. Skip when the project has no `canon/config/pr-labels.toml` and no `.claude/canon/pr-labels.toml`.
15
15
  - `${CLAUDE_SKILL_DIR}/../../standards/markdown.md`: banned words, punctuation, and formatting for all generated text
16
16
  - The `write-human` skill: voice, rhythm, and sentence construction for all generated text
17
17
  - `${CLAUDE_SKILL_DIR}/../../standards/versioning.md`: phase label vs semver discipline
@@ -119,7 +119,7 @@ A `reason` of `no-map` is the answer that the project declared no map, which ear
119
119
 
120
120
  Every other `reason` is a map or a range the verb could not read, which is `unreadable-map`, `no-domains`, `no-base`, and `unreadable-changes`, plus `bad-base` for a ref this skill resolved wrongly. Take the fallback below and warn beside the result line, naming the reason. A map with a typo in it still has rows a prefix match can reach, and reading the refusal as an absence would open the pull request with no labels and nothing said, which is the surface merging bare that the verb exists to name.
121
121
 
122
- The fallback is reading `.claude/canon/pr-labels.toml` and matching it against the name-only diff per `${CLAUDE_SKILL_DIR}/references/labels.md`. It also covers no record coming back at all, which is an installed `canon` predating the verb, since a skill reaches a target the moment it merges while the CLI reaches one only when a release publishes. The fallback labels correctly and reports no uncovered path, which is the half only the verb carries.
122
+ The fallback is reading `canon/config/pr-labels.toml`, or `.claude/canon/pr-labels.toml` when the project has not moved, and matching it against the name-only diff per `${CLAUDE_SKILL_DIR}/references/labels.md`. It also covers no record coming back at all, which is an installed `canon` predating the verb, since a skill reaches a target the moment it merges while the CLI reaches one only when a release publishes. Naming both spellings matters exactly here: the binary old enough to need this fallback is the same binary that may predate the move, so the project's map can still sit at the older path. The fallback labels correctly and reports no uncovered path, which is the half only the verb carries.
123
123
 
124
124
  Leave `pr_labels` empty when no map resolves or no prefix matches, which skips the labelling command rather than running it against nothing.
125
125
 
@@ -223,7 +223,7 @@ Respond with one line, using the `url` the final command printed:
223
223
 
224
224
  Add a line for each `uncovered` path the labels step reported, naming the path and the map it belongs in:
225
225
 
226
- `⚠️ No label covers <path>. Add a row to .claude/canon/pr-labels.toml or a [declined] entry.`
226
+ `⚠️ No label covers <path>. Add a row to canon/config/pr-labels.toml or a [declined] entry.`
227
227
 
228
228
  Add a further line only when the labelling command printed its warning, quoting the label `gh` refused:
229
229
 
@@ -17,7 +17,7 @@ Does not govern:
17
17
 
18
18
  ## Map format
19
19
 
20
- The map lives at `.claude/canon/pr-labels.toml` in the project root. Each key under `[domains]` is a label name and its value is the list of path prefixes that earn it.
20
+ The map lives at `canon/config/pr-labels.toml` in the project root, or `.claude/canon/pr-labels.toml` for a project that has not moved. Each key under `[domains]` is a label name and its value is the list of path prefixes that earn it.
21
21
 
22
22
  ```toml
23
23
  [domains]
@@ -19,7 +19,7 @@ canon audits list --json
19
19
  | ----------------- | ---------------------------------------------------------------------------------------------------------- |
20
20
  | `--json` | Add a machine-readable record on stdout, keeping the frame |
21
21
  | `--root <path>` | Measure this tree instead of the current worktree |
22
- | `--record` | Write this run's tracked counts to `.claude/canon/baseline.json` |
22
+ | `--record` | Write this run's tracked counts to `canon/config/baseline.json` |
23
23
  | `--corpus <name>` | Limit the run to one corpus (`tracked`, `per-machine`, `upstream`), repeatable, defaulting to every corpus |
24
24
 
25
25
  ## What it runs
@@ -75,7 +75,7 @@ Every run states how many corpora it measured against how many it skipped, inclu
75
75
 
76
76
  ## The retained baseline
77
77
 
78
- `.claude/canon/baseline.json` holds the counts from the last run recorded with `--record`, alongside the day it was taken and the commit it was read at. Each later run reports which counts moved and by how much.
78
+ `canon/config/baseline.json` holds the counts from the last run recorded with `--record`, alongside the day it was taken and the commit it was read at, falling back to `.claude/canon/baseline.json` for a project that has not moved. Each later run reports which counts moved and by how much.
79
79
 
80
80
  The file is committed. A per-machine record cannot answer the question this half exists for, which is whether a number grew since anyone last looked, and a fresh checkout has to inherit that answer rather than start over. The cost is that a branch moving a count either fixes it or re-records it and says why, which is the shape the consumed-copy assertion already carries.
81
81
 
@@ -56,7 +56,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
56
56
  | `canon migrate rename` | Rewrite every unprotected `aitk` token to `canon` and move the paths that carry the name, reporting the plan without `--write` (`--scope`, `--json`) |
57
57
  | `canon migrate skill-names` | Move the prefixed skill folders onto their two-word names and rewrite every citation, reporting the plan without `--write` (`--root`, `--json`) |
58
58
  | `canon migrate records` | Move the gitignored session records to `.canon/` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
59
- | `canon migrate surface-roots` | Move the tracked surfaces (`context/`, `wireframes/`, and the three loose documents) from `.claude/` to `canon/` with `git mv` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
59
+ | `canon migrate surface-roots` | Move the tracked surfaces (`context/`, `wireframes/`, the three loose documents, and the install stamp folder) from `.claude/` to `canon/` with `git mv` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
60
60
  | `canon migrate record-tree` | Repoint the old-root citations inside the records themselves, scoped to the live folders and reporting every line without `--write` (`--root`, `--json`) |
61
61
  | `canon migrate rule-layout` | Move a target's installed rules from the flat `.claude/rules/<subdir>/` layout to `.claude/rules/canon/<subdir>/`, reporting the plan without `--write` (`--root`, `--json`) |
62
62
  | `canon migrate scratch-evidence` | Move a folder under `.tmp/` that a durable record cites as evidence to `.canon/review/evidence/`, repointing every citation live or archived, reporting the plan without `--write` (`--root`, `--json`) |
@@ -167,7 +167,7 @@ Common patterns:
167
167
 
168
168
  A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the nearest non-blank line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
169
169
 
170
- `migrate surface-roots` moves the tracked surfaces from `.claude/` to `canon/`: `context/`, `wireframes/`, `ARCHITECTURE.md`, `REQUIREMENTS.md`, and `DESIGN.md`. Every path is tracked, so each file moves with `git mv` and its history follows it, and every tracked citation of one is rewritten in the same run. `.claude/canon/`, `rules/`, `skills/`, `hooks/`, and `settings.json` stay where Claude Code reads them. It reports until `--write` is passed, and a destination already present under `canon/` refuses the whole plan rather than splitting a surface across both roots.
170
+ `migrate surface-roots` moves the tracked surfaces from `.claude/` to `canon/`: `context/`, `wireframes/`, `ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`, and the install stamp folder, respelled `canon/config/` since a project under `canon/` reserves the bare `canon` name for the CLI's own install. Every path is tracked, so each file moves with `git mv` and its history follows it, and every tracked citation of one is rewritten in the same run. `rules/`, `skills/`, `hooks/`, and `settings.json` stay where Claude Code reads them. It reports until `--write` is passed, and a destination already present under `canon/` refuses the whole plan rather than splitting a surface across both roots.
171
171
 
172
172
  A line carrying `canon-keep-surface-root`, or the nearest non-blank line above it, keeps the old spelling. A rule's frontmatter `paths:` glob, a citation shaped like a path into another repository, and one inside a dated paragraph are reported by file and line and left alone. Running it twice rewrites nothing, which is the idempotence check. A project that never runs it keeps resolving each surface under `.claude/`, since every reader takes whichever root holds the entry.
173
173
 
@@ -158,9 +158,9 @@ Use `--json` for the machine-readable report and `--exit-code` to fail a CI job.
158
158
  and missing files are both excluded from that exit code: a project-authored
159
159
  rule never converges, and a sync that added a missing one silently changes
160
160
  what the project is governed by, which stays a separate command an operator
161
- chooses to run. Attribution reads `canon/config/config.json` first and falls
162
- back to `.claude/canon/config.json`, which every install and sync still writes
163
- in this release.
161
+ chooses to run. Attribution reads `canon/config/config.json` first, which every
162
+ install and sync now writes, and falls back to `.claude/canon/config.json` for
163
+ a target that has not moved.
164
164
 
165
165
  A target installed before stamping shipped has no such file, and neither does
166
166
  that fallback do anything to migrate it. A target stamped before the file
@@ -28,7 +28,7 @@ Under `--json` the record holds stdout alone and the frame still renders on stde
28
28
 
29
29
  ## The map it reads
30
30
 
31
- The map sits at `.claude/canon/pr-labels.toml` and carries two tables. `[domains]` keys a label name to the path prefixes that earn it, and `[declined]` keys a reason to the prefixes that earn no label on purpose.
31
+ The map sits at `canon/config/pr-labels.toml`, falling back to `.claude/canon/pr-labels.toml` for a project that has not moved, and carries two tables. `[domains]` keys a label name to the path prefixes that earn it, and `[declined]` keys a reason to the prefixes that earn no label on purpose.
32
32
 
33
33
  ```toml
34
34
  [domains]
@@ -208,7 +208,7 @@ The report opens by naming the binary running it. The installed version reads ag
208
208
 
209
209
  A `stale` file still matches what the toolkit installed, so the update is mechanical. A `customized` file carries local edits, so taking the upstream version is a decision and `canon:seed-sync` is the tool for it. A `stranded` file sits where an older toolkit installed it and the toolkit has since moved, which is a relocation the report names but no command runs.
210
210
 
211
- That attribution comes from `canon/config/config.json` when a project carries it, falling back to `.claude/canon/config.json`, a stamp every install and sync still writes in this release. A target stamped before that path shipped is read from the retired `.claude/canon.json` instead, reported rather than migrated. Governance records a hash per installed file, plus the stack `canon gov install` was given, and tooling records the stack chain it resolved instead of any file hash, since its install runs no per-file walk to attribute.
211
+ That attribution comes from `canon/config/config.json`, which every install and sync now writes, falling back to `.claude/canon/config.json` for a target stamped before that move. A target stamped before that path shipped is read from the retired `.claude/canon.json` instead, reported rather than migrated. Governance records a hash per installed file, plus the stack `canon gov install` was given, and tooling records the stack chain it resolved instead of any file hash, since its install runs no per-file walk to attribute.
212
212
 
213
213
  Each domain holds its own toolkit commit, so syncing governance today does not move the revision tooling measures against, and each domain reports the upstream commits touching its own source path. Running any sync stamps that domain, and the report names the ones still unstamped.
214
214
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.79.0",
4
+ "version": "4.80.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
- import { dirname, join } from 'node:path'
2
+ import { dirname, join, relative } from 'node:path'
3
3
  import type { AuditResult } from '@/audits/catalog'
4
+ import { resolveExisting } from '@/legacy-path'
4
5
 
5
6
  /**
6
7
  * Where the retained counts live, relative to the project root.
@@ -15,7 +16,14 @@ import type { AuditResult } from '@/audits/catalog'
15
16
  * that installs the CLI. A baseline in the package would hand a target this
16
17
  * repository's counts to measure its own tree against.
17
18
  */
18
- export const BASELINE_REL = join('.claude', 'canon', 'baseline.json')
19
+ export const BASELINE_REL = join('canon', 'config', 'baseline.json')
20
+
21
+ /**
22
+ * Where the baseline wrote before this move, `.claude/canon/baseline.json`.
23
+ * `readBaseline` falls back to it so a target that has not moved still reads
24
+ * its recorded floor, and the write no longer lands there.
25
+ */
26
+ const LEGACY_BASELINE_REL = join('.claude', 'canon', 'baseline.json')
19
27
 
20
28
  export interface Baseline {
21
29
  /** The day the record was taken, as `YYYY-MM-DD`. */
@@ -165,7 +173,11 @@ function isBaseline(value: unknown): value is Baseline {
165
173
  export async function readBaseline(
166
174
  root: string,
167
175
  ): Promise<Baseline | undefined> {
168
- const path = join(root, BASELINE_REL)
176
+ const path = resolveExisting([
177
+ join(root, BASELINE_REL),
178
+ join(root, LEGACY_BASELINE_REL),
179
+ ])
180
+ const rel = relative(root, path)
169
181
 
170
182
  let raw: string
171
183
  try {
@@ -178,12 +190,12 @@ export async function readBaseline(
178
190
  try {
179
191
  parsed = JSON.parse(raw)
180
192
  } catch {
181
- throw new Error(`${BASELINE_REL} does not parse as JSON. Fix or delete it.`)
193
+ throw new Error(`${rel} does not parse as JSON. Fix or delete it.`)
182
194
  }
183
195
 
184
196
  if (!isBaseline(parsed)) {
185
197
  throw new Error(
186
- `${BASELINE_REL} carries no recordedAt, commit, and checks. Fix or delete it.`,
198
+ `${rel} carries no recordedAt, commit, and checks. Fix or delete it.`,
187
199
  )
188
200
  }
189
201
 
@@ -101,7 +101,7 @@ function renderCheck(report: CheckReport): void {
101
101
 
102
102
  if (report.stampAtLegacyPath) {
103
103
  logWarn(
104
- 'Stamp found at a retired path. Move it to .claude/canon/config.json.',
104
+ 'Stamp found at a retired path. Move it to canon/config/config.json.',
105
105
  )
106
106
  }
107
107
 
@@ -126,11 +126,11 @@ export const SANDBOX_ASSERTED_FLOOR = 26
126
126
  * Where the retained counts the audit stage compares each run against are
127
127
  * read, relative to `root`.
128
128
  *
129
- * A function rather than a spelled constant, since the read now resolves at
129
+ * A function rather than a spelled constant, since the read resolves at
130
130
  * either surface root and a constant naming one of them would be believed of
131
- * a project that has moved. `canon audits run` owns writing it, and the write
132
- * stays at the creation default for this batch, so the two can disagree for
133
- * exactly the release window `src/surface-root.ts` documents.
131
+ * a project that has moved. `canon audits run` owns writing it, at
132
+ * `canon/config/baseline.json` now that the write has flipped, falling back to
133
+ * `.claude/canon/baseline.json` for a target that has not moved.
134
134
  */
135
135
  export function auditsBaselineRel(root: string): string {
136
136
  return relative(root, surfaceDir(root, 'canon', 'baseline.json'))
package/src/labels/map.ts CHANGED
@@ -1,14 +1,23 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { resolveExisting } from '@/legacy-path'
3
4
 
4
5
  /**
5
- * Where a project declares its pull request label map, spelled once.
6
+ * Where a project declares its pull request label map.
6
7
  *
7
8
  * The file has already moved once, and that relocation rewrote every mention
8
- * across four surfaces. Nothing in code spells it anywhere else, so the next
9
- * move is one edit here rather than a sweep.
9
+ * across four surfaces. `LEGACY_MAP_REL` below is the one other place code
10
+ * spells it, kept only as a read fallback, so the next move is still one edit
11
+ * to the write path rather than a sweep.
10
12
  */
11
- export const MAP_REL = join('.claude', 'canon', 'pr-labels.toml')
13
+ export const MAP_REL = join('canon', 'config', 'pr-labels.toml')
14
+
15
+ /**
16
+ * Where the map lived before this move, `.claude/canon/pr-labels.toml`.
17
+ * `readLabelMap` falls back to it so a project that has not moved still reads
18
+ * its declared map.
19
+ */
20
+ const LEGACY_MAP_REL = join('.claude', 'canon', 'pr-labels.toml')
12
21
 
13
22
  /** A label name and the path prefixes that earn it, in the map's own order. */
14
23
  export interface DomainRow {
@@ -90,9 +99,14 @@ export function parseLabelMap(source: string): LabelMap {
90
99
 
91
100
  /** Reads the map a project declares at `root`, or says why it could not. */
92
101
  export function readLabelMap(root: string): LabelMap {
102
+ const path = resolveExisting([
103
+ join(root, MAP_REL),
104
+ join(root, LEGACY_MAP_REL),
105
+ ])
106
+
93
107
  let source: string
94
108
  try {
95
- source = readFileSync(join(root, MAP_REL), 'utf8')
109
+ source = readFileSync(path, 'utf8')
96
110
  } catch {
97
111
  return { kind: 'refused', reason: 'no-map' }
98
112
  }
@@ -0,0 +1,15 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ /**
4
+ * Where a fallback chain resolves for reading: the first path that exists,
5
+ * current first, or the first candidate when none exists yet, which is
6
+ * always the write target.
7
+ *
8
+ * `src/audits/baseline.ts`, `src/labels/map.ts`, and `src/sync/stamp.ts` each
9
+ * trail a file that moved, and each used to reason about its own chain
10
+ * separately. The tuple type keeps a caller from passing an empty list, since
11
+ * every chain here has a write target to fall back to.
12
+ */
13
+ export function resolveExisting(paths: readonly [string, ...string[]]): string {
14
+ return paths.find((path) => existsSync(path)) ?? paths[0]
15
+ }
@@ -44,6 +44,7 @@ export interface RenamePlan {
44
44
  const TOOLKIT_OWNED: readonly string[] = [
45
45
  '.claude/aitk/',
46
46
  '.claude/canon/',
47
+ 'canon/config/',
47
48
  '.claude/hooks/',
48
49
  '.claude/rules/',
49
50
  '.claude/tooling/',
@@ -20,13 +20,12 @@ const TO_ROOT: SurfaceRoot = 'canon'
20
20
  /**
21
21
  * Every entry this move relocates, at the name `.claude/` gives it.
22
22
  *
23
- * Read from `SURFACE_ENTRIES` rather than restated, less the one entry that
24
- * stays: `canon` names the install stamp folder, which batch 3 owns and which
25
- * `.claude/canon/` continues to hold until that batch lands.
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.
26
27
  */
27
- export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES.filter(
28
- (entry) => entry !== 'canon',
29
- )
28
+ export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES
30
29
 
31
30
  function sourcePrefix(entry: string): string {
32
31
  return join(FROM_ROOT, spell(FROM_ROOT, entry))
@@ -56,26 +56,19 @@ export function spell(root: SurfaceRoot, entry: string): string {
56
56
  }
57
57
 
58
58
  /**
59
- * The creation default for one entry.
59
+ * The root a surface resolves at: the first that carries it, and
60
+ * `CREATION_ROOT` when neither does.
60
61
  *
61
- * The stamp folder keeps creating under `.claude/` until its own move lands,
62
- * since the install stamp and the audits baseline still write there by a
63
- * fixed path. A read resolving to `canon/config/` in a fresh project would
64
- * answer from a folder nothing writes.
65
- */
66
- function creationRootFor(entry: string): SurfaceRoot {
67
- return entry === 'canon' ? '.claude' : CREATION_ROOT
68
- }
69
-
70
- /**
71
- * The root a surface resolves at: the first that carries it, and the creation
72
- * default when neither does.
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.
73
66
  */
74
67
  function rootOf(root: string, entry: string): SurfaceRoot {
75
68
  return (
76
69
  SURFACE_ROOTS.find((candidate) =>
77
70
  existsSync(join(root, candidate, spell(candidate, entry))),
78
- ) ?? creationRootFor(entry)
71
+ ) ?? CREATION_ROOT
79
72
  )
80
73
  }
81
74
 
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 (existsSync(surfaceStampPath(target)) || existsSync(stampPath(target))) {
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
- const found = stampPaths(target).find((path) => existsSync(path))
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 {
@@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
5
  import {
6
+ claudeCanonStampPath,
6
7
  isLegacyStamped,
7
8
  legacyStampPath,
8
9
  retiredNameStampPath,
@@ -91,15 +92,18 @@ export interface SweepOptions {
91
92
  }
92
93
 
93
94
  /**
94
- * Whether a folder carries an install stamp at the current path or either
95
+ * Whether a folder carries an install stamp at the current path or any
95
96
  * retired one. `aitk@3.57.0` still writes the folder form,
96
97
  * `retiredNameStampPath`, as its current path, so a target a pre-rename
97
98
  * binary syncs after this check drops the folder form would otherwise vanish
98
- * from the walk with nothing saying so.
99
+ * from the walk with nothing saying so. `claudeCanonStampPath` is the spelling
100
+ * every target carried before the stamp moved to `canon/config/`, which a
101
+ * target this batch has not reached still writes to.
99
102
  */
100
103
  function isStamped(path: string): boolean {
101
104
  return (
102
105
  Bun.file(stampPath(path)).size > 0 ||
106
+ Bun.file(claudeCanonStampPath(path)).size > 0 ||
103
107
  Bun.file(retiredNameStampPath(path)).size > 0 ||
104
108
  Bun.file(legacyStampPath(path)).size > 0
105
109
  )