@erclx/canon 4.72.1 → 4.73.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.72.1",
4
+ "version": "4.73.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -18,6 +18,7 @@ Neither attempt named who the relay is from. A message an operator relays by han
18
18
  ## Must
19
19
 
20
20
  - Fire from inside `role-worker` or `role-planner`'s own `## The channel` section, at the point that section finds no message-sending tool
21
+ - Refuse a caller holding neither role, checked ahead of the tool-availability guard
21
22
  - Read the sender's own name off `canon sessions list --self --json` before composing anything, and degrade to naming itself from the environment when the installed CLI answers `--self` with an unknown option rather than a refusal carrying a reason
22
23
  - Resolve the addressee by running the calling role's own ladder rather than a second one
23
24
  - Carry the message body the calling role's bullet already names, verbatim
@@ -32,6 +33,7 @@ Neither attempt named who the relay is from. A message an operator relays by han
32
33
 
33
34
  ## Guards
34
35
 
36
+ - The calling session holds neither role: refuse, and state the addressing and timing it owes instead, since the plugin skill it would otherwise defer to reaches a target the moment it merges while a governance rule reaches one only through a separate install. Checked first, since the tool guard read alone let the incident behind this row stop at "you have a tool" without ever learning steps 2 and 3 were unreachable regardless.
35
37
  - A message-sending tool is available: refuse, name it, and send through it instead
36
38
 
37
39
  ## Out of scope
@@ -11,6 +11,7 @@ Read the calling body's `## The channel` section for the message owed and the la
11
11
 
12
12
  ## Guards
13
13
 
14
+ - Refuse to fire when the calling session holds neither `role-worker` nor `role-planner`. Say so, and state what it owes instead: resolve the addressee at send time through `canon sessions list --json`, never by name prefix, and send a block out as a message before it becomes an interactive prompt, leaving the message content to whatever dispatched it. Checked first, since a caller that never held either role never had a ladder here to run, and the tool guard below reads as the whole reason only once this one has cleared.
14
15
  - Refuse to fire when a message-sending tool is available. Say so, name the tool, and send through it. This skill exists for the gap, never as an alternative to the ordinary channel.
15
16
 
16
17
  ## Steps
@@ -158,8 +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 `.claude/canon/config.json`, which every
162
- install and sync writes.
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.
163
164
 
164
165
  A target installed before stamping shipped has no such file, and neither does
165
166
  that fallback do anything to migrate it. A target stamped before the file
@@ -204,7 +204,7 @@ The report opens by naming the binary running it. The installed version reads ag
204
204
 
205
205
  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.
206
206
 
207
- That attribution comes from `.claude/canon/config.json`, a stamp every install and sync writes. 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.
207
+ 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.
208
208
 
209
209
  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.
210
210
 
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: The addressing and timing a session holding neither role-worker nor role-planner owes when it must message whoever dispatched it
3
+ ---
4
+
5
+ # Channel standards
6
+
7
+ ## A session holding neither role
8
+
9
+ - Resolve the addressee at send time through `canon sessions list --json`, keyed on the id or branch the dispatcher named at launch, never by name prefix. A name is derived from what a session turns out to be doing and goes stale before a build finishes.
10
+ - Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round that drains an inbound message, so an answer relayed afterwards arrives under the open question and changes nothing.
11
+ - Leave the message content, and which transition earns one, to whatever dispatched this session. Both are task-specific and already stated where they apply.
12
+ - A session holding `canon:role-worker` or `canon:role-planner` follows that body's own addressee ladder instead, stated in its own `## The channel` section. This rule states what neither role leaves stated elsewhere, not a replacement for either ladder.
13
+ - Report it rather than proceeding silently when neither skill resolves to check against. Both ship with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have them.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.72.1",
4
+ "version": "4.73.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 { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { resolveSkillsCorpus } from '@/claude/skills-list'
4
+ import { SURFACE_ROOTS } from '@/surface-root'
4
5
 
5
6
  /**
6
7
  * The authoring roots this repository owns and no install channel delivers.
@@ -15,9 +16,14 @@ import { resolveSkillsCorpus } from '@/claude/skills-list'
15
16
  * report a correct citation on every run and bury the defect this measures.
16
17
  * `docs/agents/` is the exception, being the CLI contract pages that exist
17
18
  * here alone.
19
+ *
20
+ * `.claude/context/` carries both surface-root spellings, since this list
21
+ * decides whether a shipped body names this repository's own tree and a body
22
+ * naming either root is doing that regardless of which root a given checkout
23
+ * carries.
18
24
  */
19
25
  const AUTHORING_ROOTS = [
20
- '.claude/context/',
26
+ ...SURFACE_ROOTS.map((root) => `${root}/context/`),
21
27
  'claude/',
22
28
  'docs/agents/',
23
29
  'governance/',
@@ -92,9 +98,13 @@ export function isQualified(line: string): boolean {
92
98
  * by corpus instead.
93
99
  */
94
100
  export function authoringRootsFor(corpus: string): readonly string[] {
95
- if (!corpus.startsWith('.claude/')) return AUTHORING_ROOTS
101
+ if (!SURFACE_ROOTS.some((root) => corpus.startsWith(`${root}/`))) {
102
+ return AUTHORING_ROOTS
103
+ }
96
104
 
97
- return AUTHORING_ROOTS.filter((root) => !root.startsWith('.claude/'))
105
+ return AUTHORING_ROOTS.filter(
106
+ (root) => !SURFACE_ROOTS.some((surface) => root.startsWith(`${surface}/`)),
107
+ )
98
108
  }
99
109
 
100
110
  /**
@@ -17,11 +17,11 @@ import {
17
17
  type SectionFinding,
18
18
  } from '@/context/audit'
19
19
  import {
20
+ architectureRel,
20
21
  type ArchitectureReport,
21
22
  coveredCount,
22
23
  isOverLength,
23
24
  measureArchitecture,
24
- RECORD_REL,
25
25
  testableCount,
26
26
  } from '@/context/architecture'
27
27
  import { auditCitations, type CitationReport } from '@/context/citations'
@@ -251,7 +251,7 @@ async function runAudit(
251
251
  reportProvenance(entries, folders)
252
252
  reportNarration(entries, folders, narration)
253
253
  reportDrift(drift)
254
- reportRecord(record)
254
+ reportRecord(record, root)
255
255
  outro()
256
256
  }
257
257
 
@@ -761,12 +761,15 @@ const CLAIM_LABEL: Record<string, string> = {
761
761
  * anchors it sits beside already do. Nothing is stored: every run reclassifies,
762
762
  * so an entry rewritten tomorrow is read as it stands then.
763
763
  */
764
- function reportRecord(report: ArchitectureReport | undefined): void {
764
+ function reportRecord(
765
+ report: ArchitectureReport | undefined,
766
+ root: string,
767
+ ): void {
765
768
  logStep('Architecture record')
766
769
 
767
770
  if (report === undefined) {
768
771
  logInfo(
769
- `Out of scope. The project carries no ${RECORD_REL}, so there was no record to measure.`,
772
+ `Out of scope. The project carries no ${architectureRel(root)}, so there was no record to measure.`,
770
773
  )
771
774
  return
772
775
  }
@@ -11,6 +11,7 @@ import { renderDesignDoc } from '@/design/render'
11
11
  import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
12
12
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
13
13
  import { creationRel } from '@/record-root'
14
+ import { surfaceDir } from '@/surface-root'
14
15
  import { recordStamp, runDomainSync } from '@/sync/engine'
15
16
  import { resolveTarget } from '@/target'
16
17
  import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
@@ -88,7 +89,11 @@ export function register(program: Command): void {
88
89
  design
89
90
  .command('render')
90
91
  .description('Render DESIGN.md tokens to HTML and CSS preview')
91
- .option('-s, --source <path>', 'Source DESIGN.md path', '.claude/DESIGN.md')
92
+ .option(
93
+ '-s, --source <path>',
94
+ 'Source DESIGN.md path',
95
+ relative(process.cwd(), surfaceDir(process.cwd(), 'DESIGN.md')),
96
+ )
92
97
  .option(
93
98
  '-o, --out <path>',
94
99
  'Output directory',
@@ -1,16 +1,22 @@
1
1
  import { access, readFile } from 'node:fs/promises'
2
- import { join } from 'node:path'
2
+ import { join, relative } from 'node:path'
3
3
  import { AUDITS } from '@/audits/catalog'
4
4
  import { bodyLines } from '@/markdown/scan'
5
+ import { surfaceDir } from '@/surface-root'
5
6
 
6
7
  /**
7
- * The record this measures, relative to the project root.
8
+ * The record this measures, relative to `root`.
8
9
  *
9
- * One fixed path rather than a folder walk, because the standard governing it
10
- * names one document, and the length rule this measures is stated by whichever
11
- * record sits there rather than by the standard or by this file.
10
+ * A function rather than a fixed constant, because the path now resolves at
11
+ * either surface root and a constant naming one of them would be believed to
12
+ * be true of a project that has moved. One fixed name rather than a folder
13
+ * walk otherwise, because the standard governing it names one document, and
14
+ * the length rule this measures is stated by whichever record sits there
15
+ * rather than by the standard or by this file.
12
16
  */
13
- export const RECORD_REL = '.claude/ARCHITECTURE.md'
17
+ export function architectureRel(root: string): string {
18
+ return relative(root, surfaceDir(root, 'ARCHITECTURE.md'))
19
+ }
14
20
 
15
21
  /**
16
22
  * The line allowances a record states for itself, absent when it states none.
@@ -299,7 +305,8 @@ export function ceilingFor(allowances: Allowances, decisions: number): number {
299
305
  export async function measureArchitecture(
300
306
  root: string,
301
307
  ): Promise<ArchitectureReport | undefined> {
302
- const path = join(root, RECORD_REL)
308
+ const rel = architectureRel(root)
309
+ const path = join(root, rel)
303
310
 
304
311
  let source: string
305
312
  try {
@@ -330,7 +337,7 @@ export async function measureArchitecture(
330
337
  )
331
338
 
332
339
  return {
333
- rel: RECORD_REL,
340
+ rel,
334
341
  lines: source.replace(/\n$/, '').split('\n').length,
335
342
  ...(allowances !== undefined && {
336
343
  allowances,
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
4
  import { listRepositoryFiles } from '@/git-files'
5
5
  import { RECORD_ROOTS } from '@/record-root'
6
+ import { SURFACE_ROOTS } from '@/surface-root'
6
7
 
7
8
  /**
8
9
  * Suppresses citation checking for the source line carrying it.
@@ -78,17 +79,41 @@ export function isFixture(rel: string): boolean {
78
79
  }
79
80
 
80
81
  /**
81
- * Both record roots are spelled, so a citation into a folder that has moved is
82
- * still resolved. A pattern fixed at one root matches nothing after the move and
83
- * reports nothing, which is a stale reference passing the check that exists to
84
- * find it rather than a check that fails.
82
+ * The boundary a root's own leading character rules out.
83
+ *
84
+ * A dotted root cannot be a suffix of a path segment, since a segment boundary
85
+ * is a slash and a slash can never sit inside the dot itself, so a preceding
86
+ * slash is not a false match to guard against and the boundary admits it. That
87
+ * is what a relative link needs, since `../.claude/context/<entry>.md` carries
88
+ * a slash immediately before `.claude`. A bare root has no such protection: it
89
+ * is a suffix of a dotted root's own name and of any `/<root>/` path segment,
90
+ * so its boundary rejects a slash along with a name character or a dot.
91
+ */
92
+ function rootBoundary(root: string): string {
93
+ return root.startsWith('.') ? '(?<![\\w.])' : '(?<![\\w./])'
94
+ }
95
+
96
+ /**
97
+ * Every record and surface root is spelled, so a citation into a folder that
98
+ * has moved is still resolved. A pattern fixed at one root matches nothing
99
+ * after the move and reports nothing, which is a stale reference passing the
100
+ * check that exists to find it rather than a check that fails.
101
+ *
102
+ * Each root carries its own boundary rather than one shared ahead of the whole
103
+ * alternation, per `rootBoundary`. A shared boundary rejecting a slash blocked
104
+ * every dotted root from a relative link, which is the same failure this
105
+ * function's own boundary exists to prevent, aimed at the roots that never
106
+ * needed it.
85
107
  */
86
108
  export function citationPattern(folders: readonly string[]): RegExp {
87
109
  const names = folders.map((name) => escape(name))
88
- const roots = RECORD_ROOTS.map((name) => escape(name))
110
+ const roots = [...new Set([...RECORD_ROOTS, ...SURFACE_ROOTS])]
111
+ const alternatives = roots
112
+ .map((root) => `${rootBoundary(root)}${escape(root)}`)
113
+ .join('|')
89
114
 
90
115
  return new RegExp(
91
- `(?:${roots.join('|')})/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
116
+ `(?:${alternatives})/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
92
117
  'g',
93
118
  )
94
119
  }
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { dirname, relative, resolve } from 'node:path'
3
3
  import { INDEX_FILE, listIndexes } from '@/indexes/walk'
4
4
  import { RECORD_ROOTS } from '@/record-root'
5
+ import { SURFACE_ROOTS } from '@/surface-root'
5
6
 
6
7
  /**
7
8
  * Folder names under a record root audited by default.
@@ -23,16 +24,34 @@ export const DEFAULT_FOLDERS: readonly string[] = [
23
24
  ]
24
25
 
25
26
  /**
26
- * The bases every folder in the default list is looked for under, in the record
27
- * roots' own precedence order.
27
+ * The bases every folder in the default list is looked for under.
28
28
  *
29
- * `diagrams` is the one name here that is a session record and moves with them,
30
- * so the list has to carry the root it moves to. `context` and `wireframes` are
31
- * tracked and stay, which leaves them resolvable at a root nothing will ever put
32
- * them under. That costs one `existsSync` apiece and is cheaper than a per-name
33
- * base map that would state the same split twice.
29
+ * `diagrams` is a session record and resolves under `RECORD_ROOTS`, while
30
+ * `context` and `wireframes` are tracked and resolve under `SURFACE_ROOTS`, so
31
+ * the split this comment used to describe as hypothetical is real: two
32
+ * folders on this list read from two different root lists. The base array is
33
+ * their union rather than a per-name map, since a name resolving at the wrong
34
+ * root costs one extra `existsSync` and nothing else, where a map states the
35
+ * split a second time next to the one each resolver module already carries.
36
+ *
37
+ * `.claude` is pushed last because both lists name it, and each list's own
38
+ * precedence otherwise survives: `canon` still precedes `.claude` for a
39
+ * `SURFACE_ROOTS` name, and `.canon` still precedes `.claude` for a
40
+ * `RECORD_ROOTS` one. Order between `canon` and `.canon` is unobserved, since
41
+ * no name on this list resolves under both.
42
+ *
43
+ * Adding `canon` here means a target holding a root-level `canon/` folder of
44
+ * its own now resolves it as the toolkit's, since a project writing about a
45
+ * product called canon is a plausible name collision `canResolveAtRoot`'s own
46
+ * project-root gate does not cover. The audit only reports, so the cost is a
47
+ * wrong scope line rather than a wrong edit.
34
48
  */
35
- const CLAUDE_BASES: readonly string[] = RECORD_ROOTS
49
+ const CLAUDE_BASES: readonly string[] = [
50
+ ...new Set(
51
+ [...SURFACE_ROOTS, ...RECORD_ROOTS].filter((root) => root !== '.claude'),
52
+ ),
53
+ '.claude',
54
+ ]
36
55
 
37
56
  /** The project root, reached only by a name the caller asked for. */
38
57
  const ROOT_BASE = '.'
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
3
- import { join } from 'node:path'
3
+ import { join, relative } from 'node:path'
4
4
  import {
5
5
  CLIENT_COMMAND_MARKER,
6
6
  CLIENT_COMMANDS,
@@ -15,6 +15,7 @@ import {
15
15
  SHIPPED_CORPORA,
16
16
  type ShippedReference,
17
17
  } from '@/shipped/references'
18
+ import { surfaceDir } from '@/surface-root'
18
19
  import {
19
20
  README_PARAPHRASE_MARKER,
20
21
  readmeCitationsIn,
@@ -122,11 +123,18 @@ export const SANDBOX_UNDECLARED_CEILING = 47
122
123
  export const SANDBOX_ASSERTED_FLOOR = 26
123
124
 
124
125
  /**
125
- * The retained counts the audit stage compares each run against. Spelled here
126
- * rather than derived, because this stage only ever names the file in a remedy
127
- * a reader has to be able to open, and `canon audits run` owns writing it.
126
+ * Where the retained counts the audit stage compares each run against are
127
+ * read, relative to `root`.
128
+ *
129
+ * A function rather than a spelled constant, since the read now resolves at
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.
128
134
  */
129
- export const AUDITS_BASELINE = '.claude/canon/baseline.json'
135
+ export function auditsBaselineRel(root: string): string {
136
+ return relative(root, surfaceDir(root, 'canon', 'baseline.json'))
137
+ }
130
138
 
131
139
  export const CAPTURE_STAMP_FAILURE =
132
140
  'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets and commit each frame with its image and its stamp.'
@@ -958,16 +966,17 @@ export const auditSet: Measure = async (ctx) => {
958
966
  ),
959
967
  )
960
968
  }
969
+ const baselineRel = auditsBaselineRel(ctx.root)
961
970
  emissions.push(
962
971
  summary.grown > 0
963
972
  ? warn(
964
- `${summary.grown} measure(s) grew against ${AUDITS_BASELINE}. Run bun src/cli.ts audits run to see which, then fix them or re-record and say why.`,
973
+ `${summary.grown} measure(s) grew against ${baselineRel}. Run bun src/cli.ts audits run to see which, then fix them or re-record and say why.`,
965
974
  )
966
- : info(`No measure grew against ${AUDITS_BASELINE}`),
975
+ : info(`No measure grew against ${baselineRel}`),
967
976
  )
968
977
  if (typeof summary.shrunk === 'number' && summary.shrunk > 0) {
969
978
  emissions.push(
970
- info(`${summary.shrunk} measure(s) fell against ${AUDITS_BASELINE}`),
979
+ info(`${summary.shrunk} measure(s) fell against ${baselineRel}`),
971
980
  )
972
981
  }
973
982
 
@@ -76,10 +76,14 @@ function escape(value: string): string {
76
76
  * paths a session actually opened. Rewriting either makes it testify to
77
77
  * something that never happened.
78
78
  *
79
- * This module and `src/record-root.ts` are the two sources that state the old
79
+ * This module and `src/record-root.ts` are two of the sources that state the old
80
80
  * root on purpose. Sweeping them turns every citation this expression is built
81
81
  * from into its own replacement, leaving a rewriter that maps `.canon/` to
82
- * `.canon/` and matches nothing.
82
+ * `.canon/` and matches nothing. `src/surface-root.ts` joins the list a release
83
+ * early, for the same reason: it spells `.claude` as data the moment it
84
+ * exists, and excluding it later would leave one release where a records
85
+ * migration in a target could rewrite the resolver that migration itself
86
+ * depends on.
83
87
  *
84
88
  * A test file is excluded because the fixtures that prove the old root still
85
89
  * resolves have to keep building it. Rewriting one is worse than a failing
@@ -106,7 +110,11 @@ const EXCLUDED_PREFIXES: readonly string[] = [
106
110
  'tooling/claude/seeds/.claude/hooks/',
107
111
  ]
108
112
 
109
- const EXCLUDED_PATHS: readonly string[] = ['CHANGELOG.md', 'src/record-root.ts']
113
+ const EXCLUDED_PATHS: readonly string[] = [
114
+ 'CHANGELOG.md',
115
+ 'src/record-root.ts',
116
+ 'src/surface-root.ts',
117
+ ]
110
118
 
111
119
  const EXCLUDED_SUFFIXES: readonly string[] = ['.test.ts']
112
120
 
@@ -0,0 +1,101 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * The roots a tracked toolkit surface is read at, in precedence order.
6
+ *
7
+ * These are not `RECORD_ROOTS`, and the difference is what each root is for
8
+ * rather than an oversight. A record folder moves into `.canon/` because it is
9
+ * gitignored, so nothing tracked ever lands there. A surface here is committed,
10
+ * so a single shared list would put a record folder under a root a tracked
11
+ * file also resolves against, which is a collision this list never has to
12
+ * consider on its own.
13
+ *
14
+ * `canon` wins because a tree that carries it has moved, and reading `.claude/`
15
+ * there would answer from the copy the move left behind. Read precedence
16
+ * agreeing with the eventual creation default means the flip a later batch
17
+ * takes changes one line rather than two.
18
+ */
19
+ export const SURFACE_ROOTS = ['canon', '.claude'] as const
20
+
21
+ export type SurfaceRoot = (typeof SURFACE_ROOTS)[number]
22
+
23
+ /**
24
+ * The root a surface is created at when neither root carries it yet.
25
+ *
26
+ * Disagreeing with the head of the read order for exactly one release: read
27
+ * precedence is new-first so a tree that has moved is never answered from the
28
+ * copy left behind, while creation stays at the old root so nothing writes a
29
+ * fresh tracked file under a root a target's installed binary may not resolve
30
+ * yet. A later batch flips this once a release carries the read side.
31
+ */
32
+ export const CREATION_ROOT: SurfaceRoot = '.claude'
33
+
34
+ /**
35
+ * Every tracked surface this module resolves, at the name `.claude/` gives it.
36
+ *
37
+ * `canon` names the stamp folder rather than the CLI itself, which is the one
38
+ * entry `spell` respells per root.
39
+ */
40
+ export const SURFACE_ENTRIES: readonly string[] = [
41
+ 'ARCHITECTURE.md',
42
+ 'REQUIREMENTS.md',
43
+ 'DESIGN.md',
44
+ 'context',
45
+ 'wireframes',
46
+ 'canon',
47
+ ]
48
+
49
+ /**
50
+ * How a root spells an entry name. Only the stamp folder differs, since a
51
+ * project under `canon/` reserves the bare name for the CLI's own install
52
+ * rather than for its config.
53
+ */
54
+ export function spell(root: SurfaceRoot, entry: string): string {
55
+ return root === 'canon' && entry === 'canon' ? 'config' : entry
56
+ }
57
+
58
+ /**
59
+ * The root a surface resolves at: the first that carries it, and the creation
60
+ * default when neither does.
61
+ */
62
+ function rootOf(root: string, entry: string): SurfaceRoot {
63
+ return (
64
+ SURFACE_ROOTS.find((candidate) =>
65
+ existsSync(join(root, candidate, spell(candidate, entry))),
66
+ ) ?? CREATION_ROOT
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Where a tracked surface is read.
72
+ *
73
+ * `entry` is the surface itself and `rest` is whatever sits inside it, so a
74
+ * caller spells no root and no per-root naming variant of its own.
75
+ */
76
+ export function surfaceDir(
77
+ root: string,
78
+ entry: string,
79
+ ...rest: string[]
80
+ ): string {
81
+ const at = rootOf(root, entry)
82
+ return join(root, at, spell(at, entry), ...rest)
83
+ }
84
+
85
+ /**
86
+ * Every root a surface would be read at, in precedence order, whether or not
87
+ * it is on disk.
88
+ *
89
+ * Containment tests take this rather than `surfaceDir`, since a path written
90
+ * against the root a tree no longer uses is still a path into that surface,
91
+ * and reading it as outside would report a live reference as stale.
92
+ */
93
+ export function surfaceDirs(
94
+ root: string,
95
+ entry: string,
96
+ ...rest: string[]
97
+ ): string[] {
98
+ return SURFACE_ROOTS.map((candidate) =>
99
+ join(root, candidate, spell(candidate, entry), ...rest),
100
+ )
101
+ }
package/src/sync/stamp.ts CHANGED
@@ -84,17 +84,30 @@ export function retiredNameStampPath(target: string): string {
84
84
  return join(target, '.claude', 'aitk', 'config.json')
85
85
  }
86
86
 
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
+
87
99
  /**
88
100
  * Every spelling a stamp has been written under, current first. The order is
89
101
  * the read order, so a target carrying more than one resolves to the newest.
90
102
  *
91
- * The fallback carries no end date. It costs two path reads on a command that
92
- * already touches the filesystem, and dropping it later is a second breaking
93
- * change aimed at exactly the targets that were slowest to migrate the first
94
- * time.
103
+ * The fallback carries no end date. It costs three path reads on a command
104
+ * that already touches the filesystem, and dropping any of them later is a
105
+ * second breaking change aimed at exactly the targets that were slowest to
106
+ * migrate the first time.
95
107
  */
96
108
  export function stampPaths(target: string): readonly string[] {
97
109
  return [
110
+ surfaceStampPath(target),
98
111
  stampPath(target),
99
112
  retiredNameStampPath(target),
100
113
  legacyStampPath(target),
@@ -107,7 +120,9 @@ export function stampPaths(target: string): readonly string[] {
107
120
  * there is nothing to migrate off of.
108
121
  */
109
122
  export function isLegacyStamped(target: string): boolean {
110
- if (existsSync(stampPath(target))) return false
123
+ if (existsSync(surfaceStampPath(target)) || existsSync(stampPath(target))) {
124
+ return false
125
+ }
111
126
  return (
112
127
  existsSync(retiredNameStampPath(target)) ||
113
128
  existsSync(legacyStampPath(target))