@erclx/canon 4.36.0 → 4.36.1

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.36.0",
4
+ "version": "4.36.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -16,7 +16,7 @@ Some decisions are settled by looking rather than by reasoning, and no draft is
16
16
  ## Step 1: name the decision and the arms
17
17
 
18
18
  1. State the decision in one sentence, naming what changes between arms and what stays fixed.
19
- 2. Derive a kebab slug from that sentence. Every file this run writes goes to `.canon/tmp/<slug>/`, per `.claude/rules/core/055-scratch.md`.
19
+ 2. Derive a kebab slug from that sentence. Every file this run writes goes to `.canon/tmp/<slug>/`, per `.claude/rules/canon/core/055-scratch.md`.
20
20
  3. Write one arm per candidate, each carrying an id, a label, and what the arm costs. An arm with no stated cost is not an option.
21
21
  4. Make the current state arm `0`, so the baseline is a candidate rather than an absence. A decision with nothing shipped yet says so and starts at arm `1`.
22
22
  5. Stop at three to five arms. Two is a comparison the operator can hold in prose, and past five the pick stops being a look and becomes a sort.
@@ -47,7 +47,7 @@ canon capture .canon/tmp/<slug>/candidates.html --selector <element>
47
47
 
48
48
  ## Step 4: take the pick
49
49
 
50
- Put the choice to the operator through the structured question surface, per `.claude/rules/core/005-behavior.md`.
50
+ Put the choice to the operator through the structured question surface, per `.claude/rules/canon/core/005-behavior.md`.
51
51
 
52
52
  - One option per arm, labeled with the arm's id and carrying its cost as the description.
53
53
  - Rank the recommendation first and mark it `(Recommended)`.
@@ -86,7 +86,7 @@ Print the blocks below, omitting any that is empty.
86
86
  ## Citations to repoint
87
87
 
88
88
  - `CLAUDE.md:42` → `canon standards markdown`
89
- - `.claude/rules/core/<n>-<slug>.md:8` → `canon standards skill`
89
+ - `.claude/rules/canon/core/<n>-<slug>.md:8` → `canon standards skill`
90
90
 
91
91
  ## You supply
92
92
 
@@ -149,7 +149,7 @@ Common patterns:
149
149
 
150
150
  `migrate records` moves a project's session records from `.claude/` to `.canon/` and rewrites every tracked file that cites one. It reports until `--write` is passed, and refuses outright when the project does not already ignore `.canon/`, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. Take the ignore entry with `canon tooling sync --write` first.
151
151
 
152
- A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
152
+ A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the nearest non-blank line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
153
153
 
154
154
  `migrate record-tree` is what reaches the records the sweep above passes over, and the two share no scope. That one enumerates through git, so it sees every tracked file and none of the records, which are gitignored by construction. This one walks `.canon/` itself and is scoped to the folders a session still follows a path into: `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`, each minus its own `archive/` subtree. A closed groundwork or intake trail, the scratch folder, and the backup history are reported as counts and never rewritten, because a path inside a closed trail sits in a sentence about work that already ended. Run it after the move, since there is no new root to walk before one.
155
155
 
@@ -9,22 +9,25 @@ The behavior notes behind the verbs listed in `commands.md`. Each one records wh
9
9
 
10
10
  ## Domain sync
11
11
 
12
- `canon gov sync` updates only rules already present under `.claude/rules/` and
13
- never adds new ones. A rule the toolkit does not ship is left alone, which is
14
- how project-authored rules survive, and one under `.claude/rules/project/`
15
- is left alone regardless of its name, since that subfolder is project-authored
16
- by location. It also removes a stale `.claude/GOV.md`
17
- from the retired build. Use `canon gov install` to add rules.
18
-
19
- A rule the toolkit finds no source for, sitting in a shared subdirectory rather
20
- than under `.claude/rules/project/`, is reported with the path under that
21
- subfolder it would take. The offer is conditional on the project having written
22
- the rule, since a rule the toolkit shipped and later renamed reaches the same
23
- line, and moving one there would mark it the project's for good. Nothing is
24
- moved either way, because a rule's installed path is one the project's own
25
- rules, skills, and docs may cite. `canon standards rule` carries the reserved
26
- number bands behind that placement, where `900-999` is the range a
27
- project-authored rule takes and everything below it belongs to the toolkit.
12
+ `canon gov sync` updates only rules already present under
13
+ `.claude/rules/canon/` and never adds new ones. That wrapper marks the
14
+ toolkit-owned half of the tree, and it is the whole of what the walk reaches:
15
+ `.claude/rules/project/` sits outside it by location and is never read,
16
+ matched, or reported on, which is how project-authored rules survive. It also
17
+ removes a stale `.claude/GOV.md` from the retired build. Use
18
+ `canon gov install` to add rules.
19
+
20
+ A rule under `.claude/rules/canon/` that the toolkit finds no source for is
21
+ reported orphaned with no destination offered. The toolkit cannot tell a rule
22
+ a project dropped there from one it shipped and later renamed, and a
23
+ destination nested inside `canon/` would be wrong for the first case
24
+ regardless, since that folder is replaced wholesale on sync.
25
+
26
+ Nothing is moved either way, because a rule's installed path is one the
27
+ project's own rules, skills, and docs may cite. `canon standards rule` carries
28
+ the reserved number bands behind the three-way split, where `900-999` under
29
+ `.claude/rules/project/` is what a project-authored rule takes and everything
30
+ under `.claude/rules/canon/` belongs to the toolkit.
28
31
 
29
32
  `canon sync --check` does not report an orphaned entry. It skips every one, so
30
33
  the destination reaches `canon gov sync` alone among the two per-file domain
@@ -52,8 +55,8 @@ without governance has yet to install what every project carries, while one
52
55
  without design chose that.
53
56
 
54
57
  When the target's install recorded a stack, `canon gov sync` also reports a
55
- rule that stack lists and `.claude/rules/` does not hold, as a `missing` entry
56
- carrying no change. This is what makes a target whose recorded sync point
58
+ rule that stack lists and `.claude/rules/canon/` does not hold, as a `missing`
59
+ entry carrying no change. This is what makes a target whose recorded sync point
57
60
  postdates a rule joining its stack still see that rule: the report reads the
58
61
  target's current entitlement against its current tree rather than diffing
59
62
  from an anchor a later sync could advance past the rule's own commit. A
@@ -109,9 +112,12 @@ runs the verb again for that name.
109
112
 
110
113
  `canon gov regen` is the one governance verb that runs against the toolkit root,
111
114
  because the `.claude/rules/` it writes there is produced output rather than an
112
- operator's working copy. It reads the stack recorded in `internal/governance.toml`,
113
- installs it alongside anything under `internal/rules/`, and clears the
114
- destination first so a rule the record stopped naming disappears.
115
+ operator's working copy. It reads the stack recorded in `internal/governance.toml`
116
+ into `.claude/rules/canon/`, installs anything under `internal/rules/` into a
117
+ separate `.claude/rules/internal/`, and clears both destinations first so a
118
+ rule the record stopped naming disappears. `internal/` is what this repository
119
+ alone carries: no `canon gov install` or `canon gov sync` target ever writes it,
120
+ since a target has no rule source of its own that ships nowhere.
115
121
 
116
122
  It takes `--root <path>` and defaults to the toolkit root, prints nothing on success, and
117
123
  reports the reason on stderr with exit 1 when the record names a stack or rule
@@ -5,7 +5,7 @@ description: Reading per CLAUDE.md section how many bullets name a path, what co
5
5
 
6
6
  # Routing report
7
7
 
8
- `canon claude routing` reports, per `CLAUDE.md` section, how many top-level bullets name a path and how many of those a path-scoped rule already covers. It answers the firing axis of the tier test in `.claude/rules/claude/592-claude-md.md`, whether a fact applies every session or fires on one path, and which until this verb existed was a judgment nothing counted. `.claude/ARCHITECTURE.md` names two further axes, conditional presence and updatability, that this verb does not check.
8
+ `canon claude routing` reports, per `CLAUDE.md` section, how many top-level bullets name a path and how many of those a path-scoped rule already covers. It answers the firing axis of the tier test in `.claude/rules/canon/claude/592-claude-md.md`, whether a fact applies every session or fires on one path, and which until this verb existed was a judgment nothing counted. `.claude/ARCHITECTURE.md` names two further axes, conditional presence and updatability, that this verb does not check.
9
9
 
10
10
  ```bash
11
11
  canon claude routing
@@ -5,7 +5,7 @@ description: Reading where an implementation reached history ahead of its test,
5
5
 
6
6
  # Test order
7
7
 
8
- `canon gov test-order` reports where an implementation reached a commit ahead of the test covering the same behavior. It answers the rule in `.claude/rules/core/070-planning.md` that asks for the test first, which until this verb existed was prose loaded on a glob match with nothing measuring it.
8
+ `canon gov test-order` reports where an implementation reached a commit ahead of the test covering the same behavior. It answers the rule in `.claude/rules/canon/core/070-planning.md` that asks for the test first, which until this verb existed was prose loaded on a glob match with nothing measuring it.
9
9
 
10
10
  ```bash
11
11
  canon gov test-order
@@ -142,7 +142,7 @@ Order matters between the first line and the two under it. The sync prunes the t
142
142
 
143
143
  Read the `ok` field out of the `--json` record rather than the exit code. A shell profile that wraps `canon` in a function takes its status from whatever the function runs last, so an absent subcommand and a clean run can both exit 0, and a reader watching the exit alone concludes the move happened.
144
144
 
145
- A tracked file that names an old record path on purpose, such as prose dating a decision, keeps it by carrying `canon-keep-record-root` on that line or the one above. The report pass prints every file it would rewrite, which is where to catch one before `--write` runs.
145
+ A tracked file that names an old record path on purpose, such as prose dating a decision, keeps it by carrying `canon-keep-record-root` on that line or the nearest non-blank line above. The report pass prints every file it would rewrite, which is where to catch one before `--write` runs.
146
146
 
147
147
  Until the move runs, the project is exposed. The shipped ignore set no longer names the old record paths, so a project holding records at `.claude/` stops ignoring them on its next `canon tooling sync`, and the first sign is a memory file or a task board appearing in a commit. Every command reads either root, so nothing else breaks in the meantime, and running the move is what closes it.
148
148
 
@@ -31,4 +31,4 @@ paths:
31
31
 
32
32
  ## Authority
33
33
 
34
- - Follow `.claude/rules/lib/300-testing-ts.md` for framework choice, file placement, and test naming.
34
+ - Follow `.claude/rules/canon/lib/300-testing-ts.md` for framework choice, file placement, and test naming.
@@ -21,5 +21,5 @@ paths:
21
21
 
22
22
  - Answer a question about the running page with a script against the dev server rather than with the suite.
23
23
  - Do not enable `fullyParallel` in `playwright.config.ts`.
24
- - Follow `.claude/rules/ui/440-surface-capture.md` for capture scope.
25
- - Follow `.claude/rules/lib/305-e2e-reliability.md` for waits and guards.
24
+ - Follow `.claude/rules/canon/ui/440-surface-capture.md` for capture scope.
25
+ - Follow `.claude/rules/canon/lib/305-e2e-reliability.md` for waits and guards.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.36.0",
4
+ "version": "4.36.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -97,7 +97,7 @@ log_step "Assert scaffold"
97
97
  # folder-whole entry the same way it does `core` and `claude`.
98
98
  for path in "CLAUDE.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".canon/diagrams/index.md" \
99
99
  ".prettierrc" ".editorconfig" ".lintstagedrc" ".husky/pre-commit" ".github/workflows/verify.yml" "scripts/verify.sh" \
100
- ".claude/rules/core/000-constitution.md" ".claude/rules/snippets/505-at-references.md"; do
100
+ ".claude/rules/canon/core/000-constitution.md" ".claude/rules/canon/snippets/600-at-references.md"; do
101
101
  if [ ! -e "$TARGET_DIR/$path" ]; then
102
102
  log_error "Missing after canon init: $path"
103
103
  fi
@@ -106,7 +106,7 @@ export function register(program: Command): void {
106
106
  }
107
107
  const reason = issueFailureMessage(result)
108
108
  if (!isToolkitSource()) {
109
- frameError(`${reason} No toolkit source to fall back to.`)
109
+ frameError(reason)
110
110
  process.exitCode = 1
111
111
  return
112
112
  }
@@ -78,6 +78,7 @@ function payloadRel(root: string): string {
78
78
  return creationRel(root, SCRATCH, 'gov', 'rules.md')
79
79
  }
80
80
  const RULES_REL = join('.claude', 'rules')
81
+ const CANON_RULES_REL = join('.claude', 'rules', 'canon')
81
82
 
82
83
  interface InstallOptions {
83
84
  readonly add?: string
@@ -139,7 +140,7 @@ export function register(program: Command): void {
139
140
 
140
141
  gov
141
142
  .command('install')
142
- .description('Install a governance stack into .claude/rules/')
143
+ .description('Install a governance stack into .claude/rules/canon/')
143
144
  .argument('[stack]', 'Stack name (e.g. base, node, react)')
144
145
  .argument('[target]', 'Target directory', '.')
145
146
  .helpOption('-h, --help', 'Show this help message')
@@ -167,7 +168,7 @@ export function register(program: Command): void {
167
168
 
168
169
  gov
169
170
  .command('sync')
170
- .description('Update rules already installed under .claude/rules/')
171
+ .description('Update rules already installed under .claude/rules/canon/')
171
172
  .argument('[target]', 'Target directory', '.')
172
173
  .helpOption('-h, --help', 'Show this help message')
173
174
  .action(async (target: string) => {
@@ -230,7 +231,7 @@ export function register(program: Command): void {
230
231
  'after',
231
232
  [
232
233
  '',
233
- 'Measures the rule in .claude/rules/core/070-planning.md that asks for',
234
+ 'Measures the rule in .claude/rules/canon/core/070-planning.md that asks for',
234
235
  'the test before the code. It reports and never gates, because pairing',
235
236
  'a test to an implementation is a judgment.',
236
237
  '',
@@ -1191,7 +1192,7 @@ async function runInstall(
1191
1192
  for (const source of found) logInfo(source.rule)
1192
1193
 
1193
1194
  const shouldInstall = await select({
1194
- message: `Install ${found.length} rules to ${displayPath(target, RULES_REL)}?`,
1195
+ message: `Install ${found.length} rules to ${displayPath(target, CANON_RULES_REL)}?`,
1195
1196
  options: [
1196
1197
  { value: true, label: 'Yes' },
1197
1198
  { value: false, label: 'No' },
@@ -435,8 +435,9 @@ export function register(program: Command): void {
435
435
  'this relocates is ignored where it stands, and landing one under a root',
436
436
  'the project tracks publishes the memory pen into the next commit.',
437
437
  '',
438
- 'A line carrying canon-keep-record-root, or the line below it, keeps the',
439
- 'old root. Prose that dates a decision needs it; a live path does not.',
438
+ 'A line carrying canon-keep-record-root, or the nearest non-blank line',
439
+ 'below it, keeps the old root. Prose that dates a decision needs it; a',
440
+ 'live path does not.',
440
441
  '',
441
442
  'The records themselves are never swept. Everything under .canon/ and',
442
443
  'every .claude/ record folder is left alone and reported as a count, so',
@@ -487,8 +488,9 @@ export function register(program: Command): void {
487
488
  'The records are untracked and unbacked, so a wrong rewrite has no undo',
488
489
  'and the report is what a reader judges before passing --write.',
489
490
  '',
490
- 'A line carrying canon-keep-record-root, or the line below it, keeps the',
491
- 'old root. Prose that dates a decision needs it; a live path does not.',
491
+ 'A line carrying canon-keep-record-root, or the nearest non-blank line',
492
+ 'below it, keeps the old root. Prose that dates a decision needs it; a',
493
+ 'live path does not.',
492
494
  '',
493
495
  'Examples:',
494
496
  ' canon migrate record-tree',
@@ -85,7 +85,7 @@ const output = (text: string): Emission => ({ kind: 'output', text })
85
85
  /**
86
86
  * Rules no stack reaches, sorted the way `canon gov list` emits them.
87
87
  * `260-shadcn` and `320-tanstack-query` are opt-in libraries a project may not
88
- * want. `505-at-references` used to sit here too, shipping with no stack on
88
+ * want. `600-at-references` used to sit here too, shipping with no stack on
89
89
  * purpose since a rule under `claude/` would reach every base consumer through
90
90
  * the folder-whole entry there. Its own install channel, `canon snippets
91
91
  * install`, retired with nothing left to deliver it, so `base` now carries
@@ -25,13 +25,15 @@ export interface IssueSuccess {
25
25
 
26
26
  export type CreateIssueResult = IssueSuccess | IssueFailure
27
27
 
28
- const ISSUE_URL = 'https://github.com/erclx/canon/issues/new'
28
+ const REPO = 'erclx/canon'
29
+ const ISSUE_URL = `https://github.com/${REPO}/issues/new`
29
30
 
30
31
  export function buildIssueArgs(opts: CreateIssueOptions): string[] {
31
32
  const args = ['issue', 'create', '--title', opts.title, '--body', opts.body]
32
33
  for (const label of opts.labels ?? []) {
33
34
  args.push('--label', label)
34
35
  }
36
+ args.push('--repo', REPO)
35
37
  return args
36
38
  }
37
39
 
@@ -71,5 +73,5 @@ export function issueFailureMessage(failure: IssueFailure): string {
71
73
  if (failure.reason === 'missing-binary') {
72
74
  return `gh is not installed, so no issue was filed. Install gh, or file it at ${ISSUE_URL}`
73
75
  }
74
- return `gh could not file the issue: ${failure.detail ?? 'no diagnostic on stderr'}`
76
+ return `gh could not file the issue: ${failure.detail ?? 'no diagnostic on stderr'}. File it manually at ${ISSUE_URL} instead.`
75
77
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { basename, join, relative, resolve } from 'node:path'
3
+ import { canonRulesDir } from '@/gov/install'
3
4
  import { resolveMissingRules } from '@/gov/stacks'
4
5
  import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
5
6
  import { readStamp, stampedChain } from '@/sync/stamp'
@@ -42,6 +43,13 @@ export function indexSourceRules(root: string): Map<string, string> {
42
43
  * Matches installed rules to sources by rule name rather than by relative
43
44
  * path, so a rule that moved between subdirectories in the toolkit still
44
45
  * syncs into the subdirectory the target already uses.
46
+ *
47
+ * Declares no `projectSubdir`. That exemption existed to keep a project's
48
+ * `.claude/rules/project/` folder out of the walk by location before any
49
+ * name lookup ran, and `installedRoot` narrowing to `.claude/rules/canon/`
50
+ * already leaves `project/` outside the walked root, so a second exemption
51
+ * computing a stale `canon/project/` destination would be wrong rather than
52
+ * merely redundant.
45
53
  */
46
54
  export function createGovAdapter(root: string): SyncAdapter {
47
55
  const index = indexSourceRules(root)
@@ -52,12 +60,11 @@ export function createGovAdapter(root: string): SyncAdapter {
52
60
  missingMessage:
53
61
  "No governance surfaces found in target. Run 'canon gov install' first.",
54
62
  unit: 'changes',
55
- installedRoot: (target: string) => join(target, '.claude', 'rules'),
63
+ installedRoot: canonRulesDir,
56
64
  locateSource: (file: InstalledFile) =>
57
65
  index.get(basename(file.path, '.md')),
58
66
  collectRetired: (target: string) => collectRetiredGov(target),
59
67
  collectMissing: (target: string) => collectMissingGov(root, target),
60
- projectSubdir: 'project',
61
68
  stamp: { domain: 'governance', toolkitRoot: root },
62
69
  }
63
70
  }
@@ -72,13 +79,7 @@ function collectMissingGov(root: string, target: string): RetiredSurface[] {
72
79
  const chain = stampedChain(readStamp(target), 'governance')
73
80
 
74
81
  return resolveMissingRules(root, target, chain).map((source) => {
75
- const dest = join(
76
- target,
77
- '.claude',
78
- 'rules',
79
- source.subdir,
80
- `${source.rule}.md`,
81
- )
82
+ const dest = join(canonRulesDir(target), source.subdir, `${source.rule}.md`)
82
83
  const rel = relative(target, dest)
83
84
  return {
84
85
  path: dest,
@@ -2,8 +2,9 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { rm } from 'node:fs/promises'
3
3
  import { basename, join } from 'node:path'
4
4
  import {
5
+ canonRulesDir,
6
+ installedInternalRulesDir,
5
7
  installRules,
6
- installedRulesDir,
7
8
  lookupRules,
8
9
  type RuleSource,
9
10
  ruleSubdir,
@@ -117,12 +118,15 @@ export async function regenConsumedRules(
117
118
 
118
119
  // Clearing first is what makes a rule the record stopped naming disappear.
119
120
  // Copying over the destination would leave it behind as an unsourced file,
120
- // which is the state this producer exists to end.
121
- await rm(installedRulesDir(root), { recursive: true, force: true })
121
+ // which is the state this producer exists to end. Each subtree clears on
122
+ // its own rather than through the shared `.claude/rules/` parent, so a
123
+ // `project/` folder landing beside them later is never in the blast radius.
124
+ await rm(canonRulesDir(root), { recursive: true, force: true })
125
+ await rm(installedInternalRulesDir(root), { recursive: true, force: true })
122
126
 
123
127
  const installed = [
124
- ...(await installRules(found, root)),
125
- ...(await installRules(internal, root)),
128
+ ...(await installRules(found, root, 'canon')),
129
+ ...(await installRules(internal, root, 'internal')),
126
130
  ]
127
131
 
128
132
  return { ok: true, installed: installed.sort() }
@@ -21,13 +21,26 @@ export function installedRulesDir(target: string): string {
21
21
  return join(target, '.claude', 'rules')
22
22
  }
23
23
 
24
+ /** Toolkit-shipped stack rules, wrapped so their source reads from location. */
25
+ export function canonRulesDir(target: string): string {
26
+ return join(installedRulesDir(target), 'canon')
27
+ }
28
+
29
+ /**
30
+ * This repository's own toolkit-only rules, installed beside `canon/` rather
31
+ * than interleaved into its bands. No target ever holds this folder.
32
+ */
33
+ export function installedInternalRulesDir(target: string): string {
34
+ return join(installedRulesDir(target), 'internal')
35
+ }
36
+
24
37
  /**
25
- * Rule names a target already holds, read off the installed tree by basename
26
- * rather than off a recorded stack, since a target may hold rules `--add`
27
- * layered on that no stack lists.
38
+ * Rule names a target already holds, read off the installed `canon/` tree by
39
+ * basename rather than off a recorded stack, since a target may hold rules
40
+ * `--add` layered on that no stack lists.
28
41
  */
29
42
  export function installedRuleNames(target: string): Set<string> {
30
- const dir = installedRulesDir(target)
43
+ const dir = canonRulesDir(target)
31
44
  const names = new Set<string>()
32
45
  if (!existsSync(dir)) return names
33
46
 
@@ -114,12 +127,20 @@ export function lookupRules(
114
127
  * Copies each rule into the subdirectory it was authored in, so the installed
115
128
  * tree keeps the band structure `governance/rules/` carries. Returns the
116
129
  * target-relative paths the timeline prints.
130
+ *
131
+ * `destSubdir` picks which wrapper the rule lands under: `canon` for every
132
+ * target-facing install, and `internal` only for this repository's own
133
+ * toolkit-only rules, which `regenConsumedRules` installs separately.
117
134
  */
118
135
  export async function installRules(
119
136
  sources: readonly RuleSource[],
120
137
  target: string,
138
+ destSubdir: 'canon' | 'internal' = 'canon',
121
139
  ): Promise<string[]> {
122
- const rulesDir = installedRulesDir(target)
140
+ const rulesDir =
141
+ destSubdir === 'canon'
142
+ ? canonRulesDir(target)
143
+ : installedInternalRulesDir(target)
123
144
  const installed: string[] = []
124
145
 
125
146
  for (const source of sources) {
@@ -699,7 +699,7 @@ export function readRestated(root: string): RestatedReport {
699
699
  // Every candidate's rare set is invariant across the subject loop, so it is
700
700
  // built once here rather than per pair. The corpora multiply out to hundreds
701
701
  // of thousands of pairings, and rebuilding a set inside that is the shape
702
- // `.claude/rules/core/040-performance.md` names.
702
+ // `.claude/rules/canon/core/040-performance.md` names.
703
703
  const rareCandidates = candidates.map((candidate) => ({
704
704
  ...candidate,
705
705
  rare: distinctive(candidate.analysis),
@@ -134,7 +134,7 @@ export const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
134
134
  *
135
135
  * The asymmetry is the point. A whole-root prefix is correct for the new root
136
136
  * and wrong for the old one, which is mixed: this repository tracks 163 files
137
- * under `.claude/`, and a target's installed `.claude/rules/core/035-tasks.md`
137
+ * under `.claude/`, and a target's installed `.claude/rules/canon/core/035-tasks.md`
138
138
  * is the file the sweep exists to repoint, so a bare `.claude/` prefix strands
139
139
  * it silently. The old root is therefore entry-scoped, through `spell` so the
140
140
  * one naming variant stays decided in `record-root.ts`.
@@ -180,14 +180,21 @@ export function isRecordArtifact(path: string): boolean {
180
180
  * Prose that dates a decision, records where a defect landed, or names the
181
181
  * fallback a target still resolves through all have to keep saying `.claude/`,
182
182
  * and a sweep cannot tell those from a live path. The marker sits on the line
183
- * itself or on the one above it, which is the placement `canon-keep-retired`
184
- * and `canon-allow-superseded` already use here.
183
+ * itself or on the nearest non-blank line above it, walking past a run of
184
+ * blank markdown lines to reach a marker written on its own line above the
185
+ * paragraph it protects. `canon-keep-retired` and `canon-allow-superseded`
186
+ * mark code instead, where no blank line ever falls between the marker and
187
+ * the line it protects, so neither needs the walk.
185
188
  */
186
189
  const KEEP_MARKER = 'canon-keep-record-root'
187
190
 
188
191
  function isKept(lines: readonly string[], index: number): boolean {
189
192
  if (lines[index]?.includes(KEEP_MARKER)) return true
190
- return index > 0 && (lines[index - 1]?.includes(KEEP_MARKER) ?? false)
193
+
194
+ let above = index - 1
195
+ while (above >= 0 && lines[above]?.trim() === '') above -= 1
196
+
197
+ return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
191
198
  }
192
199
 
193
200
  /** Rewrites every unmarked citation into a moved entry. */
package/src/sync/check.ts CHANGED
@@ -67,7 +67,7 @@ const ADAPTERS: Record<ScannedDomain, (root: string) => SyncAdapter> = {
67
67
  }
68
68
 
69
69
  const INSTALL_MARKERS: Record<ScannedDomain, readonly string[]> = {
70
- governance: ['.claude', 'rules'],
70
+ governance: ['.claude', 'rules', 'canon'],
71
71
  design: DESIGN_INSTALL_DIR.split(sep),
72
72
  }
73
73
 
@@ -40,7 +40,9 @@ This check is one of the two the destination rule above scopes. The reader insid
40
40
 
41
41
  A phase label is one way text names the board, and a path under a record root is the other. Both resolve for a reader holding this checkout and neither resolves for anyone else, so this check is the second one the destination rule scopes.
42
42
 
43
- Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open. Under `.claude/`'s own tracked folders there is no hit, since `.claude/rules/core/005-behavior.md` resolves everywhere. `.canon/` carries no such carve-out: one ignore line covers the root whole, so every path beneath it is a hit regardless of which folder names it.
43
+ Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open.
44
+
45
+ Under `.claude/`'s own tracked folders there is no hit, since `.claude/context/governance/rules.md` resolves everywhere. `.canon/` carries no such carve-out: one ignore line covers the root whole, so every path beneath it is a hit regardless of which folder names it.
44
46
 
45
47
  Rewrite a hit to name what the reader can reach rather than deleting it. A row's subject stated plainly replaces its label, and what a record folder holds, said in a sentence, replaces its path.
46
48
 
package/standards/rule.md CHANGED
@@ -31,27 +31,32 @@ Write both when both apply. A rule stating the directive and a skill stating how
31
31
 
32
32
  ## Location
33
33
 
34
- - Rules live at `.claude/rules/<subdirectory>/<n>-<slug>.md`
34
+ - A toolkit-shipped rule lives at `.claude/rules/canon/<subdirectory>/<n>-<slug>.md`. The wrapper marks the file as toolkit-owned and replaced on sync, so a reader unfamiliar with the numbering convention still knows not to edit it.
35
35
  - Subdirectories group by domain: `core/`, `lang/`, `framework/`, `lib/`, `ui/`, `claude/`, `snippets/`, `ci/`
36
36
  - `<n>` is a number from the band reserved for the rule's source and `<slug>` is a one-to-three-word kebab topic
37
- - A rule the project authored itself lives at `.claude/rules/project/<subdirectory>/<n>-<slug>.md`, keeping the same subdirectory names
37
+ - A rule the project authored itself lives at `.claude/rules/project/<subdirectory>/<n>-<slug>.md`, a sibling of `canon/` rather than nested inside it, keeping the same subdirectory names
38
+ - This repository's own toolkit-only rules, which never ship to a target, live at `.claude/rules/internal/<subdirectory>/<n>-<slug>.md`, a second sibling of `canon/`. See "Two sources numbering into one folder" below.
38
39
  - Give every rule a numeric prefix. A bare-word filename reads as a folder name where a stack names its rules, so a rule without one is unreachable from a stack entry.
39
40
 
40
41
  ## Reserved numbers
41
42
 
42
- `000-899` is reserved for a rule set that ships to targets. `000-599` is what the first six subdirectories above already divide, at 100 per subdirectory. `ci/` was the first to draw from the headroom past that, taking `700-799`, and `600-699` with `800-899` is what remains for a subdirectory a shipped set has not added yet.
43
+ `000-899` is reserved for a rule set that ships to targets. `000-599` is what the first six subdirectories above already divide, at 100 per subdirectory. `ci/` was the first to draw from the headroom past that, taking `700-799`.
44
+
45
+ `snippets/` holds one rule rather than a subdirectory's worth of them, and draws a single number, `600`, from the nearer overflow band rather than claiming a hundred for one file. The rest of `600-699`, along with `800-899`, is what remains for a subdirectory a shipped set has not added yet.
43
46
 
44
47
  A project-authored rule takes `900-999`, one sequence across every subdirectory under `.claude/rules/project/`. Scanning for a free number instead is what fails, because a shipped set fills its own band release by release, so what reads as free today is what a later release lands on. One target authored `claude/561-self-check.md` on a day nothing shipped at 561, met `claude/561-teach.md` on its next install, and now reads two numbers differently from everywhere else.
45
48
 
46
49
  The cost is that a project-authored rule's leading digit stops naming its domain. Its subdirectory names it instead, and install preserves that either way. What a shared band costs is worse: a session that loads two rules reading as one number, with nothing in the folder to say which is which.
47
50
 
48
- `canon gov sync` reports a rule it finds no toolkit source for, outside `.claude/rules/project/`, and offers the path under it. The offer is conditional on the project having authored the rule, because a rule the toolkit shipped and later renamed looks identical from the outside and moving one there would mark it the project's for good. Nothing is moved either way, since a rule's installed path is one the project's own rules, skills, and docs may cite.
51
+ `canon gov sync` walks only `.claude/rules/canon/`, so `.claude/rules/project/` sits outside the walk by location and is never matched, read, or reported on. A file under `.claude/rules/canon/` itself that no toolkit source names is still reported orphaned, but with no destination offered: the toolkit cannot tell a rule the project dropped there from one it shipped and later renamed, and a destination nested inside `canon/` would be wrong for the first case regardless, since that folder is toolkit-owned and replaced on sync. Nothing is moved either way, since a rule's installed path is one the project's own rules, skills, and docs may cite.
49
52
 
50
53
  ## Two sources numbering into one folder
51
54
 
52
- The reservation above divides one pair, being a shipped set against the rules a project wrote for itself. A third source numbering into the same folder needs its own division, which is the case for a rule set held back from targets and installed only where it was authored. Divide by source rather than by topic: one source takes the top of each subdirectory's band and the other takes the gaps between the tens.
55
+ The reservation above divides one pair, being a shipped set against the rules a project wrote for itself. A third source, a rule set held back from targets and installed only where it was authored, needs its own division too. This repository is the one place all three sources exist at once, and it divides the third pair two ways at once rather than one: `canon/` and `internal/` separate a shipped rule from an internal one by location, the same way `project/` separates a project-authored one, and within each subdirectory the numbering still divides by source as well, one source taking the top of the band and the other the gaps between the tens.
56
+
57
+ The numbering half stays because location alone does not carry to a reader who only sees the number, such as one comparing `core/070-planning.md` against a citation written before the rules moved into `canon/`.
53
58
 
54
- The collision this prevents is silent. Two rules that resolve to the same `<n>-<slug>` path leave one file in the installed folder, and neither the install nor the session that reads it reports which source lost. Nothing checks a division outside the reserved bands, so it holds only while both sources follow it.
59
+ The collision this prevents is silent. Two rules that resolve to the same `<n>-<slug>` path leave one file in the installed folder, and neither the install nor the session that reads it reports which source lost. Nothing checks a division outside the reserved bands, so it holds only while both sources follow it. Separating `canon/` from `internal/` by folder removes the filename-collision case specifically, since the two no longer install to the same directory, but the number still carries the source signal for a reader who has only the number in view.
55
60
 
56
61
  State that division where the rule sources are described, not inside the rules it divides. A rule states its own topic, and a numbering convention spanning two sources belongs to whatever documents the pair.
57
62
 
@@ -57,5 +57,5 @@ marker="$marker_dir/$key"
57
57
  mkdir -p "$marker_dir"
58
58
  : >"$marker"
59
59
 
60
- msg='Temporary file write outside the project scratch folder. Write temp files to .canon/tmp/<slug>/ in the project root, or .claude/.tmp/<slug>/ where the project carries no .canon/ root, not system temp. See the Scratch rule at .claude/rules/core/055-scratch.md.'
60
+ msg='Temporary file write outside the project scratch folder. Write temp files to .canon/tmp/<slug>/ in the project root, or .claude/.tmp/<slug>/ where the project carries no .canon/ root, not system temp. See the Scratch rule (055-scratch, under core/ in your installed governance rules).'
61
61
  jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'