@erclx/canon 5.33.0 → 5.35.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 (190) hide show
  1. package/README.md +3 -5
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/REQUIREMENT.md +2 -3
  4. package/claude/skills/api-design/SKILL.md +2 -3
  5. package/claude/skills/api-design/references/adopted.md +1 -1
  6. package/claude/skills/auto-ship/references/draft-mark.md +1 -1
  7. package/claude/skills/canon-cli/SKILL.md +10 -11
  8. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  9. package/claude/skills/canon-feedback/SKILL.md +10 -28
  10. package/claude/skills/canon-feedback/references/template.md +53 -0
  11. package/claude/skills/canvas-board/SKILL.md +4 -1
  12. package/claude/skills/canvas-board/references/canvas.md +26 -3
  13. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  14. package/claude/skills/code-craft/SKILL.md +0 -1
  15. package/claude/skills/create-rule/SKILL.md +1 -1
  16. package/claude/skills/create-rule/references/rule.md +3 -1
  17. package/claude/skills/create-skill/SKILL.md +2 -1
  18. package/claude/skills/create-skill/references/skill-paths.md +12 -15
  19. package/{standards/standard.md → claude/skills/create-skill/references/skill-reference.md} +50 -45
  20. package/claude/skills/create-skill/references/skill.md +10 -4
  21. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  22. package/claude/skills/git-commit/references/versioning.md +13 -13
  23. package/claude/skills/git-issue/references/issue.md +4 -1
  24. package/claude/skills/git-pr/SKILL.md +1 -1
  25. package/claude/skills/git-pr/references/publish.md +1 -1
  26. package/claude/skills/review-address/REQUIREMENT.md +1 -1
  27. package/claude/skills/review-address/SKILL.md +2 -2
  28. package/claude/skills/review-craft/references/ui.md +1 -1
  29. package/claude/skills/review-pr/SKILL.md +3 -3
  30. package/claude/skills/review-pr/references/post.md +1 -1
  31. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  32. package/claude/skills/review-ui/SKILL.md +8 -6
  33. package/claude/skills/review-ui/references/driving.md +8 -8
  34. package/claude/skills/review-ui/references/post.md +52 -16
  35. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
  36. package/claude/skills/role-orchestrator/SKILL.md +11 -12
  37. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +3 -1
  38. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -8
  39. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +10 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +11 -2
  41. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -12
  42. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +12 -15
  43. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +6 -4
  44. package/claude/skills/role-orchestrator/scripts/poll.ts +50 -1
  45. package/claude/skills/role-reviewer/REQUIREMENT.md +17 -7
  46. package/claude/skills/role-reviewer/SKILL.md +17 -10
  47. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  48. package/claude/skills/role-worker/SKILL.md +6 -6
  49. package/claude/skills/session-compact/references/handoff-note.md +1 -1
  50. package/claude/skills/target-setup/SKILL.md +12 -6
  51. package/claude/skills/target-setup/references/detect.md +22 -5
  52. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  53. package/claude/skills/test-craft/SKILL.md +15 -3
  54. package/docs/agents/audits.md +1 -1
  55. package/docs/agents/canvas.md +5 -1
  56. package/docs/agents/commands.md +3 -5
  57. package/docs/agents/counts.md +5 -6
  58. package/docs/agents/drift-surfaces.md +15 -19
  59. package/docs/agents/gov-detect.md +47 -0
  60. package/docs/agents/index.md +6 -4
  61. package/docs/agents/install-and-sync.md +11 -31
  62. package/docs/agents/markdown-audit.md +10 -12
  63. package/docs/agents/migrate.md +1 -1
  64. package/docs/agents/pr-evidence-layout.md +70 -0
  65. package/docs/agents/pr-evidence.md +8 -9
  66. package/docs/agents/pr-frames.md +7 -2
  67. package/docs/agents/pr-lift.md +132 -0
  68. package/docs/agents/records-stale.md +1 -1
  69. package/docs/agents/records-validate.md +11 -23
  70. package/docs/agents/records.md +1 -1
  71. package/docs/agents/routing.md +1 -1
  72. package/docs/agents/rule-citations.md +11 -14
  73. package/docs/agents/scripting.md +4 -21
  74. package/docs/agents/state-scoped-risk.md +1 -1
  75. package/docs/agents/tasks-validate.md +1 -1
  76. package/docs/agents/teach.md +1 -1
  77. package/docs/target/index.md +1 -0
  78. package/docs/target/migrations.md +4 -0
  79. package/docs/target/projects.md +11 -11
  80. package/docs/target/scenarios.md +5 -6
  81. package/docs/target/standards-moved.md +32 -0
  82. package/docs/target/sync.md +5 -5
  83. package/docs/workflow/operating-model.md +16 -18
  84. package/docs/workflow/skill-map.md +1 -1
  85. package/governance/detect.toml +197 -0
  86. package/governance/renames.toml +0 -1
  87. package/governance/rules/code/000-code.md +1 -13
  88. package/governance/rules/code/010-testing.md +2 -30
  89. package/governance/rules/ui/400-ui.md +1 -1
  90. package/governance/rules/ui/480-canvas.md +13 -0
  91. package/governance/stacks/base.toml +15 -8
  92. package/governance/stacks/go.toml +2 -0
  93. package/governance/stacks/node.toml +1 -1
  94. package/governance/stacks/php.toml +2 -0
  95. package/governance/stacks/python.toml +1 -1
  96. package/package.json +1 -4
  97. package/scripts/core/install-check.sh +2 -3
  98. package/scripts/core/regen-hero.ts +0 -6
  99. package/scripts/lib/ui.sh +0 -9
  100. package/src/audits/catalog.ts +0 -9
  101. package/src/canvas/arrange.ts +66 -0
  102. package/src/canvas/capture.ts +20 -6
  103. package/src/canvas/content.ts +39 -0
  104. package/src/canvas/fit.ts +238 -0
  105. package/src/canvas/tokens.ts +28 -10
  106. package/src/capture/frame-values.ts +2 -32
  107. package/src/claude/routing.ts +1 -1
  108. package/src/claude/skills-reach.ts +4 -6
  109. package/src/cli/help.ts +3 -4
  110. package/src/cli.ts +0 -2
  111. package/src/commands/canvas.ts +179 -1
  112. package/src/commands/gov.ts +149 -10
  113. package/src/commands/init.ts +1 -1
  114. package/src/commands/labels.ts +4 -44
  115. package/src/commands/migrate.ts +1 -1
  116. package/src/commands/pr.ts +376 -1
  117. package/src/commands/records.ts +4 -20
  118. package/src/commands/tooling.ts +2 -4
  119. package/src/counts/catalogs.ts +0 -6
  120. package/src/feedback/body.ts +26 -5
  121. package/src/gate/boundaries.ts +2 -2
  122. package/src/gate/measures.ts +64 -72
  123. package/src/gate/sequencer.ts +51 -2
  124. package/src/gate/stages.ts +3 -22
  125. package/src/gov/citations.ts +11 -43
  126. package/src/gov/detect.ts +301 -0
  127. package/src/gov/list.ts +23 -2
  128. package/src/gov/stacks.ts +9 -0
  129. package/src/markdown/bans.ts +1 -1
  130. package/src/migrate/scratch-evidence.ts +2 -2
  131. package/src/pr/evidence.ts +218 -45
  132. package/src/pr/frames.ts +23 -3
  133. package/src/pr/lift.ts +418 -0
  134. package/src/pr/rest.ts +3 -0
  135. package/src/pr/review-scope.ts +76 -0
  136. package/src/records/validate.ts +6 -194
  137. package/src/sandbox/equivalence.ts +14 -7
  138. package/src/sandbox/provision.ts +0 -5
  139. package/src/secrets/shipped.ts +1 -2
  140. package/src/serve/static.ts +1 -2
  141. package/src/shipped/references.ts +29 -80
  142. package/src/sync/check.ts +18 -14
  143. package/src/tooling/inject.ts +6 -37
  144. package/src/tooling/read.ts +2 -3
  145. package/src/tooling/scan.ts +4 -17
  146. package/src/tooling/subfolder.ts +2 -66
  147. package/tooling/base/configs/scripts/verify.json +1 -1
  148. package/tooling/base/configs/scripts/verify.ts +1 -12
  149. package/tooling/base/manifest.toml +1 -2
  150. package/tooling/base/reference.md +10 -16
  151. package/tooling/base/seeds/.github/workflows/verify.yml +0 -3
  152. package/tooling/base/seeds/.lintstagedrc +1 -2
  153. package/tooling/base/seeds/canon/context/ci.md +0 -1
  154. package/tooling/base/seeds/canon/context/development.md +1 -1
  155. package/tooling/claude/reference.md +1 -1
  156. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +13 -1
  157. package/tooling/go/reference.md +1 -1
  158. package/tooling/php/reference.md +1 -1
  159. package/tooling/python/reference.md +2 -2
  160. package/tooling/web/configs/.github/workflows/verify.yml +0 -3
  161. package/tooling/web/manifest.toml +0 -1
  162. package/tooling/web/reference.md +2 -4
  163. package/docs/agents/standards-audit.md +0 -40
  164. package/governance/rules/tooling/765-spelling.md +0 -19
  165. package/governance/stacks/astro.toml +0 -2
  166. package/governance/stacks/nextjs.toml +0 -2
  167. package/governance/stacks/node-server.toml +0 -2
  168. package/governance/stacks/python-fastapi.toml +0 -2
  169. package/governance/stacks/react.toml +0 -2
  170. package/src/commands/standards.ts +0 -278
  171. package/src/labels/spelling.ts +0 -120
  172. package/src/standards/audit.ts +0 -146
  173. package/src/standards/list.ts +0 -33
  174. package/src/standards/read.ts +0 -177
  175. package/src/standards/scope.ts +0 -56
  176. package/standards/index.md +0 -10
  177. package/tooling/base/configs/.vscode/extensions.json +0 -9
  178. package/tooling/base/configs/.vscode/settings.json +0 -3
  179. package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
  180. package/tooling/base/seeds/.cspell/tech-stack.txt +0 -25
  181. package/tooling/base/seeds/cspell.json +0 -20
  182. package/tooling/go/seeds/.cspell/tech-stack.txt +0 -16
  183. package/tooling/nextjs/seeds/.cspell/tech-stack.txt +0 -3
  184. package/tooling/php/seeds/.cspell/tech-stack.txt +0 -5
  185. package/tooling/python/seeds/.cspell/tech-stack.txt +0 -18
  186. package/tooling/vite-react/seeds/.cspell/project-terms.txt +0 -1
  187. package/tooling/vite-react/seeds/.cspell/tech-stack.txt +0 -2
  188. package/tooling/web/configs/.vscode/extensions.json +0 -13
  189. package/tooling/web/configs/.vscode/settings.json +0 -10
  190. package/tooling/web/seeds/.cspell/tech-stack.txt +0 -21
@@ -7,7 +7,6 @@ import {
7
7
  recordDir as resolveRecordDir,
8
8
  recordDirs as resolveRecordDirs,
9
9
  } from '@/roots/record'
10
- import { ATTRIBUTE_MARKER, governedPaths, readScope } from '@/standards/scope'
11
10
  import {
12
11
  TEACH_GLOSSARY,
13
12
  TEACH_MISSION,
@@ -18,34 +17,10 @@ import {
18
17
  WORKSPACE_NAME,
19
18
  } from '@/teach/workspace'
20
19
 
21
- export const RECORD_KINDS = [
22
- 'plans',
23
- 'groundwork',
24
- 'intake',
25
- 'standards',
26
- 'teach',
27
- ] as const
20
+ export const RECORD_KINDS = ['plans', 'groundwork', 'intake', 'teach'] as const
28
21
 
29
22
  export type RecordKind = (typeof RECORD_KINDS)[number]
30
23
 
31
- /**
32
- * The folders standards reads, in precedence order.
33
- *
34
- * It carries two because the corpus authors at the project root and installs
35
- * under `.claude/`. The authoring root wins where both exist, since the
36
- * installed tree is a generated copy here and a finding fixed there is
37
- * overwritten by the next regen. A project that consumed the corpus holds only
38
- * the second, so one order serves both.
39
- *
40
- * Every other kind is a session record and takes the record roots instead,
41
- * resolved by `@/roots/record`. Standards is the one kind that is tracked, so it
42
- * does not move and spells its own two candidates here.
43
- */
44
- const STANDARDS_FOLDERS: readonly string[] = [
45
- 'standards',
46
- join('.claude', 'standards'),
47
- ]
48
-
49
24
  /**
50
25
  * `unknown-kind` is raised at the argument boundary rather than by the walk, and
51
26
  * it sits here because both reach a caller through the same `reason` field. A
@@ -60,7 +35,6 @@ export const FINDING_KINDS = [
60
35
  'name-malformed',
61
36
  'title-missing',
62
37
  'section-missing',
63
- 'scope-unanchored',
64
38
  'entry-unreasoned',
65
39
  'suggestion-missing',
66
40
  'question-unanswerable',
@@ -103,10 +77,6 @@ export type ValidateOutcome = ValidateReport | ValidateRefused
103
77
 
104
78
  /** Every folder a kind would accept, whether or not it is on disk. */
105
79
  export function recordDirs(root: string, kind: RecordKind): string[] {
106
- if (kind === 'standards') {
107
- return STANDARDS_FOLDERS.map((folder) => join(root, folder))
108
- }
109
-
110
80
  return resolveRecordDirs(root, kind)
111
81
  }
112
82
 
@@ -114,19 +84,8 @@ export function recordDirs(root: string, kind: RecordKind): string[] {
114
84
  * The folder a kind reads. The first candidate on disk wins, and the creation
115
85
  * default stands in when none exists, so a refusal and a test fixture both name
116
86
  * the location the kind would be written to.
117
- *
118
- * The default is where this parts company with `dirs[0]`, which the record
119
- * kinds can no longer take: their first candidate is the root the move lands at
120
- * and nothing creates there yet, so a fallback reading it would name a folder no
121
- * verb would ever write. Standards has no such split, since it is tracked and
122
- * its first candidate is where it is authored.
123
87
  */
124
88
  export function recordsDir(root: string, kind: RecordKind): string {
125
- if (kind === 'standards') {
126
- const dirs = recordDirs(root, kind)
127
- return dirs.find((dir) => existsSync(dir)) ?? dirs[0]
128
- }
129
-
130
89
  return resolveRecordDir(root, kind)
131
90
  }
132
91
 
@@ -134,18 +93,6 @@ export function isRecordKind(value: string): value is RecordKind {
134
93
  return (RECORD_KINDS as readonly string[]).includes(value)
135
94
  }
136
95
 
137
- /**
138
- * Whether a kind's folder is shared session scratch at the main worktree root.
139
- *
140
- * The five record folders are, so every session validates the records every
141
- * other session reads. The corpus is tracked instead, so a linked worktree holds
142
- * its own edited copy, and defaulting that kind to the main root would report on
143
- * a tree the session never touched and say nothing about which one it read.
144
- */
145
- export function isSharedScratch(kind: RecordKind): boolean {
146
- return kind !== 'standards'
147
- }
148
-
149
96
  const NONE_IDENTIFIED = 'None identified.'
150
97
  const NUMBERED_FILE = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
151
98
 
@@ -920,127 +867,6 @@ async function checkWorkspace(dir: string, slug: string): Promise<Finding[]> {
920
867
  ]
921
868
  }
922
869
 
923
- const STANDARD_INDEX = 'index.md'
924
- const STANDARD_FIELDS = ['title', 'description'] as const
925
-
926
- const DOES_NOT_GOVERN = 'Does not govern:'
927
-
928
- /**
929
- * The words a governed path offers a filename. Each segment gives its own word
930
- * and, where it carries a prefix or a placeholder, the parts either side of a
931
- * hyphen, so `.canon/tasks/session-<slug>.md` offers `tasks` and `session`.
932
- *
933
- * A dotted segment gives nothing. It names the folder holding the artifact
934
- * rather than the artifact, and a standard named for it would pass this check
935
- * while naming the container every sibling shares.
936
- */
937
- export function pathWords(path: string): string[] {
938
- const words: string[] = []
939
-
940
- for (const segment of path.split('/')) {
941
- if (segment.startsWith('.')) continue
942
-
943
- const stem = segment.replace(/\.[a-z]+$/i, '').toLowerCase()
944
- words.push(stem)
945
- if (stem.includes('-')) words.push(...stem.split('-'))
946
- }
947
-
948
- return words.filter((word) => /^[a-z]+$/.test(word))
949
- }
950
-
951
- /**
952
- * Accepts the singular and the plural of one word. A standard over a single
953
- * document is named for the document and one over a folder of them is named for
954
- * either, and picking a side would report a conforming half of the corpus.
955
- */
956
- function namesWord(stem: string, word: string): boolean {
957
- return stem === word || `${stem}s` === word || stem === `${word}s`
958
- }
959
-
960
- function checkStandardName(name: string, statement: string): Finding[] {
961
- const paths = governedPaths(statement)
962
-
963
- // The marker is read only where the first sentence backticks nothing, which
964
- // is the catalog's own rule. A statement naming a path publishes that path
965
- // however the rest of the statement describes itself.
966
- if (paths.length === 0) {
967
- if (statement.includes(ATTRIBUTE_MARKER)) return []
968
-
969
- return [
970
- finding(
971
- 'scope-unanchored',
972
- name,
973
- name,
974
- 'backticks no path in its first scope sentence and does not call itself an attribute standard, so it names no artifact to be named for.',
975
- ),
976
- ]
977
- }
978
-
979
- const stem = name.replace(/\.md$/, '')
980
- const words = paths.flatMap(pathWords)
981
-
982
- if (words.some((word) => namesWord(stem, word))) return []
983
-
984
- return [
985
- finding(
986
- 'name-malformed',
987
- name,
988
- name,
989
- `names no part of ${paths.join(', ')}, which is what it governs. A rename reaches every target that installed the corpus and every surface citing it by bare filename.`,
990
- ),
991
- ]
992
- }
993
-
994
- export function checkStandard(name: string, text: string): Finding[] {
995
- const findings: Finding[] = []
996
- const frontmatter = parseFrontmatter(text)
997
-
998
- const missing = STANDARD_FIELDS.filter(
999
- (field) => !readField(frontmatter, field),
1000
- )
1001
-
1002
- if (missing.length > 0) {
1003
- findings.push(
1004
- finding(
1005
- 'frontmatter-incomplete',
1006
- name,
1007
- name,
1008
- `carries no ${missing.join(' and no ')}.`,
1009
- ),
1010
- )
1011
- }
1012
-
1013
- const scope = readScope(text)
1014
-
1015
- // The name derives from the scope statement, so an absent section leaves
1016
- // nothing to derive against. Reporting the name as well would name one defect
1017
- // twice and point the fix at the wrong file.
1018
- if (!scope) {
1019
- return [
1020
- ...findings,
1021
- finding(
1022
- 'section-missing',
1023
- name,
1024
- '## Scope',
1025
- 'is absent, so the standard claims no jurisdiction and can refuse no rule.',
1026
- ),
1027
- ]
1028
- }
1029
-
1030
- if (!scope.lines.some((line) => line.trim().startsWith(DOES_NOT_GOVERN))) {
1031
- findings.push(
1032
- finding(
1033
- 'section-missing',
1034
- name,
1035
- DOES_NOT_GOVERN,
1036
- 'is absent from the scope section, so no boundary names the owner it hands off to.',
1037
- ),
1038
- )
1039
- }
1040
-
1041
- return [...findings, ...checkStandardName(name, scope.statement)]
1042
- }
1043
-
1044
870
  /**
1045
871
  * The three kinds whose records are folders rather than files. Keyed by kind so
1046
872
  * a seventh arrives as an entry here and the compiler names the walk it owes,
@@ -1048,7 +874,7 @@ export function checkStandard(name: string, text: string): Finding[] {
1048
874
  */
1049
875
  const FOLDER_CHECK: Readonly<
1050
876
  Record<
1051
- Exclude<RecordKind, 'plans' | 'standards'>,
877
+ Exclude<RecordKind, 'plans'>,
1052
878
  (dir: string, slug: string) => Promise<Finding[]>
1053
879
  >
1054
880
  > = {
@@ -1066,9 +892,8 @@ async function validateFiles(
1066
892
  dir: string,
1067
893
  kind: RecordKind,
1068
894
  check: (name: string, text: string) => Finding[],
1069
- skip: (file: string) => boolean = () => false,
1070
895
  ): Promise<ValidateReport> {
1071
- const files = (await listMarkdown(dir)).filter((file) => !skip(file))
896
+ const files = await listMarkdown(dir)
1072
897
 
1073
898
  const perFile = await Promise.all(
1074
899
  files.map(async (file) =>
@@ -1080,12 +905,9 @@ async function validateFiles(
1080
905
  }
1081
906
 
1082
907
  /**
1083
- * Reports what every record in one folder claims against the shape its standard
1084
- * fixes. It writes nothing whichever kind runs, and the reason differs by kind.
1085
- * A session record is per-machine scratch with no history behind it, so a repair
1086
- * that guessed wrong could not be undone. A standard installs into every target
1087
- * and is cited by bare filename, so a rename costs more than the file move it
1088
- * looks like.
908
+ * Reports what every record in one folder claims against the shape its reference
909
+ * fixes. It writes nothing, since a session record is per-machine scratch with
910
+ * no history behind it, so a repair that guessed wrong could not be undone.
1089
911
  */
1090
912
  export async function validateRecords(
1091
913
  root: string,
@@ -1102,16 +924,6 @@ export async function validateRecords(
1102
924
 
1103
925
  if (kind === 'plans') return validateFiles(dir, kind, checkPlan)
1104
926
 
1105
- // The walk stays flat, matching install and the catalog.
1106
- if (kind === 'standards') {
1107
- return validateFiles(
1108
- dir,
1109
- kind,
1110
- checkStandard,
1111
- (file) => file === STANDARD_INDEX,
1112
- )
1113
- }
1114
-
1115
927
  const folders = await listFolders(dir)
1116
928
  const check = FOLDER_CHECK[kind]
1117
929
  const perFolder = await Promise.all(
@@ -701,13 +701,16 @@ function canonVersion(): string {
701
701
  }
702
702
 
703
703
  /**
704
- * Adds the baseline as a detached worktree of the base ref, so it carries full
705
- * history for the scenarios that read it, and returns a remover that is safe to
706
- * call twice. `node_modules` is linked from the working checkout, since the
707
- * baseline runs the same CLI and carries no install of its own.
704
+ * Adds a detached worktree of `ref` at `path`, so it carries full history for
705
+ * the scenarios that read it, and returns a remover that is safe to call twice.
706
+ * `node_modules` is linked from the working checkout, since the copy runs the
707
+ * same CLI and carries no install of its own.
708
708
  */
709
- function addBaseline(root: string, workDir: string, ref: string): () => void {
710
- const path = join(workDir, 'base')
709
+ export function addDetachedWorktree(
710
+ root: string,
711
+ path: string,
712
+ ref: string,
713
+ ): () => void {
711
714
  execFileSync('git', ['worktree', 'add', '--detach', path, ref], {
712
715
  cwd: root,
713
716
  stdio: ['ignore', 'ignore', 'pipe'],
@@ -761,7 +764,11 @@ export async function runEquivalence(
761
764
 
762
765
  let removeBaseline: () => void
763
766
  try {
764
- removeBaseline = addBaseline(options.root, workDir, options.base)
767
+ removeBaseline = addDetachedWorktree(
768
+ options.root,
769
+ join(workDir, 'base'),
770
+ options.base,
771
+ )
765
772
  } catch (error) {
766
773
  // An unknown ref must not leave an empty scratch folder behind.
767
774
  if (options.out === undefined)
@@ -220,11 +220,6 @@ function install(
220
220
  fail(`Could not install ${label} into the sandbox.`)
221
221
  }
222
222
 
223
- /**
224
- * No standards install. The corpus installs into no target, so a scenario
225
- * driving a skill that reads one takes the `canon standards <name>` path a real
226
- * project takes.
227
- */
228
223
  function setupAssets(
229
224
  root: string,
230
225
  sandbox: string,
@@ -13,8 +13,7 @@ import { join } from 'node:path'
13
13
  *
14
14
  * What it does not cover is the plugin, which a marketplace install reads live
15
15
  * from `claude/` rather than from a tarball. That folder is a `files` entry
16
- * too, and its `standards` symlink resolves into a tree the field lists in its
17
- * own right, so both routes land inside the same corpus.
16
+ * too, so both routes land inside the same corpus.
18
17
  */
19
18
  export type ShipEntries =
20
19
  /** The field declares a corpus, which is what this check reads. */
@@ -200,8 +200,7 @@ export function resolveWithin(
200
200
  /**
201
201
  * Re-tests containment after following symlinks. `resolveWithin` is lexical
202
202
  * and `resolve` does not follow a link, so a link inside the root clears that
203
- * test while the file it points at sits outside. This repository is a live
204
- * instance, since `claude/standards` is a link out of `claude/`.
203
+ * test while the file it points at sits outside.
205
204
  *
206
205
  * Only a path that exists is checked, because a link can be followed only once
207
206
  * there is something on the other end, and a path that resolves to nothing is
@@ -10,8 +10,7 @@ import { isMarked } from '@/sweep/exempt-marker'
10
10
  * on purpose rather than by omission. What decides membership here is whether
11
11
  * anything serves the file to a reader who does not hold this repository: a
12
12
  * skill body loads into a session, a docs page is read through `canon docs`, a
13
- * standard through `canon standards`, a rule through a glob match, and a seed
14
- * through an install. A `src/` doc comment
13
+ * rule through a glob match, and a seed through an install. A `src/` doc comment
15
14
  * is read by someone working on the toolkit, in this checkout, where every
16
15
  * number resolves.
17
16
  *
@@ -19,18 +18,12 @@ import { isMarked } from '@/sweep/exempt-marker'
19
18
  * `src/design/base.css` and `src/design/tokens.ts` carry sixteen values shaped
20
19
  * `#191512`, which no width or boundary rule separates from a pull request
21
20
  * number, so telling them apart would need the line's syntactic position.
22
- *
23
- * `claude/standards` is a symlink into one of the roots below, and a walk that
24
- * followed it would read that corpus twice and report every finding in it
25
- * under two paths. Nothing is lost by not following, since it is a corpus here
26
- * in its own right.
27
21
  */
28
22
  export const SHIPPED_CORPORA = [
29
23
  'claude',
30
24
  'docs',
31
25
  'governance',
32
26
  'scripts',
33
- 'standards',
34
27
  'tooling',
35
28
  ] as const
36
29
 
@@ -146,37 +139,15 @@ const SAME_REPOSITORY =
146
139
  const DOCS_PATH = /(?<![\w./-])docs\/[^\s`)\]]*\.md\b/g
147
140
 
148
141
  /**
149
- * A bare `standards/<name>.md` citation from a body under `claude/skills/`,
150
- * which `598-authoring-layout.md` fixes as the broken form there:
151
- * `${CLAUDE_SKILL_DIR}` is what resolves off the `claude/standards` symlink
152
- * in every plugin cache, and a raw path has nothing to expand it.
153
- *
154
- * Scoped to that one corpus rather than every `isShippedCorpus` reads,
155
- * because `docs/agents/` and `docs/workflow/` carry the identical
156
- * `standards/<name>.md` shape correctly: a docs page resolves from this
157
- * checkout's own root rather than through a skill's `${CLAUDE_SKILL_DIR}`,
158
- * so the same string is the fix in one corpus and the defect in the other.
159
- * Widening the pattern to every corpus turns it into a mass false-positive
160
- * over the dozens of correct citations those two folders carry.
161
- *
162
- * The same leading-boundary discipline as `DOCS_PATH` excludes the already
163
- * correct form: a `/` sits ahead of `standards` in
164
- * `${CLAUDE_SKILL_DIR}/../../standards/<name>.md`, which the negative
165
- * lookbehind rejects, so a citation already rewritten to the resolving form
166
- * does not fail this check a second time.
142
+ * A call to `canon standards`, a verb no installed CLI carries any more. A
143
+ * shipped body naming it sends a target reader to an unknown-command error,
144
+ * so every corpus reports it, `docs/` included. A page saying the verb is gone
145
+ * carries the reference marker on that line.
167
146
  *
168
- * Reported unless `isStandardsPathReportable` reads the match as a
169
- * deliberate placeholder rather than a real citation. A body illustrating
170
- * the shape a project's own `standards/<name>.md` takes writes a bracketed
171
- * token this pattern also matches, such as `standards/<slug>.md`
172
- * in a skill that drafts one, which names no real file. A bare,
173
- * non-bracketed match now reports whether or not the file exists, unlike
174
- * `DOCS_PATH`, which still gates on existence: a same-repository citation
175
- * under this corpus names no target project's own tree the way a `docs/`
176
- * path can, so shape alone separates a placeholder from a broken citation
177
- * here with no false-positive class existence was catching.
147
+ * The trailing lookahead keeps a hyphenated token such as a `standards-audit`
148
+ * page name from matching, since that names a file rather than a call.
178
149
  */
179
- const STANDARDS_PATH = /(?<![\w./-])standards\/[^\s`)\]]*\.md\b/g
150
+ const STANDARDS_CALL = /\bcanon standards(?![\w-])/g
180
151
 
181
152
  /**
182
153
  * A phase-label-shaped token: exactly two numeric groups, with a negative
@@ -223,7 +194,7 @@ export interface ShippedReference {
223
194
  | 'pull-request'
224
195
  | 'commit'
225
196
  | 'docs-path'
226
- | 'standards-path'
197
+ | 'retired-verb'
227
198
  | 'phase-label'
228
199
  | 'rule-path'
229
200
  /** The reference as written, so a report names the token to qualify. */
@@ -272,50 +243,31 @@ function isDocsPathReportable(
272
243
  }
273
244
 
274
245
  /**
275
- * Whether `file` sits in the one corpus `STANDARDS_PATH` and `RULE_PATH`
276
- * both gate: a shipped skill body, minus its own `REQUIREMENT.md`.
246
+ * Whether `file` sits in the one corpus `RULE_PATH` gates: a shipped skill
247
+ * body, minus its own `REQUIREMENT.md`.
277
248
  *
278
- * Both patterns share this scope because both bans live in
279
- * `598-authoring-layout.md`, stated for the same reader: a session loading
280
- * the `SKILL.md` body a target actually receives. `REQUIREMENT.md` is
281
- * excluded for the reason that file states there, since a maintainer or an
282
- * audit command reads it rather than a session loading it, so neither
283
- * resolver rule this pair enforces ever applies to it. One predicate serves
284
- * both call sites rather than two copies drifting apart with nothing
285
- * comparing them.
249
+ * The ban lives in `598-authoring-layout.md`, stated for one reader: a
250
+ * session loading the `SKILL.md` body a target actually receives.
251
+ * `REQUIREMENT.md` is excluded for the reason that file states there, since a
252
+ * maintainer or an audit command reads it rather than a session loading it,
253
+ * so the resolver rule never applies to it.
286
254
  */
287
255
  function isSkillBodyScope(file: string): boolean {
288
256
  return file.startsWith('claude/skills/') && !file.endsWith('/REQUIREMENT.md')
289
257
  }
290
258
 
291
- /**
292
- * Whether a `STANDARDS_PATH` match should be reported: every match that is
293
- * not a placeholder, whether or not the named file exists.
294
- *
295
- * Existence used to gate this the same way `isDocsPathReportable` gates its
296
- * corpus, which conflated a deliberate placeholder with a genuinely broken,
297
- * real-looking citation: both fail existence and both passed muted. A bare
298
- * `standards/<name>.md` names no target-project tree the way a `docs/` path
299
- * can, so shape alone separates the two classes here with no false-positive
300
- * class existence was catching.
301
- */
302
- function isStandardsPathReportable(path: string): boolean {
303
- return !isPlaceholderPath(path)
304
- }
305
-
306
259
  /**
307
260
  * Every reference in one shipped file that no marker mutes.
308
261
  *
309
262
  * The corpus walk is deliberately absent, which lets most of the shape be
310
263
  * tested against a string rather than against a fixture. That is the seam
311
264
  * `headingCitationsIn` draws in `src/claude/skills-headings.ts` and `citationsIn`
312
- * draws in `skills-reach.ts`. `DOCS_PATH` and `STANDARDS_PATH` are the two
313
- * patterns that still need a filesystem, since resolving against this
314
- * checkout is the only thing that separates a real citation from an
315
- * illustration for either, so `referencesIn` takes `root` as the one
316
- * caller-supplied exception to that rule. `root` is required rather than
317
- * defaulted, since a caller that dropped it silently would report zero
318
- * findings for both rather than raising, which is the wrong failure
265
+ * draws in `skills-reach.ts`. `DOCS_PATH` is the one pattern that still
266
+ * needs a filesystem, since resolving against this checkout is the only thing
267
+ * that separates a real citation from an illustration, so `referencesIn`
268
+ * takes `root` as the one caller-supplied exception to that rule. `root` is
269
+ * required rather than defaulted, since a caller that dropped it silently
270
+ * would report zero findings rather than raising, which is the wrong failure
319
271
  * direction for a gate.
320
272
  *
321
273
  * The unit is the match rather than the line, unlike those two, because one
@@ -375,16 +327,13 @@ export function referencesIn(
375
327
  })
376
328
  }
377
329
 
378
- if (isSkillBodyScope(file)) {
379
- for (const match of line.matchAll(STANDARDS_PATH)) {
380
- if (!isStandardsPathReportable(match[0])) continue
381
- references.push({
382
- file,
383
- line: index + 1,
384
- kind: 'standards-path',
385
- text: match[0],
386
- })
387
- }
330
+ for (const match of line.matchAll(STANDARDS_CALL)) {
331
+ references.push({
332
+ file,
333
+ line: index + 1,
334
+ kind: 'retired-verb',
335
+ text: match[0],
336
+ })
388
337
  }
389
338
 
390
339
  for (const match of line.matchAll(PHASE_LABEL)) {
package/src/sync/check.ts CHANGED
@@ -3,9 +3,10 @@ import { basename, join, sep } from 'node:path'
3
3
  import { execa } from 'execa'
4
4
  import { gitEnv } from '@/git/env'
5
5
  import { createDesignAdapter, DESIGN_INSTALL_DIR } from '@/design/adapter'
6
- import { createGovAdapter, rulesSourceDir } from '@/gov/adapter'
6
+ import { createGovAdapter } from '@/gov/adapter'
7
7
  import { successorResolver } from '@/gov/renames'
8
- import { loadGovStack, resolveMissingRules, resolveRules } from '@/gov/stacks'
8
+ import { lookupRules } from '@/gov/install'
9
+ import { resolveMissingRules, resolveRules } from '@/gov/stacks'
9
10
  import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
10
11
  import {
11
12
  collectSuperseded,
@@ -576,7 +577,8 @@ export function readInstalledRules(
576
577
  * not already hold.
577
578
  *
578
579
  * The band test is the entitlement filter, and `bands` carries two sources: the
579
- * folders the target already holds, and the folders the base stack takes whole.
580
+ * folders the target already holds, and the folders holding a rule the base
581
+ * stack resolves to.
580
582
  * A rule authored under `lang/` or `ui/` is named by an individual stack, so it
581
583
  * belongs to some targets and not others, and listing every added file would
582
584
  * tell a base consumer about rules it was never entitled to. The test
@@ -615,27 +617,29 @@ export function selectNewRules(
615
617
  }
616
618
 
617
619
  /**
618
- * Band folders the base stack takes whole. Every governance stack extends base,
619
- * so a rule authored under one of these is entitled to every target.
620
+ * Band folders holding a rule the base stack resolves to. Every governance
621
+ * stack extends base, so a new rule authored beside one of base's rules is
622
+ * reported to every target the fallback below reads.
620
623
  *
621
624
  * This is what covers a band no target can carry yet. Entitlement is otherwise
622
- * read off folders the target already holds, and a folder added to base later
625
+ * read off folders the target already holds, and a band base reaches later
623
626
  * exists in no installed tree, so without this the rules inside it would reach
624
- * nobody. Read from the stack file rather than fixed, so that addition needs no
625
- * code change here.
627
+ * nobody. Read through `resolveRules` rather than off the stack's entries,
628
+ * since base names its rules one by one and an entry is a slug rather than a
629
+ * folder.
626
630
  *
627
- * A folder only a leaf stack names is deliberately absent. It is entitled to
631
+ * A folder only a leaf stack reaches is deliberately absent. It is entitled to
628
632
  * some targets and not others, which is the distinction the band test makes and
629
633
  * the installed tree is the only evidence of.
630
634
  */
631
635
  export function baseBands(root: string): Set<string> {
632
- const stack = loadGovStack(root, 'base')
633
- if (stack === undefined) return new Set()
636
+ const resolution = resolveRules(root, 'base')
637
+ if (!resolution.ok) return new Set()
634
638
 
635
639
  return new Set(
636
- stack.rules.filter((entry) =>
637
- isDirectory(join(rulesSourceDir(root), entry)),
638
- ),
640
+ lookupRules(root, resolution.rules)
641
+ .found.map((source) => source.subdir.split(sep)[0] ?? '')
642
+ .filter((band) => band !== ''),
639
643
  )
640
644
  }
641
645
 
@@ -20,13 +20,7 @@ import {
20
20
  readPackage,
21
21
  serializePackage,
22
22
  } from '@/tooling/package'
23
- import {
24
- hasSpellConfig,
25
- isSubfolderTarget,
26
- isWithheldInSubfolder,
27
- SUBFOLDER_SPELL_CONFIG,
28
- subfolderSpellConfig,
29
- } from '@/tooling/subfolder'
23
+ import { isSubfolderTarget, isWithheldInSubfolder } from '@/tooling/subfolder'
30
24
  import { logAdd, logRemove, logStep, logWarn } from '@/cli/ui'
31
25
 
32
26
  export async function injectConfigs(
@@ -72,30 +66,13 @@ async function writeSeed(src: string, dest: string): Promise<void> {
72
66
  }
73
67
 
74
68
  /**
75
- * Copies a seed when the target lacks it. When the target already has one and
76
- * the seed is a `.txt` word list, missing lines are appended and the file is
77
- * re-sorted. Every other existing file is left untouched.
69
+ * Copies a seed when the target lacks it and leaves an existing file untouched.
78
70
  */
79
- async function mergeSeedFile(src: string, dest: string): Promise<void> {
71
+ async function copySeedIfMissing(src: string, dest: string): Promise<void> {
80
72
  await mkdir(dirname(dest), { recursive: true })
73
+ if (existsSync(dest)) return
81
74
 
82
- if (!existsSync(dest)) {
83
- await writeSeed(src, dest)
84
- return
85
- }
86
-
87
- if (!src.endsWith('.txt')) return
88
-
89
- const existing = (await readFile(dest, 'utf8')).split('\n')
90
- const incoming = (await readFile(src, 'utf8')).split('\n')
91
- const missing = incoming.filter(
92
- (word) => word !== '' && !existing.includes(word),
93
- )
94
-
95
- if (missing.length === 0) return
96
-
97
- const merged = [...existing.filter((line) => line !== ''), ...missing].sort()
98
- await writeFile(dest, `${merged.join('\n')}\n`)
75
+ await writeSeed(src, dest)
99
76
  }
100
77
 
101
78
  export async function injectSeeds(
@@ -116,20 +93,12 @@ export async function injectSeeds(
116
93
  logStep(`Applying ${manifest.name} seeds`)
117
94
  for (const rel of files) {
118
95
  const dest = resolveSurfacePath(target, rel)
119
- await mergeSeedFile(join(manifest.seedsDir, rel), dest)
96
+ await copySeedIfMissing(join(manifest.seedsDir, rel), dest)
120
97
  logAdd(relative(target, dest))
121
98
  applied.push(rel)
122
99
  }
123
100
  }
124
101
 
125
- const spellConfig = isSubfolder ? subfolderSpellConfig(chain) : undefined
126
- if (spellConfig !== undefined && !hasSpellConfig(target)) {
127
- logStep('Applying subfolder spell config')
128
- await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
129
- logAdd(SUBFOLDER_SPELL_CONFIG)
130
- applied.push(SUBFOLDER_SPELL_CONFIG)
131
- }
132
-
133
102
  return applied
134
103
  }
135
104
 
@@ -28,9 +28,8 @@ function toolingSourceDir(root: string): string {
28
28
  }
29
29
 
30
30
  /**
31
- * The roots a stack reference resolves against, mirroring `standardRoots` in
32
- * `src/standards/read.ts`. No stack reference installs into a project any
33
- * more, so the package corpus is the only root that answers there. The
31
+ * The roots a stack reference resolves against. No stack reference installs
32
+ * into a project any more, so the package corpus is the only root that answers there. The
34
33
  * authoring root stays ahead of it for the toolkit's own repository, which
35
34
  * authors the corpus this reads, and for a project authoring stacks of its
36
35
  * own under the same layout.