@erclx/canon 4.72.1 → 4.74.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.
Files changed (42) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/plan-feature/SKILL.md +2 -2
  3. package/claude/skills/plan-groundwork/SKILL.md +6 -5
  4. package/claude/skills/plan-intake/SKILL.md +1 -1
  5. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
  6. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -1
  7. package/claude/skills/session-relay/REQUIREMENT.md +2 -0
  8. package/claude/skills/session-relay/SKILL.md +1 -0
  9. package/claude/skills/task-board/SKILL.md +37 -3
  10. package/docs/agents/commands.md +1 -0
  11. package/docs/agents/index.md +1 -1
  12. package/docs/agents/install-and-sync.md +10 -6
  13. package/docs/agents/records.md +29 -9
  14. package/docs/agents/tasks.md +2 -0
  15. package/docs/target-projects.md +5 -1
  16. package/governance/rules/core/091-channel.md +13 -0
  17. package/package.json +1 -1
  18. package/src/claude/skills-reach.ts +13 -3
  19. package/src/commands/context.ts +7 -4
  20. package/src/commands/design.ts +6 -1
  21. package/src/commands/records.ts +138 -0
  22. package/src/commands/transcripts.ts +20 -3
  23. package/src/context/architecture.ts +15 -8
  24. package/src/context/citations.ts +31 -6
  25. package/src/context/folders.ts +27 -8
  26. package/src/gate/measures.ts +17 -8
  27. package/src/init/plan.ts +8 -1
  28. package/src/init/steps.ts +17 -0
  29. package/src/intake/folder.ts +1 -1
  30. package/src/migrate/records.ts +11 -3
  31. package/src/records/backup.ts +110 -38
  32. package/src/records/ordinal.ts +243 -0
  33. package/src/records/validate.ts +28 -0
  34. package/src/surface-root.ts +101 -0
  35. package/src/sync/stamp.ts +20 -5
  36. package/src/tasks/answers.ts +10 -1
  37. package/src/transcripts/fetch.ts +31 -1
  38. package/standards/groundwork.md +1 -1
  39. package/standards/intake.md +1 -1
  40. package/standards/plan.md +1 -0
  41. package/tooling/base/configs/.husky/post-merge +27 -0
  42. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +5 -4
@@ -3,6 +3,13 @@ import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { BACKED_FOLDERS, pullRecords, pushRecords } from '@/records/backup'
5
5
  import { migrateRecord } from '@/records/migrate'
6
+ import {
7
+ type ClaimOutcome,
8
+ claimOrdinal,
9
+ highestOrdinal,
10
+ isOrdinalKind,
11
+ ORDINAL_KINDS,
12
+ } from '@/records/ordinal'
6
13
  import {
7
14
  type FolderSize,
8
15
  formatBytes,
@@ -40,6 +47,9 @@ const EXIT_FINDINGS = 2
40
47
  /** Returned when a record carries a known transform and `--write` was not passed. */
41
48
  const EXIT_MIGRATABLE = 2
42
49
 
50
+ /** Returned when `--claim` loses every retry to a collision. */
51
+ const EXIT_CONTENDED = 2
52
+
43
53
  interface ValidateCommandOptions {
44
54
  readonly json?: boolean
45
55
  readonly root?: string
@@ -51,6 +61,10 @@ interface MigrateCommandOptions extends ValidateCommandOptions {
51
61
  readonly write?: boolean
52
62
  }
53
63
 
64
+ interface OrdinalCommandOptions extends ValidateCommandOptions {
65
+ readonly claim?: boolean
66
+ }
67
+
54
68
  export function register(program: Command): void {
55
69
  const records = program
56
70
  .command('records')
@@ -140,6 +154,49 @@ export function register(program: Command): void {
140
154
  process.exitCode = await runMigrate(kind, opts)
141
155
  })
142
156
 
157
+ records
158
+ .command('ordinal')
159
+ .description(
160
+ 'Report or claim the next ordinal shared by intake and groundwork folders',
161
+ )
162
+ .argument('<kind>', `Ordinal-bearing folder: ${ORDINAL_KINDS.join(', ')}`)
163
+ .argument('<slug>', 'The kebab-case slug the new folder will carry')
164
+ .helpOption('-h, --help', 'Show this help message')
165
+ .option('--json', 'Add a machine-readable record on stdout')
166
+ .option(
167
+ '--claim',
168
+ 'Create the folder atomically instead of only reporting the ordinal',
169
+ )
170
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
171
+ .addHelpText(
172
+ 'after',
173
+ [
174
+ '',
175
+ 'intake and groundwork folders share one ordinal sequence, so this reads',
176
+ 'both .canon/intake/ and .canon/groundwork/ regardless of which kind was',
177
+ 'asked for.',
178
+ '',
179
+ 'Exit codes:',
180
+ ' 0 reported the next ordinal, or --claim created the folder',
181
+ ' 1 refused, with the reason on stderr or in the JSON record',
182
+ ' 2 --claim lost every retry to a collision',
183
+ '',
184
+ 'Without --claim this only reports, so two sessions reading at once can',
185
+ 'still report the same number. --claim resolves that by creating the',
186
+ 'folder as part of the same act, retrying past a losing race rather than',
187
+ 'reporting one.',
188
+ '',
189
+ 'Examples:',
190
+ ' canon records ordinal intake my-topic',
191
+ ' canon records ordinal groundwork my-topic --claim',
192
+ ' canon records ordinal groundwork my-topic --claim --json',
193
+ '',
194
+ ].join('\n'),
195
+ )
196
+ .action(async (kind: string, slug: string, opts: OrdinalCommandOptions) => {
197
+ process.exitCode = await runOrdinal(kind, slug, opts)
198
+ })
199
+
143
200
  records
144
201
  .command('size')
145
202
  .description('Report what each record folder holds and how much is recent')
@@ -726,3 +783,84 @@ export function migrateExitCode(
726
783
  if (!write) return EXIT_MIGRATABLE
727
784
  return refused.length > 0 ? 1 : 0
728
785
  }
786
+
787
+ async function runOrdinal(
788
+ kind: string,
789
+ slug: string,
790
+ opts: OrdinalCommandOptions,
791
+ ): Promise<number> {
792
+ const emitJson = opts.json ?? false
793
+
794
+ if (!isOrdinalKind(kind)) {
795
+ return reportRefusal(
796
+ 'canon records ordinal',
797
+ {
798
+ reason: 'unknown-kind',
799
+ message: `Not an ordinal-bearing kind: ${kind}. Expected one of: ${ORDINAL_KINDS.join(', ')}.`,
800
+ },
801
+ emitJson,
802
+ )
803
+ }
804
+
805
+ const root = opts.root ?? (await mainWorktreeRoot())
806
+ const claim = opts.claim ?? false
807
+
808
+ if (!claim) {
809
+ const next = String((await highestOrdinal(root)) + 1).padStart(2, '0')
810
+
811
+ if (emitJson) {
812
+ process.stdout.write(
813
+ `${JSON.stringify({ ok: true, root, kind, slug, ordinal: next, claimed: false })}\n`,
814
+ )
815
+ } else {
816
+ intro('canon records ordinal')
817
+ logStep('Next')
818
+ logInfo(`${next}-${slug} (report only, pass --claim to create it)`)
819
+ outro()
820
+ }
821
+
822
+ return 0
823
+ }
824
+
825
+ return reportOrdinal(root, await claimOrdinal(root, kind, slug), emitJson)
826
+ }
827
+
828
+ function reportOrdinal(
829
+ root: string,
830
+ outcome: ClaimOutcome,
831
+ emitJson: boolean,
832
+ ): number {
833
+ if (!outcome.ok) {
834
+ if (emitJson) {
835
+ process.stderr.write(`${outcome.message}\n`)
836
+ process.stdout.write(
837
+ `${JSON.stringify({
838
+ ok: false,
839
+ reason: outcome.reason,
840
+ message: outcome.message,
841
+ lastOrdinal: outcome.lastOrdinal,
842
+ })}\n`,
843
+ )
844
+ return EXIT_CONTENDED
845
+ }
846
+
847
+ intro('canon records ordinal')
848
+ logStep('Refused')
849
+ logError(outcome.message)
850
+ outro()
851
+ return EXIT_CONTENDED
852
+ }
853
+
854
+ if (emitJson) {
855
+ process.stdout.write(
856
+ `${JSON.stringify({ root, ...outcome, claimed: true })}\n`,
857
+ )
858
+ } else {
859
+ intro('canon records ordinal')
860
+ logStep('Claimed')
861
+ logInfo(outcome.path)
862
+ outro()
863
+ }
864
+
865
+ return 0
866
+ }
@@ -1,24 +1,41 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
+ import { recordDir } from '@/record-root'
3
4
  import { ensureYtDlp, fetchOne } from '@/transcripts/fetch'
4
5
  import { palette } from '@/ui'
6
+ import { mainWorktreeRoot } from '@/worktree'
5
7
 
6
8
  interface TranscriptOptions {
7
- out: string
9
+ out?: string
8
10
  keepTimestamps?: boolean
9
11
  }
10
12
 
13
+ /**
14
+ * A caller-supplied `--out` resolves against the CWD, since naming a path
15
+ * explicitly opts out of the backed default. With none, the destination is
16
+ * the backed `transcripts` record folder at the main worktree root, which
17
+ * `canon records push` and `canon records pull` carry along with the rest.
18
+ */
19
+ export async function resolveOutDir(opts: TranscriptOptions): Promise<string> {
20
+ return opts.out
21
+ ? resolve(process.cwd(), opts.out)
22
+ : recordDir(await mainWorktreeRoot(), 'transcripts')
23
+ }
24
+
11
25
  export function register(program: Command): void {
12
26
  program
13
27
  .command('transcripts <url>')
14
28
  .description('Fetch a YouTube transcript with metadata frontmatter')
15
- .option('-o, --out <path>', 'Output directory', 'transcripts')
29
+ .option(
30
+ '-o, --out <path>',
31
+ 'Output directory, defaulting to the backed transcripts record folder',
32
+ )
16
33
  .option(
17
34
  '--keep-timestamps',
18
35
  'Prefix each line with [mm:ss] instead of prose',
19
36
  )
20
37
  .action(async (url: string, opts: TranscriptOptions) => {
21
- const outDir = resolve(process.cwd(), opts.out)
38
+ const outDir = await resolveOutDir(opts)
22
39
  const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
23
40
  process.stderr.write(
24
41
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}canon transcripts${NC}\n`,
@@ -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
 
package/src/init/plan.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const SKIPPABLE_DOMAINS = ['wiki', 'governance'] as const
1
+ export const SKIPPABLE_DOMAINS = ['wiki', 'governance', 'records'] as const
2
2
 
3
3
  export type SkippableDomain = (typeof SKIPPABLE_DOMAINS)[number]
4
4
 
@@ -104,6 +104,13 @@ export function planInit(flags: InitFlags): InitPlan {
104
104
  })
105
105
  }
106
106
 
107
+ if (!flags.skip.skipped.has('records')) {
108
+ preview.push({
109
+ level: 'info',
110
+ text: 'records (one-time backup setup notice)',
111
+ })
112
+ }
113
+
107
114
  const total = preview.filter((line) => line.level === 'info').length
108
115
 
109
116
  return { preview, total }
package/src/init/steps.ts CHANGED
@@ -4,6 +4,13 @@ import type { DomainStep } from '@/init/run'
4
4
  /** Builds the child-process invocation for one domain. */
5
5
  export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
6
6
 
7
+ /**
8
+ * States the same fact as `docs/target-projects.md`'s backup section in its
9
+ * own words. Keep the two in step by hand; nothing compares them.
10
+ */
11
+ const RECORDS_SETUP_NOTICE =
12
+ "No private repository to push to yet. Run 'canon records push' once one exists to print the one-time setup command, or --skip records to silence this."
13
+
7
14
  /**
8
15
  * Orders the domains an init installs. Base tooling seeds the files the later
9
16
  * domains install alongside, so the sequence is part of the contract rather
@@ -56,6 +63,16 @@ export function buildSteps(
56
63
  })
57
64
  }
58
65
 
66
+ if (!flags.skip.skipped.has('records')) {
67
+ // `skip` here means there is nothing to run non-interactively, not that
68
+ // the caller opted out, unlike every other push of this kind above.
69
+ steps.push({
70
+ kind: 'skip',
71
+ label: 'Records backup',
72
+ notice: RECORDS_SETUP_NOTICE,
73
+ })
74
+ }
75
+
59
76
  return steps
60
77
  }
61
78
 
@@ -138,7 +138,7 @@ function matchSlug(names: readonly string[], slug: string): SlugMatch {
138
138
  return { kind: 'none' }
139
139
  }
140
140
 
141
- function extractOrdinal(name: string): string {
141
+ export function extractOrdinal(name: string): string {
142
142
  return /^\d{2,}-/.exec(name)?.[0].slice(0, -1) ?? ''
143
143
  }
144
144
 
@@ -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