@erclx/canon 4.72.0 → 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.0",
4
+ "version": "4.73.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -88,7 +88,7 @@ A proposal outlives its own diff. The source moves under it, so an approved chan
88
88
 
89
89
  What remains is the record of what changed and why, which is the half no commit message holds. The folder stays where it is, beside every earlier pass, so a later one reads what an earlier one settled instead of asking it again.
90
90
 
91
- The folder is gitignored and unbacked. It carries decision state rather than generated data, which is why `src/records/backup.ts` names it in `BACKED_FOLDERS`, but nothing else about its contents is enforced.
91
+ The folder is gitignored, and backed wherever a records remote is configured. It carries decision state rather than generated data, which is why `src/records/backup.ts` names it in `BACKED_FOLDERS` so `canon records push` and `canon records pull` carry it there, refusing with `no-remote` otherwise, but nothing else about its contents is enforced.
92
92
 
93
93
  ## Retrieval
94
94
 
@@ -25,7 +25,7 @@ A track that closes with several separable findings writes only one task, and th
25
25
  - Place the closing task's row through `task-board` Step 4 rather than writing `priority.md` or `backlog.md` directly
26
26
  - Link every claim about a source outside the project, and list an unread source as a lead rather than citing it
27
27
  - Put a fixture a headless run is pointed at outside the repository
28
- - Write the next-session file self-contained, since the folder is unbacked and dies with the machine
28
+ - Write the next-session file self-contained, since backing the folder through `canon records push` and `canon records pull` holds only where a records remote is configured, protects only against the machine being lost even there, and never against a compaction dropping this session's reasoning before it has pushed
29
29
 
30
30
  ## Must not
31
31
 
@@ -26,7 +26,7 @@ Read `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md` before writing any file
26
26
  - A `draft-and-pick` run invoked from inside a live track follows its own branch for this, writing to `evidence/` here rather than `.canon/tmp/<slug>/`.
27
27
  - Reading is not restricted. External research is in scope, so read documentation, comparable projects, and papers whenever a live question needs them.
28
28
  - Every claim about a source outside the project carries a link to it. A source found and not read is listed as a lead and is never cited.
29
- - Treat the folder as gitignored and unbacked. It dies with the machine, so `07-next-session.md` repeats what it needs instead of pointing at its siblings.
29
+ - Treat the folder as gitignored, and backed only where a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there and refuse with `no-remote` otherwise, and neither protects against a compaction dropping this session's own reasoning before anyone has pushed, so `07-next-session.md` repeats what it needs instead of pointing at its siblings.
30
30
 
31
31
  ## Running a spike
32
32
 
@@ -32,7 +32,7 @@ Using the wrong one fails in two shapes. Intake on a question that needs measuri
32
32
  - Write only inside `.canon/intake/<nn>-<slug>/`. A plan file, a task file, a source change, a standard, and a rule all live outside that folder, so this one rule forbids every one of them.
33
33
  - There is no exception. Promoting an item onto the board runs through `task-board` after the operator has answered, which is a separate invocation.
34
34
  - Reading is unrestricted inside the project. Measuring is the work.
35
- - Treat the folder as gitignored and unbacked. No check reaches its contents, so every rule stated here holds only while a session reads it.
35
+ - Treat the folder as gitignored, and backed only where a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there and refuse with `no-remote` otherwise, and neither protects against a compaction dropping a session's reasoning before anyone has pushed. No check reaches its contents, so every rule stated here holds only while a session reads it.
36
36
 
37
37
  Nothing outside this body carries the write-scope floor, and no path-scoped rule can. A misrouted write lands on a path the folder's glob never matches, so the rule that ships beside the intake standard carries the item format and the answer contract instead, for a session editing the folder with this skill unloaded.
38
38
 
@@ -81,7 +81,7 @@ Write `00-overview.md` last, once the clusters are filed and the counts are real
81
81
 
82
82
  Each open question in the index is a labeled markdown link to its owning item's heading anchor. The index points and the item owns, so no answer slot appears in the index. One question in two answerable places has no rule for which wins, and retrieval walks item headings, so an answer typed into the index is found by nothing and lost silently.
83
83
 
84
- Add `99-next-session.md` only where the pass ends holding context no cluster file carries, such as a dump half filed or a measurement that has to be redone. Write it self-contained, since the folder is unbacked and the conversation behind it compacts away.
84
+ Add `99-next-session.md` only where the pass ends holding context no cluster file carries, such as a dump half filed or a measurement that has to be redone. Write it self-contained, since a compaction drops the conversation behind it regardless of whether the folder is backed.
85
85
 
86
86
  ## Output
87
87
 
@@ -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.0",
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))
@@ -7,7 +7,7 @@ description: Folder layout, ordinal naming, reserved numbering, frontmatter and
7
7
 
8
8
  Applies to a groundwork track at `.canon/groundwork/<nn>-<slug>/`. A track measures one question that has to be settled before anyone can plan against it. The numbering inside the folder is the table of contents, so a reader opens the folder and knows where to start and what follows without an index maintained inside each file.
9
9
 
10
- The folder is gitignored and unbacked. No check reaches its contents and no history recovers a deleted one, so every rule here holds only while a session reads it, and the handoff file has to be self-contained.
10
+ The folder is gitignored, and backed wherever a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there, refusing with `no-remote` where no remote is set, and neither protects against a compaction dropping a session's reasoning before anyone has pushed. No check reaches its contents, so every rule here holds only while a session reads it, and the handoff file has to be self-contained.
11
11
 
12
12
  ## Scope
13
13
 
@@ -7,7 +7,7 @@ description: Folder layout, ordinal naming, reserved index number, frontmatter a
7
7
 
8
8
  Applies to an intake folder at `.canon/intake/<nn>-<slug>/`. One folder holds one dump, filed by domain, and every finding in it is an item carrying a measured problem, one proposed fix, and a verdict.
9
9
 
10
- The folder is gitignored and unbacked. No check reaches its contents, so the shape below survives only by being read.
10
+ The folder is gitignored, and backed wherever a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there, refuse with `no-remote` where it is not, and neither protects against a compaction dropping a session's reasoning before anyone has pushed. No check reaches its contents, so the shape below survives only by being read.
11
11
 
12
12
  ## Scope
13
13
 
@@ -7,7 +7,7 @@ description: Filename and type prefix, frontmatter, the body shape per type, lin
7
7
 
8
8
  Applies to a memory entry at `.canon/memory/<type>-<slug>.md`. One file holds one rule or one fact, written at the end of the session that produced it and read by a session that holds none of it. Which surface owns a given fact is settled before an entry is written at all, and that routing is project policy rather than a shape rule.
9
9
 
10
- The folder is gitignored and unbacked. Nothing recovers a deleted entry, which is why the retire step below is a move rather than a cleanup.
10
+ The folder is gitignored, and backed wherever a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there, refuse with `no-remote` where it is not, and protect nothing against an entry deleted before anyone has pushed. That is why the retire step below is a move rather than a cleanup: a retired entry stays readable regardless of push timing, where a deleted one is gone the moment nothing has captured it yet.
11
11
 
12
12
  ## Scope
13
13
 
@@ -98,7 +98,7 @@ Link a related entry as `[[name]]`, where `name` is the target's filename stem w
98
98
 
99
99
  - Check the folder for an entry on the same topic before writing a new one, and update that entry in place when one exists. Two entries on one rule disagree the moment either is edited.
100
100
  - Rewrite an entry the tree has moved under rather than appending a second passage narrating the change. A reader cannot tell which of two claims is current.
101
- - Never delete an entry. Retire one by moving it to an archive under its own name, because the folder is unbacked and a bulk judgment has no undo behind it.
101
+ - Never delete an entry. Retire one by moving it to an archive under its own name, because a bulk judgment made before the next push has no undo behind it, and moving keeps the record where deleting would not.
102
102
  - Treat the folder as a holding pen rather than a destination. An entry whose rule belongs on a durable surface is promoted there and retired here, and the rest is what the pen is for.
103
103
 
104
104
  The catalog is generated from sibling frontmatter rather than authored. Never hand-edit it, since the next regeneration discards whatever was added by hand.
package/standards/plan.md CHANGED
@@ -7,7 +7,7 @@ description: Filename and slug, required sections, the suggested-and-answer cont
7
7
 
8
8
  Applies to a feature plan at `.canon/plans/feature-<slug>.md`. One file holds one concern, written before implementation starts and read by whatever executes it, so it has to carry the scope without the conversation that produced it.
9
9
 
10
- The folder is gitignored and unbacked. Nothing recovers a deleted plan, which is why the archive step below is a move rather than a cleanup.
10
+ The folder is gitignored, and backed wherever a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there, refuse with `no-remote` where it is not, and protect nothing against a plan deleted before anyone has pushed. That is why the archive step below is a move rather than a cleanup.
11
11
 
12
12
  ## Scope
13
13