@erclx/canon 4.126.0 → 5.1.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 (107) hide show
  1. package/README.md +5 -7
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/verb-absent.md +0 -1
  4. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
  5. package/claude/skills/canon-feedback/SKILL.md +2 -2
  6. package/claude/skills/canon-operator/REQUIREMENT.md +2 -2
  7. package/claude/skills/canon-operator/SKILL.md +3 -3
  8. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -0
  9. package/claude/skills/codebase-layout/SKILL.md +6 -0
  10. package/claude/skills/create-rule/REQUIREMENT.md +1 -1
  11. package/claude/skills/create-standard/REQUIREMENT.md +0 -2
  12. package/claude/skills/decision-escalate/REQUIREMENT.md +1 -2
  13. package/claude/skills/draft-context/REQUIREMENT.md +1 -1
  14. package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
  15. package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
  16. package/claude/skills/git-pr/references/task-number.md +2 -0
  17. package/claude/skills/plan-intake/SKILL.md +1 -1
  18. package/claude/skills/target-setup/REQUIREMENT.md +2 -2
  19. package/claude/skills/target-setup/SKILL.md +1 -1
  20. package/claude/skills/target-setup/references/detect.md +1 -1
  21. package/claude/skills/target-setup/references/indexes.md +1 -5
  22. package/claude/skills/task-board/references/archive.md +1 -0
  23. package/docs/agents/commands.md +1 -3
  24. package/docs/agents/counts.md +1 -2
  25. package/docs/agents/docs.md +1 -1
  26. package/docs/agents/drift-surfaces.md +6 -4
  27. package/docs/agents/index.md +1 -1
  28. package/docs/agents/indexes.md +1 -1
  29. package/docs/agents/install-and-sync.md +1 -8
  30. package/docs/agents/markdown-audit.md +2 -2
  31. package/docs/agents/restated.md +7 -5
  32. package/docs/agents/review-classification.md +1 -2
  33. package/docs/agents/scripting.md +2 -3
  34. package/docs/agents/skills-reach.md +3 -3
  35. package/docs/agents/state-scoped-risk.md +1 -1
  36. package/docs/agents/tasks-archive.md +1 -1
  37. package/docs/agents/tasks.md +4 -0
  38. package/docs/target/index.md +4 -4
  39. package/docs/target/{target-migrations.md → migrations.md} +17 -1
  40. package/docs/target/{target-projects.md → projects.md} +5 -7
  41. package/docs/target/{target-scenarios.md → scenarios.md} +1 -1
  42. package/docs/target/{target-sync.md → sync.md} +4 -2
  43. package/docs/workflow/ai-workflow.md +3 -7
  44. package/docs/workflow/operating-model.md +4 -4
  45. package/docs/workflow/parallel-features.md +1 -1
  46. package/docs/workflow/skill-map.md +1 -2
  47. package/docs/workflow/visual-design-workflow.md +5 -5
  48. package/docs/workflow/zshrc-aliases.md +1 -1
  49. package/package.json +1 -2
  50. package/scripts/core/check-plugin-boundary.sh +1 -1
  51. package/scripts/core/install-check.sh +3 -4
  52. package/scripts/core/regen-hero.sh +2 -9
  53. package/src/autoship/paths.ts +3 -5
  54. package/src/claude/cases/authoring.ts +1 -5
  55. package/src/claude/skills-reach.ts +1 -2
  56. package/src/cli.ts +0 -2
  57. package/src/commands/feedback-format.ts +0 -1
  58. package/src/commands/gov.ts +8 -7
  59. package/src/commands/labels.ts +3 -3
  60. package/src/commands/markdown.ts +2 -2
  61. package/src/commands/tasks.ts +82 -2
  62. package/src/counts/catalogs.ts +0 -16
  63. package/src/counts/scan.ts +1 -1
  64. package/src/docs/read.ts +25 -25
  65. package/src/gate/measures.ts +52 -0
  66. package/src/gate/stages.ts +9 -0
  67. package/src/gov/restated.ts +60 -22
  68. package/src/help.ts +0 -2
  69. package/src/init/steps.ts +1 -1
  70. package/src/labels/format.ts +2 -2
  71. package/src/migrate/skill-names.ts +4 -5
  72. package/src/secrets/shipped.ts +2 -2
  73. package/src/serve/static.ts +1 -2
  74. package/src/shipped/references.ts +7 -8
  75. package/src/sync/engine.ts +2 -2
  76. package/src/tasks/archive.ts +30 -0
  77. package/src/tasks/record.ts +106 -9
  78. package/standards/commit.md +1 -1
  79. package/standards/context.md +2 -0
  80. package/standards/docs.md +1 -0
  81. package/standards/index.md +0 -1
  82. package/standards/pr.md +1 -1
  83. package/standards/skill.md +1 -2
  84. package/standards/tasks.md +2 -2
  85. package/standards/wiki.md +2 -1
  86. package/tooling/base/reference.md +2 -2
  87. package/tooling/claude/reference.md +1 -1
  88. package/claude/skills/create-snippet/REQUIREMENT.md +0 -39
  89. package/claude/skills/create-snippet/SKILL.md +0 -30
  90. package/scripts/snippets/create.sh +0 -156
  91. package/snippets/align.md +0 -12
  92. package/snippets/claude/decision-memo.md +0 -39
  93. package/snippets/claude/feature-recap.md +0 -19
  94. package/snippets/claude/figma-steps.md +0 -24
  95. package/snippets/compact-summary.md +0 -5
  96. package/snippets/decision-help.md +0 -6
  97. package/snippets/meta-prompt.md +0 -14
  98. package/snippets/research-prompt.md +0 -7
  99. package/snippets/session-notes.md +0 -11
  100. package/snippets/snippets.toml +0 -5
  101. package/snippets/step-by-step.md +0 -10
  102. package/snippets/web-research.md +0 -21
  103. package/src/commands/snippets.ts +0 -90
  104. package/src/snippets/categories.ts +0 -66
  105. package/src/snippets/list.ts +0 -32
  106. package/src/snippets/presets.ts +0 -50
  107. package/standards/snippets.md +0 -76
@@ -64,6 +64,8 @@ const EXIT_FINDINGS = 2
64
64
  /** Matches `trunk.ts`'s bound on a git subprocess this verb also shells out to. */
65
65
  const GIT_TIMEOUT_MS = 10_000
66
66
 
67
+ const TRUNK_BRANCHES: readonly string[] = ['main', 'master']
68
+
67
69
  interface ArchiveCommandOptions {
68
70
  readonly json?: boolean
69
71
  readonly pullRequest?: string
@@ -164,6 +166,10 @@ export function register(program: Command): void {
164
166
  'folder that does not resolve, or a destination already taken, moves',
165
167
  'nothing and refuses nothing.',
166
168
  '',
169
+ 'With --pull-request, it refuses earlier-slice when a later number is',
170
+ 'listed, and pending-branch while a Pending branch: line names a branch',
171
+ 'whose own pull request is not recorded yet. A stem skips both.',
172
+ '',
167
173
  'Exit codes:',
168
174
  ' 0 the task was archived',
169
175
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -420,6 +426,10 @@ export function register(program: Command): void {
420
426
  'list is replaced. The board is shared scratch, so a linked worktree',
421
427
  'records against the same board.',
422
428
  '',
429
+ 'It also drops the current branch, and each name it was renamed from,',
430
+ 'from the Pending branch: line, even when the number was already',
431
+ 'listed, and removes the line once empty.',
432
+ '',
423
433
  'Examples:',
424
434
  ' canon tasks pull-request 673 v28.1-trigger-escalation',
425
435
  ' canon tasks pull-request 673 --plan worktree-scratch-routing --json',
@@ -497,6 +507,11 @@ export function register(program: Command): void {
497
507
  'An outcome already closed is reported rather than refused, so a rerun',
498
508
  'against the same positions is safe.',
499
509
  '',
510
+ 'Run from a branch other than main or master, a close that ticks a new',
511
+ 'box names that branch on a Pending branch: line, which holds the task',
512
+ 'against a merge-time archive until canon tasks pull-request records',
513
+ "the branch's own number.",
514
+ '',
500
515
  'Examples:',
501
516
  ' canon tasks outcome v28.1-trigger-escalation --close 1 --close 3',
502
517
  ' canon tasks outcome --plan worktree-scratch-routing --close 2 --json',
@@ -644,11 +659,63 @@ async function runPullRequest(
644
659
  }
645
660
 
646
661
  const root = opts.root ?? (await mainWorktreeRoot())
647
- const outcome = await recordPullRequest(root, selector, Number(number))
662
+ const branch = await workingBranch()
663
+ const outcome = await recordPullRequest(
664
+ root,
665
+ selector,
666
+ Number(number),
667
+ branch ? [branch, ...(await formerNames(branch))] : [],
668
+ )
648
669
 
649
670
  return reportPullRequest(outcome, emitJson, root)
650
671
  }
651
672
 
673
+ const RENAME_PATTERN = /^Branch: renamed refs\/heads\/(.+) to refs\/heads\//
674
+
675
+ /**
676
+ * Names the branch carried before each `git branch -m`, read off its reflog,
677
+ * which moves with a rename and records every earlier one. A marker written
678
+ * under an old name is otherwise one nothing ever clears.
679
+ */
680
+ async function formerNames(branch: string): Promise<readonly string[]> {
681
+ const result = await execa(
682
+ 'git',
683
+ [
684
+ '-C',
685
+ process.cwd(),
686
+ 'reflog',
687
+ 'show',
688
+ '--format=%gs',
689
+ `refs/heads/${branch}`,
690
+ ],
691
+ { reject: false, timeout: GIT_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
692
+ )
693
+ if (result.exitCode !== 0) return []
694
+
695
+ return result.stdout
696
+ .split('\n')
697
+ .map((line) => RENAME_PATTERN.exec(line)?.[1])
698
+ .filter((name): name is string => name !== undefined)
699
+ }
700
+
701
+ /**
702
+ * The branch checked out where the command runs, which is where the work is,
703
+ * rather than the board root's. Undefined on trunk, a detached HEAD, or a
704
+ * failed read, where no slice is in flight to hold the task open.
705
+ */
706
+ async function workingBranch(): Promise<string | undefined> {
707
+ const result = await execa(
708
+ 'git',
709
+ ['-C', process.cwd(), 'branch', '--show-current'],
710
+ { reject: false, timeout: GIT_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
711
+ )
712
+
713
+ const branch = result.exitCode === 0 ? result.stdout.trim() : ''
714
+ if (branch.length === 0 || TRUNK_BRANCHES.includes(branch)) return undefined
715
+
716
+ return branch
717
+ }
718
+
652
719
  async function runPlanLink(
653
720
  task: string,
654
721
  plan: string,
@@ -710,7 +777,12 @@ async function runOutcome(
710
777
  }
711
778
 
712
779
  const root = opts.root ?? (await mainWorktreeRoot())
713
- const outcome = await closeOutcomes(root, selector, raw.map(Number))
780
+ const outcome = await closeOutcomes(
781
+ root,
782
+ selector,
783
+ raw.map(Number),
784
+ await workingBranch(),
785
+ )
714
786
 
715
787
  return reportOutcome(outcome, emitJson, root)
716
788
  }
@@ -765,6 +837,7 @@ function reportPullRequest(
765
837
  path: relative(root, outcome.path),
766
838
  pullRequest: outcome.number,
767
839
  action: outcome.action,
840
+ pending: outcome.pending,
768
841
  })}\n`,
769
842
  )
770
843
  return 0
@@ -773,6 +846,9 @@ function reportPullRequest(
773
846
  intro('canon tasks pull-request')
774
847
  logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
775
848
  logInfo(`${outcome.stem} names pull request #${outcome.number}`)
849
+ if (outcome.pending.length > 0) {
850
+ logInfo(`still pending on ${outcome.pending.join(', ')}`)
851
+ }
776
852
  if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
777
853
  outro()
778
854
 
@@ -831,6 +907,7 @@ function reportOutcome(
831
907
  path: relative(root, outcome.path),
832
908
  closed: outcome.closed,
833
909
  alreadyClosed: outcome.alreadyClosed,
910
+ pending: outcome.pending,
834
911
  })}\n`,
835
912
  )
836
913
  return 0
@@ -841,6 +918,9 @@ function reportOutcome(
841
918
 
842
919
  for (const closed of outcome.closed) logAdd(closed)
843
920
  for (const already of outcome.alreadyClosed) logInfo(`${already} (already)`)
921
+ if (outcome.pending.length > 0) {
922
+ logInfo(`pending on ${outcome.pending.join(', ')}`)
923
+ }
844
924
 
845
925
  if (outcome.closed.length > 0) logInfo(relative(root, outcome.path))
846
926
  outro()
@@ -3,7 +3,6 @@ import { join } from 'node:path'
3
3
  import { AUDITS } from '@/audits/catalog'
4
4
  import { listSkills } from '@/claude/skills-list'
5
5
  import { buildGovCatalog } from '@/gov/list'
6
- import { buildSnippetsCatalog } from '@/snippets/list'
7
6
  import { listStandards } from '@/standards/read'
8
7
 
9
8
  export interface Catalog {
@@ -39,16 +38,6 @@ function countCommands(root: string): number | undefined {
39
38
  return [...readFileSync(cli, 'utf8').matchAll(REGISTER_IMPORT)].length
40
39
  }
41
40
 
42
- /**
43
- * The distinct entries across every category, matching what `regen-hero.sh`
44
- * counts. A snippet reachable from two categories is one snippet, not two.
45
- */
46
- function countSnippets(root: string): number {
47
- const catalog = buildSnippetsCatalog(root)
48
- return new Set(catalog.categories.flatMap((category) => category.entries))
49
- .size
50
- }
51
-
52
41
  /**
53
42
  * The closed set of catalogs this sweep counts a document against.
54
43
  *
@@ -75,11 +64,6 @@ export const CATALOGS: readonly Catalog[] = [
75
64
  nouns: ['standard', 'standards'],
76
65
  count: (root) => listStandards(root).length,
77
66
  },
78
- {
79
- id: 'snippets',
80
- nouns: ['snippet', 'snippets'],
81
- count: countSnippets,
82
- },
83
67
  {
84
68
  id: 'commands',
85
69
  nouns: ['command', 'commands'],
@@ -153,7 +153,7 @@ const ARTICLES = ['the', 'a', 'an']
153
153
  * either number.
154
154
  *
155
155
  * The optional word between the number and the noun is what reaches a form
156
- * like `installs 11 shipped snippets` without also reaching past an
156
+ * like `installs 11 shipped rules` without also reaching past an
157
157
  * intervening clause, since a wider gap would start pairing a number in one
158
158
  * clause with a noun in the next. Requiring the noun's own plural form to
159
159
  * double as its match narrows the corpus this reads without hand-listing
package/src/docs/read.ts CHANGED
@@ -56,22 +56,22 @@ export function resolveTopic(
56
56
  }
57
57
 
58
58
  /**
59
- * Names every sub-area file called `<topic>.md`, across both roots. A name
60
- * carried by more than one folder resolves to none of them, since answering a
61
- * bare `overview` with whichever folder sorts first is a confident wrong answer
62
- * where the miss is a listing of what the caller could have typed.
59
+ * Names every sub-area file called `<topic>.md` in the first root whose folders
60
+ * carry one, so the root precedence above holds one folder down as well. A name
61
+ * carried by more than one folder of that root resolves to none of them, since
62
+ * answering a bare `overview` with whichever folder sorts first is a confident
63
+ * wrong answer where the miss is a listing of what the caller could have typed.
63
64
  */
64
65
  function findLeaves(root: string, topic: string): string[] {
65
- const matches: string[] = []
66
-
67
66
  for (const dir of ROOTS) {
68
- for (const folder of catalogedFolders(join(root, dir))) {
69
- const rel = join(dir, folder, `${topic}.md`)
70
- if (existsSync(join(root, rel))) matches.push(rel)
71
- }
67
+ const matches = catalogedFolders(join(root, dir))
68
+ .map((folder) => join(dir, folder, `${topic}.md`))
69
+ .filter((rel) => existsSync(join(root, rel)))
70
+
71
+ if (matches.length > 0) return matches
72
72
  }
73
73
 
74
- return matches
74
+ return []
75
75
  }
76
76
 
77
77
  /**
@@ -100,20 +100,20 @@ export function listTopics(root: string): string[] {
100
100
  const leaves = ROOTS.map((dir) => collectLeaves(root, dir))
101
101
 
102
102
  const taken = new Set(named.flat())
103
- const counts = new Map<string, number>()
104
- for (const name of leaves.flat()) {
105
- counts.set(name, (counts.get(name) ?? 0) + 1)
106
- }
107
-
108
- const reachable = (name: string): boolean =>
109
- !taken.has(name) && counts.get(name) === 1
110
-
111
- return ROOTS.flatMap((_, position) =>
112
- [
113
- ...(named[position] as string[]),
114
- ...(leaves[position] as string[]).filter(reachable),
115
- ].sort(),
116
- )
103
+ const claimed = new Set<string>()
104
+
105
+ return ROOTS.flatMap((_, position) => {
106
+ const here = leaves[position] as string[]
107
+ const reachable = [...new Set(here)].filter(
108
+ (name) =>
109
+ !taken.has(name) &&
110
+ !claimed.has(name) &&
111
+ here.indexOf(name) === here.lastIndexOf(name),
112
+ )
113
+ for (const name of here) claimed.add(name)
114
+
115
+ return [...(named[position] as string[]), ...reachable].sort()
116
+ })
117
117
  }
118
118
 
119
119
  /** The sibling files and split folders of one root, which shadow every leaf. */
@@ -361,6 +361,58 @@ export const documentCeiling: Measure = async (ctx) => {
361
361
  }
362
362
  }
363
363
 
364
+ /**
365
+ * Whether a path's stem, its basename up to the first `.`, repeats its
366
+ * immediate parent folder, either whole or as a `<folder>-` prefix.
367
+ *
368
+ * Exact rather than singular-aware, so `standards/standard.md` passes. A false
369
+ * positive on a gate stage teaches a contributor to route around it, and a
370
+ * widened match is cheap to add once a real echo slips through.
371
+ */
372
+ export function isFolderEchoedName(path: string): boolean {
373
+ const segments = path.split('/')
374
+ if (segments.length < 2) return false
375
+ const folder = segments[segments.length - 2] ?? ''
376
+ const stem = (segments[segments.length - 1] ?? '').split('.')[0] ?? ''
377
+ if (folder === '' || stem === '') return false
378
+ return stem === folder || stem.startsWith(`${folder}-`)
379
+ }
380
+
381
+ /**
382
+ * Every tracked file whose name repeats the folder it sits in, which says the
383
+ * folder's name twice in every path that cites it.
384
+ *
385
+ * Reads paths only, so it runs over the whole tree rather than the markdown
386
+ * corpus. It stays out of `canon markdown audit`, which runs inside every
387
+ * target, where a folder-named file may be a convention the target chose.
388
+ */
389
+ export const folderEchoedNames: Measure = async (ctx) => {
390
+ const files = await listRepositoryFiles(ctx.root)
391
+ if (files === undefined) {
392
+ return {
393
+ emissions: [],
394
+ unmeasured:
395
+ 'The tracked file list could not be read, so no filename was checked.',
396
+ }
397
+ }
398
+
399
+ const echoed = files.filter(isFolderEchoedName)
400
+ if (echoed.length === 0) {
401
+ return {
402
+ emissions: [
403
+ info(
404
+ `No filename repeats its folder across ${plural(files.length, 'tracked file')}`,
405
+ ),
406
+ ],
407
+ }
408
+ }
409
+
410
+ return {
411
+ emissions: echoed.map((path) => warn(`${path} repeats its folder's name`)),
412
+ failure: `${echoed.length === 1 ? 'One tracked file repeats its' : `${echoed.length} tracked files repeat their`} folder's name: ${echoed.join(', ')}. Rename each for what it covers inside the folder, dropping the prefix the folder already states.`,
413
+ }
414
+ }
415
+
364
416
  /**
365
417
  * A second run of the records move should rewrite nothing, and the count is
366
418
  * only knowable once the folders themselves have landed.
@@ -4,6 +4,7 @@ import {
4
4
  captureStamps,
5
5
  clientCommandCitations,
6
6
  documentCeiling,
7
+ folderEchoedNames,
7
8
  markdownBans,
8
9
  type Measure,
9
10
  pluginManifests,
@@ -366,6 +367,14 @@ export const STAGES: readonly Stage[] = [
366
367
  label: 'Document ceiling',
367
368
  checks: [{ kind: 'measure', measure: documentCeiling }],
368
369
  },
370
+ {
371
+ // Unscoped, since any branch can add a file anywhere and one git ls-files
372
+ // costs nothing. Reads paths rather than markdown, so it sits here rather
373
+ // than in the markdown audit that also runs inside every target.
374
+ id: 'folder-echoed-names',
375
+ label: 'Folder-echoed filenames',
376
+ checks: [{ kind: 'measure', measure: folderEchoedNames }],
377
+ },
369
378
  {
370
379
  // A rule citing a file that moved fails silently. The consumed-copy drift
371
380
  // stage passes an authored rule and its copy that are wrong together, and
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { RULE_DIRS } from '@/gov/citations'
3
4
 
4
5
  /** The always-loaded file whose bullets are the subjects this sweep matches. */
5
6
  export const INSTRUCTIONS_REL = 'CLAUDE.md'
@@ -11,15 +12,26 @@ export const SEED_REL = join('tooling', 'claude', 'seeds', 'CLAUDE.md')
11
12
  export const SHIPPED_SKILLS_REL = join('claude', 'skills')
12
13
 
13
14
  /**
14
- * Path-scoped rules, read from the authoring root rather than the consumed
15
- * copy under `.claude/rules/`. A stack takes a whole rule folder, so a bullet
16
- * landing anywhere under here ships to every target the stack reaches, the
17
- * same way a bullet in the always-loaded file or the seed does. That is what
18
- * makes a rule an instruction surface rather than a place a rule is merely
19
- * quoted, and it is why a bullet moved here from the seed still belongs to
20
- * the corpus this sweep reads rather than leaving it.
15
+ * The project's own skill bodies. Here that is the toolkit's internal skills,
16
+ * and in a target it is that project's own, which is equally a place a rule
17
+ * gets restated in prose.
18
+ */
19
+ export const PROJECT_SKILLS_REL = join('.claude', 'skills')
20
+
21
+ /** Every root a candidate body is read from, each reported under its own path. */
22
+ const SKILL_ROOTS: readonly string[] = [SHIPPED_SKILLS_REL, PROJECT_SKILLS_REL]
23
+
24
+ /*
25
+ * Path-scoped rules are read from every root in `RULE_DIRS`, the authoring
26
+ * roots rather than the consumed copy under `.claude/rules/`. A stack takes a
27
+ * whole rule folder, so a bullet landing anywhere under `governance/rules/`
28
+ * ships to every target the stack reaches, the same way a bullet in the
29
+ * always-loaded file or the seed does, and `internal/rules/` loads the same
30
+ * way for every session in this repository. That is what makes a rule an
31
+ * instruction surface rather than a place a rule is merely quoted, and it is
32
+ * why a bullet moved into either root from the seed still belongs to the
33
+ * corpus this sweep reads rather than leaving it.
21
34
  */
22
- export const RULES_REL = join('governance', 'rules')
23
35
 
24
36
  /**
25
37
  * Path pairs whose duplication is deliberate and already recorded.
@@ -174,6 +186,22 @@ const STOPWORDS = new Set([
174
186
  */
175
187
  const PROHIBITIONS = ['never', 'do not', "don't", 'avoid', 'refuse']
176
188
 
189
+ /**
190
+ * A `no` opening a clause or a comma-set phrase directly on a code span, as in
191
+ * `pinned to major tags, no `@latest``.
192
+ *
193
+ * `no` is admitted here and only here, since sitting on a backticked
194
+ * identifier it names the thing ruled out rather than qualifying a verb. The
195
+ * phrase boundary is what keeps description out: `returns no `jq` output` puts
196
+ * the marker after a verb and reports what happens.
197
+ *
198
+ * `not` and `never` stay out. Admitting them turned two agreeing pairs into
199
+ * contradictions, `, not `pwd`` and `, never `@latest``, because the subject
200
+ * each matched spelled its own negation on plain words or in a clause the
201
+ * anchors tied against, so the reading flipped on one side alone.
202
+ */
203
+ const NEGATED_SPAN = /(?:^|[,;:(])\s*no\s+`/
204
+
177
205
  export type RestatedRefusal = 'no-instructions' | 'no-surfaces'
178
206
 
179
207
  export type Restatement = 'mirror' | 'repetition' | 'contradiction'
@@ -315,7 +343,10 @@ function prohibits(text: string): boolean {
315
343
  .replace(/^[^a-z]*/, '')
316
344
  .replace(/^(and|but|so|then|also|however)[\s,]+/, '')
317
345
 
318
- return PROHIBITIONS.some((marker) => opening.startsWith(marker))
346
+ return (
347
+ PROHIBITIONS.some((marker) => opening.startsWith(marker)) ||
348
+ NEGATED_SPAN.test(opening)
349
+ )
319
350
  }
320
351
 
321
352
  /**
@@ -465,7 +496,10 @@ function readBullets(root: string, relative: string): Statement[] {
465
496
  * is reused per file rather than rebuilt for prose, and frontmatter is read
466
497
  * past for the same reason it is: no line there starts with `- `.
467
498
  */
468
- function readRuleFiles(root: string, rulesRoot: string): Statement[] {
499
+ function readRuleFiles(root: string, rulesRel: string): Statement[] {
500
+ const rulesRoot = join(root, rulesRel)
501
+ if (!existsSync(rulesRoot)) return []
502
+
469
503
  const files = [
470
504
  ...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
471
505
  ].sort()
@@ -473,13 +507,13 @@ function readRuleFiles(root: string, rulesRoot: string): Statement[] {
473
507
  return files.flatMap((file) =>
474
508
  readBullets(
475
509
  root,
476
- `${RULES_REL.replaceAll('\\', '/')}/${file.replaceAll('\\', '/')}`,
510
+ `${rulesRel.replaceAll('\\', '/')}/${file.replaceAll('\\', '/')}`,
477
511
  ),
478
512
  )
479
513
  }
480
514
 
481
515
  /**
482
- * Every prose line and bullet in a shipped body.
516
+ * Every prose line and bullet in a skill body under one skills root.
483
517
  *
484
518
  * Wider than the bullet rule above because the motivating case was stated in a
485
519
  * body as a paragraph, so a bullet-only read would miss the one instance this
@@ -487,7 +521,10 @@ function readRuleFiles(root: string, rulesRoot: string): Statement[] {
487
521
  * heading names a section rather than stating a rule, and a fenced block is an
488
522
  * example whose words are the surrounding prose's by construction.
489
523
  */
490
- function readBodyLines(root: string, skillsRoot: string): Candidate[] {
524
+ function readBodyLines(root: string, skillsRel: string): Candidate[] {
525
+ const skillsRoot = join(root, skillsRel)
526
+ if (!existsSync(skillsRoot)) return []
527
+
491
528
  const candidates: Candidate[] = []
492
529
 
493
530
  const files = [
@@ -500,7 +537,7 @@ function readBodyLines(root: string, skillsRoot: string): Candidate[] {
500
537
  for (const file of files) {
501
538
  const posix = file.replaceAll('\\', '/')
502
539
  const skill = posix.split('/')[0]
503
- const relative = `${SHIPPED_SKILLS_REL.replaceAll('\\', '/')}/${posix}`
540
+ const relative = `${skillsRel.replaceAll('\\', '/')}/${posix}`
504
541
 
505
542
  const lines = readFileSync(join(root, relative), 'utf8').split('\n')
506
543
  let fenced = false
@@ -639,8 +676,9 @@ function classify(
639
676
  * defect the sweep exists for, and a recall-first reading can be narrowed from
640
677
  * real output where the reverse cannot.
641
678
  *
642
- * The always-loaded file and every rule each open the search as a subject, and
643
- * the seed, the shipped bodies, and every rule again close it as a candidate.
679
+ * The always-loaded file and every rule under either rule root each open the
680
+ * search as a subject, and the seed, the skill bodies under both skill roots,
681
+ * and every rule again close it as a candidate.
644
682
  * A rule sits on both sides because a stack ships a whole rule folder, so a
645
683
  * bullet duplicated between two rules reaches a target exactly as a bullet
646
684
  * duplicated between the always-loaded file and a rule does, and neither shape
@@ -660,14 +698,14 @@ export function readRestated(root: string): RestatedReport {
660
698
  kind: 'seed' as const,
661
699
  }))
662
700
 
663
- const skillsRoot = join(root, SHIPPED_SKILLS_REL)
664
- const bodies = existsSync(skillsRoot) ? readBodyLines(root, skillsRoot) : []
701
+ const bodies = SKILL_ROOTS.flatMap((skillsRel) =>
702
+ readBodyLines(root, skillsRel),
703
+ )
665
704
  const bodyFiles = new Set(bodies.map((candidate) => candidate.file)).size
666
705
 
667
- const rulesRoot = join(root, RULES_REL)
668
- const ruleStatements = existsSync(rulesRoot)
669
- ? readRuleFiles(root, rulesRoot)
670
- : []
706
+ const ruleStatements = RULE_DIRS.flatMap((rulesRel) =>
707
+ readRuleFiles(root, rulesRel),
708
+ )
671
709
  const rules: Candidate[] = ruleStatements.map((statement) => ({
672
710
  ...statement,
673
711
  kind: 'rule' as const,
package/src/help.ts CHANGED
@@ -26,7 +26,6 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
26
26
  rows: [
27
27
  ['gov [cmd]', 'Governance rules (install, sync, list)'],
28
28
  ['standards [cmd]', 'Standards (install, sync, list, <name>)'],
29
- ['snippets [cmd]', 'Prompt snippets (create, list)'],
30
29
  ['tooling [cmd]', 'Tooling stacks (sync, ref, create)'],
31
30
  ['claude [cmd]', 'Claude workflow (init, sync, setup)'],
32
31
  ['wiki [cmd]', 'Wiki pages (init)'],
@@ -93,7 +92,6 @@ const EXAMPLES = [
93
92
  'canon gov sync ../my-app',
94
93
  'canon gov restated --json',
95
94
  'canon standards markdown',
96
- 'canon snippets list',
97
95
  'canon init ../my-app',
98
96
  'canon tooling sync base',
99
97
  'canon tooling create',
package/src/init/steps.ts CHANGED
@@ -5,7 +5,7 @@ import type { DomainStep } from '@/init/run'
5
5
  export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
6
6
 
7
7
  /**
8
- * States the same fact as `docs/target/target-migrations.md`'s backup section in its
8
+ * States the same fact as `docs/target/migrations.md`'s backup section in its
9
9
  * own words. Keep the two in step by hand; nothing compares them.
10
10
  */
11
11
  const RECORDS_SETUP_NOTICE =
@@ -22,8 +22,8 @@ const TITLE_MAX_LENGTH = 72
22
22
 
23
23
  // Casing-agnostic on purpose. Casing is graded separately once the shape
24
24
  // matches, so `type` and `scope` accept either case here and `structure`
25
- // reports only a title with no `<type>(<scope>): <subject>` shape at all.
26
- const TITLE_SHAPE = /^([A-Za-z]+)\(([A-Za-z0-9][\w.-]*)\): (.+)$/
25
+ // reports only a title with no `<type>(<scope>)[!]: <subject>` shape at all.
26
+ const TITLE_SHAPE = /^([A-Za-z]+)\(([A-Za-z0-9][\w.-]*)\)!?: (.+)$/
27
27
  const LEADING_LETTERS = /^[A-Za-z]+/
28
28
 
29
29
  /**
@@ -93,11 +93,10 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
93
93
  * one destination, so the ordering is asserted directly.
94
94
  *
95
95
  * It matches whole tokens. A skill name is a complete name rather than a word
96
- * stem, and the corpus holds one word that opens with a name and means
97
- * something else: `wiki/claude/claude-worktrees.md` documents the harness
98
- * feature, not the skill, and every sibling in that folder is named
99
- * `claude-<topic>.md` for a Claude Code concept. Sixteen occurrences of it
100
- * would have moved to a name the folder's own convention contradicts.
96
+ * stem, and a word can open with a name and mean something else: a filename
97
+ * such as `claude-worktrees.md` names the harness feature, not the skill, and
98
+ * a stem matching by prefix would move it to a name that no longer describes
99
+ * what the file documents.
101
100
  *
102
101
  * No article fixup travels with it. `auto-ship` is the one destination opening
103
102
  * on a vowel sound, and the corpus spells an article before it once, which is
@@ -13,8 +13,8 @@ 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` and `snippets` symlinks resolve into trees the field
17
- * lists in their own right, so both routes land inside the same corpus.
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.
18
18
  */
19
19
  export type ShipEntries =
20
20
  /** The field declares a corpus, which is what this check reads. */
@@ -186,8 +186,7 @@ export function resolveWithin(
186
186
  * Re-tests containment after following symlinks. `resolveWithin` is lexical
187
187
  * and `resolve` does not follow a link, so a link inside the root clears that
188
188
  * test while the file it points at sits outside. This repository is a live
189
- * instance, since `claude/standards` and `claude/snippets` are links out of
190
- * `claude/`.
189
+ * instance, since `claude/standards` is a link out of `claude/`.
191
190
  *
192
191
  * Only a path that exists is checked, because a link can be followed only once
193
192
  * there is something on the other end, and a path that resolves to nothing is
@@ -10,8 +10,8 @@ import { isMarked } from '@/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, a snippet
14
- * through an `@` expansion, and a seed through an install. A `src/` doc comment
13
+ * standard through `canon standards`, a rule through a glob match, and a seed
14
+ * through an install. A `src/` doc comment
15
15
  * is read by someone working on the toolkit, in this checkout, where every
16
16
  * number resolves.
17
17
  *
@@ -20,17 +20,16 @@ import { isMarked } from '@/exempt-marker'
20
20
  * `#191512`, which no width or boundary rule separates from a pull request
21
21
  * number, so telling them apart would need the line's syntactic position.
22
22
  *
23
- * `claude/standards` and `claude/snippets` are symlinks into two of the roots
24
- * below, and a walk that followed them would read those two corpora twice and
25
- * report every finding in them under two paths. Nothing is lost by not
26
- * following, since both are corpora here in their own right.
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
27
  */
28
28
  export const SHIPPED_CORPORA = [
29
29
  'claude',
30
30
  'docs',
31
31
  'governance',
32
32
  'scripts',
33
- 'snippets',
34
33
  'standards',
35
34
  'tooling',
36
35
  ] as const
@@ -142,7 +141,7 @@ const SAME_REPOSITORY =
142
141
  * `canon/context/` carries no equivalent pattern. The same resolution test
143
142
  * cannot separate a path every scaffolded project holds, such as
144
143
  * `canon/context/index.md`, from this repository's own domain entry, such
145
- * as `canon/context/indexes.md`, since both resolve here and only the
144
+ * as `canon/context/context-model/indexes.md`, since both resolve here and only the
146
145
  * second is a defect. That is a semantic read no pattern makes, so it stays
147
146
  * with the rule and the review checklist rather than joining this reader.
148
147
  */
@@ -129,11 +129,11 @@ export type NonInteractivePolicy =
129
129
  /**
130
130
  * The two holes every domain sync leaves open: where a destination file's
131
131
  * source lives, and what counts as a change beyond a plain content diff.
132
- * Everything else in a sync is identical across gov, snippets, and standards,
132
+ * Everything else in a sync is identical across domains,
133
133
  * so it lives in the engine.
134
134
  *
135
135
  * The optional members exist because one adapter needed each of them. Leaving
136
- * all three unset reproduces the behavior gov and snippets already had.
136
+ * all three unset reproduces the behavior gov already had.
137
137
  */
138
138
  export interface SyncAdapter {
139
139
  readonly banner: string