@erclx/canon 4.3.0 → 4.5.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 (46) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-markdown-propose/REQUIREMENT.md +0 -1
  3. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +34 -9
  4. package/claude/skills/repo-metadata/REQUIREMENT.md +37 -0
  5. package/claude/skills/repo-metadata/SKILL.md +51 -0
  6. package/docs/agents/audits.md +6 -6
  7. package/docs/agents/commands.md +64 -62
  8. package/docs/agents/context-audit.md +2 -2
  9. package/docs/agents/index.md +1 -1
  10. package/docs/agents/records.md +17 -7
  11. package/docs/agents/sandbox.md +3 -1
  12. package/docs/agents/tasks.md +36 -1
  13. package/docs/operating-model.md +1 -1
  14. package/package.json +1 -1
  15. package/scripts/core/check-ignore-parity.sh +9 -3
  16. package/scripts/lib/sandbox-dispatch.sh +182 -0
  17. package/src/audits/baseline.ts +1 -1
  18. package/src/claude/cases/misc.ts +4 -0
  19. package/src/cli.ts +4 -0
  20. package/src/commands/claude.ts +7 -1
  21. package/src/commands/context.ts +3 -3
  22. package/src/commands/design.ts +6 -1
  23. package/src/commands/feedback.ts +5 -1
  24. package/src/commands/gov.ts +2 -1
  25. package/src/commands/repo.ts +393 -0
  26. package/src/commands/slides.ts +6 -1
  27. package/src/commands/tasks.ts +100 -0
  28. package/src/context/citations.ts +16 -5
  29. package/src/context/folders.ts +18 -8
  30. package/src/gate/measures.ts +1 -1
  31. package/src/intake/folder.ts +2 -1
  32. package/src/paths.ts +16 -0
  33. package/src/record-root.ts +134 -0
  34. package/src/records/backup.ts +40 -22
  35. package/src/records/size.ts +12 -7
  36. package/src/records/validate.ts +35 -17
  37. package/src/repo/metadata.ts +206 -0
  38. package/src/tasks/answers.ts +202 -0
  39. package/src/tasks/archive.ts +33 -30
  40. package/src/teach/workspace.ts +2 -1
  41. package/tooling/claude/manifest.toml +1 -1
  42. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +8 -1
  43. package/tooling/claude/seeds/.claude/hooks/memory-index.sh +28 -11
  44. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +12 -3
  45. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +4 -0
  46. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +28 -10
@@ -0,0 +1,393 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import { execa } from 'execa'
4
+ import { gitEnv } from '@/git-env'
5
+ import {
6
+ compareMetadata,
7
+ type CurrentMetadata,
8
+ isValidTopic,
9
+ type MetadataDiff,
10
+ proposeMetadata,
11
+ } from '@/repo/metadata'
12
+ import {
13
+ intro,
14
+ logAdd,
15
+ logInfo,
16
+ logRemove,
17
+ logStep,
18
+ logWarn,
19
+ outro,
20
+ } from '@/ui'
21
+
22
+ const GH_TIMEOUT_MS = 30_000
23
+
24
+ type SourceRefusal = 'gh-missing' | 'gh-failed'
25
+ type ApplyRefusal =
26
+ | SourceRefusal
27
+ | 'no-changes'
28
+ | 'empty-topics'
29
+ | 'invalid-topics'
30
+ | 'wrong-repo'
31
+
32
+ const REFUSALS: Record<ApplyRefusal, string> = {
33
+ 'gh-missing': 'gh is not on the path, so no remote could be read.',
34
+ 'gh-failed':
35
+ 'gh could not read this repository. Check the remote and gh auth status.',
36
+ 'no-changes':
37
+ 'Nothing to apply. Pass --description, --homepage, or --topics with the answered value.',
38
+ 'empty-topics':
39
+ '--topics carried no usable topic, and this command never reads that as a request to remove every topic the remote carries. Pass the full desired set, or omit the flag to leave topics untouched.',
40
+ 'invalid-topics':
41
+ '--topics carried an entry GitHub does not accept as a topic (lowercase letters, digits, and internal hyphens only). This command never narrows the desired set silently, since dropping it changes what the remaining diff removes.',
42
+ 'wrong-repo':
43
+ 'The remote --root resolved to is not the one --repo named. This command never writes to a repository the invocation did not explicitly confirm.',
44
+ }
45
+
46
+ interface ProposeOptions {
47
+ readonly root?: string
48
+ readonly json?: boolean
49
+ }
50
+
51
+ interface ApplyOptions {
52
+ readonly description?: string
53
+ readonly homepage?: string
54
+ readonly topics?: string
55
+ readonly repo: string
56
+ readonly root?: string
57
+ readonly json?: boolean
58
+ }
59
+
60
+ type CurrentRead =
61
+ | {
62
+ readonly kind: 'read'
63
+ readonly current: CurrentMetadata
64
+ readonly nameWithOwner: string
65
+ }
66
+ | { readonly kind: 'refused'; readonly reason: SourceRefusal }
67
+
68
+ /** Reads what the remote already carries. The one network call this domain makes to read. */
69
+ async function readCurrent(cwd: string): Promise<CurrentRead> {
70
+ if (Bun.which('gh') === null) return { kind: 'refused', reason: 'gh-missing' }
71
+
72
+ try {
73
+ const result = await execa(
74
+ 'gh',
75
+ [
76
+ 'repo',
77
+ 'view',
78
+ '--json',
79
+ 'description,homepageUrl,repositoryTopics,nameWithOwner',
80
+ ],
81
+ { cwd, timeout: GH_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
82
+ )
83
+ const row = JSON.parse(result.stdout) as {
84
+ description?: string
85
+ homepageUrl?: string
86
+ repositoryTopics?: readonly { name: string }[]
87
+ nameWithOwner?: string
88
+ }
89
+ return {
90
+ kind: 'read',
91
+ current: {
92
+ description: row.description ?? '',
93
+ homepage: row.homepageUrl ?? '',
94
+ topics: (row.repositoryTopics ?? []).map((topic) => topic.name),
95
+ },
96
+ nameWithOwner: row.nameWithOwner ?? '',
97
+ }
98
+ } catch {
99
+ return { kind: 'refused', reason: 'gh-failed' }
100
+ }
101
+ }
102
+
103
+ export function register(program: Command): void {
104
+ const repo = program
105
+ .command('repo')
106
+ .description('Read and write this repository’s own remote metadata')
107
+ .helpOption('-h, --help', 'Show this help message')
108
+
109
+ const metadata = repo
110
+ .command('metadata')
111
+ .description('The About description, homepage, and topics GitHub shows')
112
+ .helpOption('-h, --help', 'Show this help message')
113
+
114
+ metadata
115
+ .command('propose')
116
+ .description(
117
+ 'Compare a locally computed description, homepage, and topic set against the remote',
118
+ )
119
+ .helpOption('-h, --help', 'Show this help message')
120
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
121
+ .option('--json', 'Add a machine-readable record on stdout')
122
+ .addHelpText(
123
+ 'after',
124
+ [
125
+ '',
126
+ "Computes from the tree alone: a description from the README's opening",
127
+ 'line past its title and badges, and a homepage and topics from',
128
+ "package.json's own homepage and keywords fields. A field neither file",
129
+ 'declares is never diffed, so an undeclared topic set never reads as a',
130
+ 'proposal to clear what the remote already carries.',
131
+ '',
132
+ 'This is the read half. It never writes. Run `canon repo metadata apply`',
133
+ 'with the answered fields once a proposal is worth taking.',
134
+ '',
135
+ 'Exit codes:',
136
+ ' 0 the remote already carries what this run computed',
137
+ ' 1 refused, with the reason on stderr or in the JSON record',
138
+ ' 2 a difference was found',
139
+ '',
140
+ 'Examples:',
141
+ ' canon repo metadata propose',
142
+ ' canon repo metadata propose --json',
143
+ '',
144
+ ].join('\n'),
145
+ )
146
+ .action(async (opts: ProposeOptions) => {
147
+ process.exitCode = await runPropose(opts)
148
+ })
149
+
150
+ metadata
151
+ .command('apply')
152
+ .description(
153
+ 'Write an explicitly supplied description, homepage, or topic set to the remote',
154
+ )
155
+ .helpOption('-h, --help', 'Show this help message')
156
+ .requiredOption(
157
+ '--repo <owner/name>',
158
+ 'Repository this run is allowed to write to, checked against what gh resolves',
159
+ )
160
+ .option('--description <text>', 'About text to write')
161
+ .option('--homepage <url>', 'Homepage URL to write')
162
+ .option(
163
+ '--topics <list>',
164
+ 'Comma-separated topic set to write, replacing what the remote carries',
165
+ )
166
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
167
+ .option('--json', 'Add a machine-readable record on stdout')
168
+ .addHelpText(
169
+ 'after',
170
+ [
171
+ '',
172
+ 'Takes the answered value for each field directly rather than',
173
+ 're-running propose, so a change this writes is always one a person or',
174
+ 'a second invocation already read and confirmed. --topics is the full',
175
+ 'desired set. gh only takes an add and a remove list, so this reads the',
176
+ 'current set once to compute both. An entry shaped like something other',
177
+ 'than a GitHub topic refuses the whole run rather than being dropped.',
178
+ '',
179
+ '--repo is required and takes no default, since --root silently',
180
+ 'resolving to the caller’s own cwd is what turned a verification run',
181
+ 'against this exact command into a live write against a public remote.',
182
+ 'The value is checked against what gh resolves for --root, so the',
183
+ 'write refuses on any repository the invocation did not name aloud.',
184
+ '',
185
+ 'Exit codes:',
186
+ ' 0 the write succeeded, or the remote already matched',
187
+ ' 1 refused, with the reason on stderr or in the JSON record',
188
+ '',
189
+ 'Examples:',
190
+ ' canon repo metadata apply --repo erclx/canon --description "One source for conventions."',
191
+ ' canon repo metadata apply --repo erclx/canon --topics cli-tool,governance,standards',
192
+ '',
193
+ ].join('\n'),
194
+ )
195
+ .action(async (opts: ApplyOptions) => {
196
+ process.exitCode = await runApply(opts)
197
+ })
198
+ }
199
+
200
+ function refuse(
201
+ reason: ApplyRefusal,
202
+ emitJson: boolean,
203
+ root: string,
204
+ detail?: string,
205
+ ): number {
206
+ const message =
207
+ detail === undefined ? REFUSALS[reason] : `${REFUSALS[reason]} ${detail}`
208
+
209
+ logStep('Refused')
210
+ logWarn(message)
211
+ outro()
212
+
213
+ if (emitJson) {
214
+ process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
215
+ }
216
+ return 1
217
+ }
218
+
219
+ async function runPropose(opts: ProposeOptions): Promise<number> {
220
+ const root = resolve(opts.root ?? process.cwd())
221
+ const emitJson = opts.json ?? false
222
+
223
+ intro('canon repo metadata propose')
224
+
225
+ const [proposal, currentRead] = await Promise.all([
226
+ proposeMetadata(root),
227
+ readCurrent(root),
228
+ ])
229
+
230
+ if (currentRead.kind === 'refused') {
231
+ return refuse(currentRead.reason, emitJson, root)
232
+ }
233
+
234
+ const diff: MetadataDiff = compareMetadata(currentRead.current, proposal)
235
+ const changed = Object.keys(diff).length > 0
236
+
237
+ logStep('Repository')
238
+ logInfo(currentRead.nameWithOwner)
239
+
240
+ logStep('Computed')
241
+ if (
242
+ proposal.description === undefined &&
243
+ proposal.homepage === undefined &&
244
+ proposal.topics === undefined
245
+ ) {
246
+ logInfo('nothing local resolved a description, a homepage, or topics')
247
+ } else {
248
+ if (proposal.description !== undefined) {
249
+ logInfo(`description: ${proposal.description}`)
250
+ }
251
+ if (proposal.homepage !== undefined)
252
+ logInfo(`homepage: ${proposal.homepage}`)
253
+ if (proposal.topics !== undefined) {
254
+ logInfo(`topics: ${proposal.topics.join(', ')}`)
255
+ }
256
+ }
257
+
258
+ logStep(changed ? 'Difference' : 'No difference')
259
+ if (!changed) {
260
+ logInfo('the remote already carries what this run computed')
261
+ } else {
262
+ if (diff.description !== undefined) {
263
+ logWarn(
264
+ `description: "${diff.description.current}" → "${diff.description.proposed}"`,
265
+ )
266
+ }
267
+ if (diff.homepage !== undefined) {
268
+ logWarn(
269
+ `homepage: "${diff.homepage.current}" → "${diff.homepage.proposed}"`,
270
+ )
271
+ }
272
+ if (diff.topics !== undefined) {
273
+ for (const topic of diff.topics.added) logAdd(`topic: ${topic}`)
274
+ for (const topic of diff.topics.removed) logRemove(`topic: ${topic}`)
275
+ }
276
+ logInfo(
277
+ 'Run `canon repo metadata apply` with the answered fields. This run writes nothing.',
278
+ )
279
+ }
280
+
281
+ outro()
282
+
283
+ if (emitJson) {
284
+ process.stdout.write(
285
+ `${JSON.stringify({
286
+ root,
287
+ repo: currentRead.nameWithOwner,
288
+ current: currentRead.current,
289
+ proposal,
290
+ diff,
291
+ })}\n`,
292
+ )
293
+ }
294
+
295
+ return changed ? 2 : 0
296
+ }
297
+
298
+ async function runApply(opts: ApplyOptions): Promise<number> {
299
+ const root = resolve(opts.root ?? process.cwd())
300
+ const emitJson = opts.json ?? false
301
+
302
+ intro('canon repo metadata apply')
303
+
304
+ if (
305
+ opts.description === undefined &&
306
+ opts.homepage === undefined &&
307
+ opts.topics === undefined
308
+ ) {
309
+ return refuse('no-changes', emitJson, root)
310
+ }
311
+
312
+ let desired: ReadonlySet<string> | undefined
313
+ if (opts.topics !== undefined) {
314
+ const entries = opts.topics
315
+ .split(',')
316
+ .map((topic) => topic.trim().toLowerCase())
317
+ .filter((topic) => topic !== '')
318
+ if (entries.length === 0) return refuse('empty-topics', emitJson, root)
319
+
320
+ const invalid = entries.filter((topic) => !isValidTopic(topic))
321
+ if (invalid.length > 0) {
322
+ return refuse(
323
+ 'invalid-topics',
324
+ emitJson,
325
+ root,
326
+ `Invalid: ${invalid.join(', ')}`,
327
+ )
328
+ }
329
+
330
+ desired = new Set(entries)
331
+ }
332
+
333
+ // Read ahead of every write, never only for --topics, since this is the
334
+ // one call that confirms --repo names the remote --root actually resolved
335
+ // to. A write skipping it on a description-only or homepage-only run would
336
+ // reopen the gap --repo exists to close.
337
+ const currentRead = await readCurrent(root)
338
+ if (currentRead.kind === 'refused')
339
+ return refuse(currentRead.reason, emitJson, root)
340
+ if (currentRead.nameWithOwner !== opts.repo) {
341
+ return refuse('wrong-repo', emitJson, root)
342
+ }
343
+
344
+ const args = ['repo', 'edit']
345
+ if (opts.description !== undefined)
346
+ args.push('--description', opts.description)
347
+ if (opts.homepage !== undefined) args.push('--homepage', opts.homepage)
348
+
349
+ if (desired !== undefined) {
350
+ const currentSet = new Set(currentRead.current.topics)
351
+ const toAdd = [...desired].filter((topic) => !currentSet.has(topic))
352
+ const toRemove = currentRead.current.topics.filter(
353
+ (topic) => !desired.has(topic),
354
+ )
355
+
356
+ if (toAdd.length > 0) args.push('--add-topic', toAdd.join(','))
357
+ if (toRemove.length > 0) args.push('--remove-topic', toRemove.join(','))
358
+ }
359
+
360
+ if (args.length === 2) {
361
+ logStep('Applied')
362
+ logInfo('the remote already matches every field supplied. Nothing written.')
363
+ outro()
364
+
365
+ if (emitJson) {
366
+ process.stdout.write(`${JSON.stringify({ root, written: false })}\n`)
367
+ }
368
+ return 0
369
+ }
370
+
371
+ if (Bun.which('gh') === null) return refuse('gh-missing', emitJson, root)
372
+
373
+ try {
374
+ await execa('gh', args, {
375
+ cwd: root,
376
+ timeout: GH_TIMEOUT_MS,
377
+ env: gitEnv(),
378
+ extendEnv: false,
379
+ })
380
+ } catch {
381
+ return refuse('gh-failed', emitJson, root)
382
+ }
383
+
384
+ logStep('Applied')
385
+ logInfo('gh repo edit wrote the supplied fields to the remote.')
386
+ outro()
387
+
388
+ if (emitJson) {
389
+ process.stdout.write(`${JSON.stringify({ root, written: true })}\n`)
390
+ }
391
+
392
+ return 0
393
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { creationRel } from '@/record-root'
4
5
  import { LAYOUTS } from '@/slides/layouts'
5
6
  import { openDeck } from '@/slides/open'
6
7
  import { renderSlidesDoc } from '@/slides/render'
@@ -16,7 +17,11 @@ export function register(program: Command): void {
16
17
  .command('render')
17
18
  .description('Render a SLIDES.md source into a PowerPoint deck')
18
19
  .option('-s, --source <path>', 'Source SLIDES.md path', '.claude/SLIDES.md')
19
- .option('-o, --out <path>', 'Output directory', '.claude/review/slides')
20
+ .option(
21
+ '-o, --out <path>',
22
+ 'Output directory',
23
+ creationRel('review', 'slides'),
24
+ )
20
25
  .option('-v, --variant <variant>', 'Override variant (light or dark)')
21
26
  .option(
22
27
  '-m, --mirror <path>',
@@ -1,5 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
+ import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
3
4
  import {
4
5
  type ArchiveOutcome,
5
6
  archiveTask,
@@ -54,6 +55,11 @@ interface CitationsCommandOptions {
54
55
  readonly root?: string
55
56
  }
56
57
 
58
+ interface AnswersCommandOptions {
59
+ readonly json?: boolean
60
+ readonly root?: string
61
+ }
62
+
57
63
  interface PullRequestCommandOptions {
58
64
  readonly json?: boolean
59
65
  readonly plan?: string
@@ -173,6 +179,42 @@ export function register(program: Command): void {
173
179
  process.exitCode = await runCitations(task, opts)
174
180
  })
175
181
 
182
+ tasks
183
+ .command('plan-answers')
184
+ .description('Report whether a plan still waits on the operator to answer')
185
+ .argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
186
+ .helpOption('-h, --help', 'Show this help message')
187
+ .option('--json', 'Emit a machine-readable record on stdout')
188
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
189
+ .addHelpText(
190
+ 'after',
191
+ [
192
+ '',
193
+ 'Exit codes:',
194
+ ' 0 the plan is launchable',
195
+ ' 1 refused as no-plan, archived, or bad-input',
196
+ ' 2 the plan waits on the operator, and open names every slot',
197
+ '',
198
+ 'A blank Answer accepts the Suggested line above it, so only',
199
+ '`- Suggested: needs your call, <why>` over an empty slot is a stop.',
200
+ 'It reports and never writes. Branch on launchable rather than on the',
201
+ 'exit code, which a shell function wrapping canon can flatten to zero.',
202
+ '',
203
+ 'A relative path resolves against the project root first and against',
204
+ '.claude/tasks/ second, so the ../plans/ link a board row writes works.',
205
+ '',
206
+ 'Examples:',
207
+ ' canon tasks plan-answers dispatch-answer-gate',
208
+ ' canon tasks plan-answers .claude/plans/feature-dispatch-answer-gate.md',
209
+ ' canon tasks plan-answers ../plans/feature-dispatch-answer-gate.md',
210
+ ' canon tasks plan-answers dispatch-answer-gate --json',
211
+ '',
212
+ ].join('\n'),
213
+ )
214
+ .action(async (plan: string, opts: AnswersCommandOptions) => {
215
+ process.exitCode = await runAnswers(plan, opts)
216
+ })
217
+
176
218
  tasks
177
219
  .command('pull-request')
178
220
  .description('Record a pull request number on the task a branch closes')
@@ -539,6 +581,64 @@ function describeCitations(outcome: PlanCitations): string {
539
581
  return `shares ${outcome.target} with ${outcome.citedBy.join(', ')}, so the sweep leaves it.`
540
582
  }
541
583
 
584
+ async function runAnswers(
585
+ plan: string,
586
+ opts: AnswersCommandOptions,
587
+ ): Promise<number> {
588
+ const root = opts.root ?? (await mainWorktreeRoot())
589
+ const outcome = await planAnswers(root, plan)
590
+
591
+ return reportAnswers(outcome, opts.json ?? false, root)
592
+ }
593
+
594
+ function reportAnswers(
595
+ outcome: AnswersOutcome,
596
+ emitJson: boolean,
597
+ root: string,
598
+ ): number {
599
+ if (!outcome.ok) {
600
+ if (emitJson) {
601
+ process.stdout.write(
602
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
603
+ )
604
+ return 1
605
+ }
606
+
607
+ intro('canon tasks plan-answers')
608
+ logStep('Refused')
609
+ logError(outcome.message)
610
+ outro()
611
+ return 1
612
+ }
613
+
614
+ if (emitJson) {
615
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
616
+ return outcome.launchable ? 0 : EXIT_FINDINGS
617
+ }
618
+
619
+ intro('canon tasks plan-answers')
620
+ logStep(outcome.plan)
621
+
622
+ if (outcome.launchable) {
623
+ logInfo('waits on nobody, so the dispatch may launch it.')
624
+ outro()
625
+ return 0
626
+ }
627
+
628
+ for (const question of outcome.open) {
629
+ logWarn(`${question.label} ${question.why}`)
630
+ }
631
+
632
+ const one = outcome.open.length === 1
633
+
634
+ logError(
635
+ `${outcome.open.length} question${one ? '' : 's'} ${one ? 'waits' : 'wait'} on you, so the dispatch holds this row.`,
636
+ )
637
+ outro()
638
+
639
+ return EXIT_FINDINGS
640
+ }
641
+
542
642
  function reportValidation(
543
643
  outcome: ValidateOutcome,
544
644
  emitJson: boolean,
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
4
  import { listRepositoryFiles } from '@/git-files'
5
+ import { RECORD_ROOTS } from '@/record-root'
5
6
 
6
7
  /**
7
8
  * Suppresses citation checking for the source line carrying it.
@@ -76,21 +77,31 @@ export function isFixture(rel: string): boolean {
76
77
  return rel.split('/').some((segment) => FIXTURE_SEGMENTS.includes(segment))
77
78
  }
78
79
 
80
+ /**
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.
85
+ */
79
86
  export function citationPattern(folders: readonly string[]): RegExp {
80
- const names = folders.map((name) =>
81
- name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
82
- )
87
+ const names = folders.map((name) => escape(name))
88
+ const roots = RECORD_ROOTS.map((name) => escape(name))
89
+
83
90
  return new RegExp(
84
- `\\.claude/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
91
+ `(?:${roots.join('|')})/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
85
92
  'g',
86
93
  )
87
94
  }
88
95
 
96
+ function escape(value: string): string {
97
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
98
+ }
99
+
89
100
  /**
90
101
  * A backticked filename carrying no folder, the form a reference takes when it
91
102
  * names a sibling rather than a path.
92
103
  *
93
- * `citationPattern` spells the `.claude/` prefix and cannot see this shape at
104
+ * `citationPattern` spells a record-root prefix and cannot see this shape at
94
105
  * all, which is the reason the form rule exists. Widening that expression to
95
106
  * admit a bare name was the alternative and it puts one match in the position of
96
107
  * answering two questions, since a spelled path is a reference by construction
@@ -1,9 +1,10 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, relative, resolve } from 'node:path'
3
3
  import { INDEX_FILE, listIndexes } from '@/indexes/walk'
4
+ import { RECORD_ROOTS } from '@/record-root'
4
5
 
5
6
  /**
6
- * Folder names under `.claude/` audited by default.
7
+ * Folder names under a record root audited by default.
7
8
  *
8
9
  * A named list rather than the index-plus-entry contract read off disk, so a
9
10
  * generated tree satisfying that contract is never measured against a rule
@@ -12,8 +13,8 @@ import { INDEX_FILE, listIndexes } from '@/indexes/walk'
12
13
  * here.
13
14
  *
14
15
  * It doubles as the citation check's scope, since `citationPattern` spells only
15
- * these names. A `.claude/` folder left off the list is never resolved, so a
16
- * path into one goes stale silently rather than failing a push.
16
+ * these names. A folder left off the list is never resolved, so a path into one
17
+ * goes stale silently rather than failing a push.
17
18
  */
18
19
  export const DEFAULT_FOLDERS: readonly string[] = [
19
20
  'context',
@@ -21,8 +22,17 @@ export const DEFAULT_FOLDERS: readonly string[] = [
21
22
  'wireframes',
22
23
  ]
23
24
 
24
- /** The base every folder in the default list sits under. */
25
- const CLAUDE_BASE = '.claude'
25
+ /**
26
+ * The bases every folder in the default list is looked for under, in the record
27
+ * roots' own precedence order.
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.
34
+ */
35
+ const CLAUDE_BASES: readonly string[] = RECORD_ROOTS
26
36
 
27
37
  /** The project root, reached only by a name the caller asked for. */
28
38
  const ROOT_BASE = '.'
@@ -58,7 +68,7 @@ export interface AuditedFolder {
58
68
  }
59
69
 
60
70
  /**
61
- * Names the requested `.claude/` folders that actually exist, which is the
71
+ * Names the requested record-root folders that actually exist, which is the
62
72
  * citation check's scope.
63
73
  *
64
74
  * A skill or seed pointing into `.claude/wireframes/` is a live instruction for
@@ -76,7 +86,7 @@ export function presentNames(folders: readonly AuditedFolder[]): string[] {
76
86
  return [
77
87
  ...new Set(
78
88
  folders
79
- .filter((folder) => folder.base === CLAUDE_BASE)
89
+ .filter((folder) => CLAUDE_BASES.includes(folder.base))
80
90
  .map((folder) => folder.name),
81
91
  ),
82
92
  ]
@@ -157,7 +167,7 @@ export async function resolveFolders(
157
167
  names: readonly string[] = DEFAULT_FOLDERS,
158
168
  { canResolveAtRoot = false }: ResolveOptions = {},
159
169
  ): Promise<FolderResolution> {
160
- const bases = canResolveAtRoot ? [CLAUDE_BASE, ROOT_BASE] : [CLAUDE_BASE]
170
+ const bases = canResolveAtRoot ? [...CLAUDE_BASES, ROOT_BASE] : CLAUDE_BASES
161
171
  const folders: AuditedFolder[] = []
162
172
  const missing: string[] = []
163
173
 
@@ -97,7 +97,7 @@ export const SANDBOX_UNDECLARED_CEILING = 47
97
97
  * rather than derived, because this stage only ever names the file in a remedy
98
98
  * a reader has to be able to open, and `canon audits run` owns writing it.
99
99
  */
100
- export const AUDITS_BASELINE = '.claude/audits/baseline.json'
100
+ export const AUDITS_BASELINE = '.claude/canon/baseline.json'
101
101
 
102
102
  export const HERO_STAMP_FAILURE =
103
103
  'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html and commit all three files together.'
@@ -9,6 +9,7 @@ import {
9
9
  readItems,
10
10
  writeAnswerLine,
11
11
  } from '@/intake/items'
12
+ import { recordDir } from '@/record-root'
12
13
 
13
14
  export const INTAKE_REFUSALS = [
14
15
  'no-intake',
@@ -80,7 +81,7 @@ function refuse(
80
81
  }
81
82
 
82
83
  export function intakeDir(root: string): string {
83
- return join(root, '.claude', 'intake')
84
+ return recordDir(root, 'intake')
84
85
  }
85
86
 
86
87
  /**