@erclx/canon 5.29.0 → 5.31.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 (88) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/build-in-slices/REQUIREMENT.md +1 -1
  3. package/claude/skills/build-in-slices/references/adopted.md +1 -1
  4. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  5. package/claude/skills/context-fold/SKILL.md +1 -1
  6. package/claude/skills/create-skill/SKILL.md +1 -1
  7. package/claude/skills/create-standard/SKILL.md +1 -1
  8. package/claude/skills/design-extract/SKILL.md +2 -2
  9. package/claude/skills/design-taste/SKILL.md +1 -1
  10. package/claude/skills/docs-sync/SKILL.md +1 -1
  11. package/claude/skills/draft-doc/SKILL.md +1 -1
  12. package/claude/skills/draft-figure/SKILL.md +1 -1
  13. package/claude/skills/draft-figure/references/mermaid.md +1 -1
  14. package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
  15. package/claude/skills/git-issue/SKILL.md +1 -1
  16. package/claude/skills/git-pr/SKILL.md +1 -1
  17. package/claude/skills/git-stage/SKILL.md +1 -1
  18. package/claude/skills/markdown-craft/REQUIREMENT.md +23 -0
  19. package/claude/skills/markdown-craft/SKILL.md +17 -0
  20. package/{standards → claude/skills/markdown-craft/references}/markdown.md +14 -7
  21. package/claude/skills/memory-capture/SKILL.md +1 -1
  22. package/claude/skills/memory-review/SKILL.md +1 -1
  23. package/claude/skills/plan-feature/REQUIREMENT.md +2 -1
  24. package/claude/skills/plan-feature/SKILL.md +12 -2
  25. package/claude/skills/review-address/SKILL.md +1 -1
  26. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  27. package/claude/skills/review-pr/references/post.md +2 -2
  28. package/claude/skills/review-ui/references/post.md +1 -1
  29. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
  30. package/claude/skills/target-setup/references/indexes.md +1 -1
  31. package/claude/skills/test-first/REQUIREMENT.md +1 -1
  32. package/claude/skills/write-human/SKILL.md +1 -1
  33. package/docs/agents/commands.md +5 -4
  34. package/docs/agents/context-audit-checks.md +1 -1
  35. package/docs/agents/counts.md +3 -3
  36. package/docs/agents/index.md +1 -0
  37. package/docs/agents/install-and-sync.md +3 -3
  38. package/docs/agents/markdown-audit.md +1 -1
  39. package/docs/agents/routing.md +1 -1
  40. package/docs/agents/tasks-plans.md +1 -1
  41. package/docs/agents/test-order.md +1 -1
  42. package/docs/agents/upstream.md +70 -0
  43. package/docs/workflow/skill-map-on-request.md +1 -1
  44. package/governance/renames.toml +0 -4
  45. package/governance/rules/standards/plan.md +1 -0
  46. package/governance/rules/writing/markdown.md +23 -0
  47. package/package.json +1 -1
  48. package/scripts/core/install-check.sh +1 -1
  49. package/src/canvas/client/main.tsx +9 -0
  50. package/src/canvas/server.ts +7 -0
  51. package/src/claude/cases/authoring.ts +5 -0
  52. package/src/claude/cases/misc.ts +0 -4
  53. package/src/claude/skills-reach.ts +1 -1
  54. package/src/cli/help.ts +2 -0
  55. package/src/cli.ts +2 -0
  56. package/src/commands/claude.ts +1 -1
  57. package/src/commands/gov.ts +2 -2
  58. package/src/commands/records.ts +19 -1
  59. package/src/commands/upstream.ts +295 -0
  60. package/src/context/audit.ts +1 -1
  61. package/src/design/brand-favicon.ts +18 -0
  62. package/src/gate/stages.ts +0 -1
  63. package/src/markdown/structure.ts +1 -1
  64. package/src/records/backup.ts +1 -0
  65. package/src/records/validate.ts +55 -0
  66. package/src/roots/record.ts +1 -0
  67. package/src/standards/read.ts +21 -1
  68. package/src/tasks/answers.ts +14 -1
  69. package/src/tasks/stack.ts +176 -0
  70. package/src/teach/nav.ts +5 -12
  71. package/src/upstream/catalog.ts +112 -0
  72. package/src/upstream/cursor.ts +120 -0
  73. package/src/upstream/digest.ts +75 -0
  74. package/src/upstream/releases.ts +169 -0
  75. package/standards/index.md +0 -1
  76. package/standards/plan.md +25 -12
  77. package/standards/rule.md +1 -1
  78. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +1 -1
  79. package/claude/skills/index-lookup/REQUIREMENT.md +0 -35
  80. package/claude/skills/index-lookup/SKILL.md +0 -44
  81. package/governance/rules/canon/601-indexes.md +0 -9
  82. package/governance/rules/claude/565-behavior.md +0 -27
  83. package/governance/rules/claude/567-planning.md +0 -16
  84. package/governance/rules/claude/575-hooks.md +0 -16
  85. package/governance/rules/claude/576-settings.md +0 -13
  86. package/governance/rules/claude/592-claude-md.md +0 -19
  87. package/governance/rules/standards/markdown.md +0 -16
  88. package/governance/rules/writing/800-prose.md +0 -15
@@ -92,7 +92,7 @@ const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“(\[]*[A-Z]|`)|\s*$)/g
92
92
  * Checkpoints the structural measures run against.
93
93
  *
94
94
  * These are the definition rather than a fallback. Reading each number out of
95
- * the sentence `standards/markdown.md` states it in was the alternative, and it
95
+ * the sentence `claude/skills/markdown-craft/references/markdown.md` states it in was the alternative, and it
96
96
  * put a parser contract on a document authored for people, where a rewording
97
97
  * degraded a number and the report had to carry a legend saying which one. The
98
98
  * standard still states every number for a reader, and moving one is an edit to
@@ -47,6 +47,7 @@ export const BACKED_FOLDERS = [
47
47
  'tasks',
48
48
  'teach',
49
49
  'transcripts',
50
+ 'upstream',
50
51
  'walkthroughs',
51
52
  ] as const
52
53
 
@@ -75,6 +75,8 @@ export const FINDING_KINDS = [
75
75
  'category-mismatch',
76
76
  'operator-call-phrasing',
77
77
  'batch-unsplit',
78
+ 'source-unquoted',
79
+ 'stack-foldable',
78
80
  ] as const
79
81
 
80
82
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -276,6 +278,7 @@ const PLAN_SECTIONS = [
276
278
  'Risks',
277
279
  'Review focus',
278
280
  'Questions',
281
+ 'Sources',
279
282
  ] as const
280
283
 
281
284
  type PlanSection = (typeof PLAN_SECTIONS)[number]
@@ -425,6 +428,57 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
425
428
  return findings
426
429
  }
427
430
 
431
+ const SOURCE_LINK = /\[[^\]]+\]\(https?:\/\/[^)\s]+\)|https?:\/\/\S+/
432
+ const SOURCE_QUOTE = /["“][^"“”]+["”]/
433
+ const SOURCE_UNVERIFIED = /\bunverified\b/i
434
+
435
+ /**
436
+ * A bullet opens an entry only at the section's shallowest indent, so a nested
437
+ * bullet and a wrapped line both belong to the entry above. Joining them first
438
+ * lets a passage wrap across lines and still count as quoted.
439
+ */
440
+ function readSourceEntries(lines: readonly string[]): string[] {
441
+ const entries: string[] = []
442
+ let depth: number | undefined
443
+
444
+ for (const line of lines) {
445
+ const trimmed = line.trim()
446
+ if (trimmed === '') continue
447
+
448
+ const indent = line.length - line.trimStart().length
449
+ const opensEntry =
450
+ trimmed.startsWith('- ') && (depth === undefined || indent <= depth)
451
+
452
+ if (opensEntry) {
453
+ depth = indent
454
+ entries.push(trimmed)
455
+ continue
456
+ }
457
+
458
+ if (entries.length > 0) entries[entries.length - 1] += ` ${trimmed}`
459
+ }
460
+
461
+ return entries
462
+ }
463
+
464
+ function checkSources(name: string, lines: readonly string[]): Finding[] {
465
+ return readSourceEntries(lines)
466
+ .filter((entry) => entry !== `- ${NONE_IDENTIFIED}`)
467
+ .filter(
468
+ (entry) =>
469
+ !SOURCE_LINK.test(entry) ||
470
+ !(SOURCE_QUOTE.test(entry) || SOURCE_UNVERIFIED.test(entry)),
471
+ )
472
+ .map((entry) =>
473
+ finding(
474
+ 'source-unquoted',
475
+ name,
476
+ shorten(entry),
477
+ 'carries no link, or a link with neither a double-quoted passage nor the word unverified.',
478
+ ),
479
+ )
480
+ }
481
+
428
482
  export function checkPlan(name: string, text: string): Finding[] {
429
483
  const findings: Finding[] = []
430
484
 
@@ -480,6 +534,7 @@ export function checkPlan(name: string, text: string): Finding[] {
480
534
  }
481
535
 
482
536
  findings.push(...checkQuestionContract(name, sections.get('Questions') ?? []))
537
+ findings.push(...checkSources(name, sections.get('Sources') ?? []))
483
538
 
484
539
  if (hasStagedBatches(text)) {
485
540
  const staged = linesOutsideFences(text).find((line) =>
@@ -80,6 +80,7 @@ export const RECORD_ENTRIES: readonly string[] = [
80
80
  'tasks',
81
81
  'teach',
82
82
  'transcripts',
83
+ 'upstream',
83
84
  'walkthroughs',
84
85
  ]
85
86
 
@@ -53,6 +53,15 @@ export function standardRoots(root: string): StandardRoot[] {
53
53
  ]
54
54
  }
55
55
 
56
+ /**
57
+ * Standards retired into the skill reference that took them, keyed by name with
58
+ * a path relative to the package root. Each retirement adds one line, and the
59
+ * map goes with the verb in the last slice of the retirement.
60
+ */
61
+ const MOVED_STANDARDS: Readonly<Record<string, string>> = {
62
+ markdown: 'claude/skills/markdown-craft/references/markdown.md',
63
+ }
64
+
56
65
  function filename(name: string): string {
57
66
  return name.endsWith('.md') ? name : `${name}.md`
58
67
  }
@@ -69,7 +78,16 @@ export function resolveStandard(
69
78
  if (existsSync(path)) return { path, source: join(label, file) }
70
79
  }
71
80
 
72
- return undefined
81
+ const movedName = basename(file, '.md')
82
+ if (!Object.hasOwn(MOVED_STANDARDS, movedName)) return undefined
83
+
84
+ const moved = MOVED_STANDARDS[movedName]
85
+ if (!moved) return undefined
86
+
87
+ const path = join(PROJECT_ROOT, moved)
88
+ if (!existsSync(path)) return undefined
89
+
90
+ return { path, source: join(PACKAGE_LABEL, moved) }
73
91
  }
74
92
 
75
93
  /**
@@ -92,6 +110,8 @@ export function listStandards(root: string): string[] {
92
110
  }
93
111
  }
94
112
 
113
+ for (const name of Object.keys(MOVED_STANDARDS)) names.add(name)
114
+
95
115
  return [...names].sort()
96
116
  }
97
117
 
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
- import { isAbsolute, relative, resolve } from 'node:path'
3
+ import { basename, isAbsolute, relative, resolve } from 'node:path'
4
4
  import { isUnder } from '@/tasks/paths'
5
+ import { JUDGED_APART_REMEDY, readLivePlans, stackPairs } from '@/tasks/stack'
5
6
  import { recordDir, recordDirs } from '@/roots/record'
6
7
  import {
7
8
  hasStagedBatches,
@@ -231,6 +232,18 @@ export async function planAnswers(
231
232
  })
232
233
  }
233
234
 
235
+ const stem = basename(resolved.path, '.md')
236
+ const pairs = stackPairs(await readLivePlans(root))
237
+
238
+ for (const { child, parent, shared } of pairs) {
239
+ if (child !== stem) continue
240
+
241
+ open.push({
242
+ label: 'Stacked sibling',
243
+ why: `stacks on \`${parent}\` and shares ${shared} declared files with it. ${JUDGED_APART_REMEDY}`,
244
+ })
245
+ }
246
+
234
247
  return {
235
248
  ok: true,
236
249
  plan: resolved.plan,
@@ -0,0 +1,176 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readdir, readFile } from 'node:fs/promises'
3
+ import { basename, join } from 'node:path'
4
+ import { recordDir } from '@/roots/record'
5
+ import type { Finding } from '@/records/validate'
6
+ import { splitPlanSections } from '@/records/validate'
7
+ import { readDeclarations } from '@/tasks/reach'
8
+
9
+ /**
10
+ * The fold thresholds, calibrated over 1,043 live and archived plans in which
11
+ * 87 stack pairs name a parent. At these values 15 report: the inspector,
12
+ * canvas, slides, and evidence chains, plus one sandbox contract split that
13
+ * carries a `Judged apart from` line. The ceiling drops one pair of 24 files.
14
+ * The per-PR cost was never measured as a number, so these are calibrated
15
+ * against the corpus and not derived.
16
+ */
17
+ export const MIN_SHARED = 3
18
+ export const MIN_OVERLAP = 0.6
19
+ export const MAX_UNION = 20
20
+
21
+ const STACK_PARENT =
22
+ /(?:stacks? on|builds? on|branch(?:es)? from)\s+(?:(?:the|batch|plan|slice)\s+)*`(feature-[a-z0-9-]+)`/gi
23
+
24
+ const JUDGED_APART = /^judged apart from\s+`(feature-[a-z0-9-]+)`:\s*\S/i
25
+
26
+ const BULLET = /^[-*]\s+/
27
+
28
+ export interface StackPlan {
29
+ readonly stem: string
30
+ readonly text: string
31
+ }
32
+
33
+ export interface StackPair {
34
+ readonly child: string
35
+ readonly parent: string
36
+ readonly shared: number
37
+ readonly union: number
38
+ }
39
+
40
+ /**
41
+ * One string per `**Constraints:**` bullet, with a wrapped continuation line
42
+ * joined to the bullet it belongs to.
43
+ */
44
+ function constraintBullets(text: string): string[] {
45
+ const bullets: string[] = []
46
+
47
+ for (const line of splitPlanSections(text).get('Constraints') ?? []) {
48
+ const trimmed = line.trim()
49
+ if (trimmed.length === 0) continue
50
+
51
+ if (BULLET.test(trimmed)) bullets.push(trimmed.replace(BULLET, ''))
52
+ else if (bullets.length > 0) bullets[bullets.length - 1] += ` ${trimmed}`
53
+ }
54
+
55
+ return bullets
56
+ }
57
+
58
+ /**
59
+ * The plan stems a plan says it stacks on, in the order written.
60
+ *
61
+ * Only the slug standing directly after the phrase counts, with at most a
62
+ * filler word between. A line like "`feature-x` stacks on this slice, not on
63
+ * `feature-y`" names a parent for `feature-y`'s plan and none for this one, and
64
+ * reading every slug on a stack-worded line would hand it the wrong parent.
65
+ */
66
+ export function readStackParents(text: string): string[] {
67
+ const parents: string[] = []
68
+
69
+ for (const bullet of constraintBullets(text)) {
70
+ for (const match of bullet.matchAll(STACK_PARENT)) {
71
+ const parent = match[1]
72
+ if (parent !== undefined) parents.push(parent.toLowerCase())
73
+ }
74
+ }
75
+
76
+ return [...new Set(parents)]
77
+ }
78
+
79
+ function isJudgedApart(text: string, parent: string): boolean {
80
+ return constraintBullets(text).some(
81
+ (bullet) => JUDGED_APART.exec(bullet)?.[1]?.toLowerCase() === parent,
82
+ )
83
+ }
84
+
85
+ /** Whether two declarations name the same file or one folder holding the other. */
86
+ function overlaps(a: string, b: string): boolean {
87
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
88
+ }
89
+
90
+ function countShared(from: readonly string[], to: readonly string[]): number {
91
+ return from.filter((entry) => to.some((other) => overlaps(entry, other)))
92
+ .length
93
+ }
94
+
95
+ /**
96
+ * Every live plan that stacks on a live sibling it mostly shares files with and
97
+ * has not written down why the two are judged apart.
98
+ *
99
+ * A parent absent from `plans` is never reported, which is how an archived
100
+ * parent stays clear: the caller hands over the live folder only, so a merged
101
+ * parent cannot be folded into. When two plans each name the other, the pair is
102
+ * reported once, on the child sorting second.
103
+ *
104
+ * A folder declaration counts as sharing the files under it, taking the larger
105
+ * of the two directions and capping at the smaller set, so a folder and the
106
+ * files beneath it are not undercounted as distinct strings.
107
+ */
108
+ export function stackPairs(plans: readonly StackPlan[]): StackPair[] {
109
+ const byStem = new Map(plans.map((plan) => [plan.stem, plan]))
110
+ const pairs: StackPair[] = []
111
+
112
+ for (const child of plans) {
113
+ for (const parentStem of readStackParents(child.text)) {
114
+ const parent = byStem.get(parentStem)
115
+ if (parent === undefined || parent.stem === child.stem) continue
116
+
117
+ const mutual = readStackParents(parent.text).includes(child.stem)
118
+ if (mutual && child.stem < parent.stem) continue
119
+
120
+ if (isJudgedApart(child.text, parentStem)) continue
121
+
122
+ const own = readDeclarations(child.text)
123
+ const theirs = readDeclarations(parent.text)
124
+ const smaller = Math.min(own.length, theirs.length)
125
+ if (smaller === 0) continue
126
+
127
+ const shared = Math.min(
128
+ smaller,
129
+ Math.max(countShared(own, theirs), countShared(theirs, own)),
130
+ )
131
+ const union = new Set([...own, ...theirs]).size
132
+
133
+ if (
134
+ shared >= MIN_SHARED &&
135
+ shared / smaller >= MIN_OVERLAP &&
136
+ union <= MAX_UNION
137
+ ) {
138
+ pairs.push({ child: child.stem, parent: parentStem, shared, union })
139
+ }
140
+ }
141
+ }
142
+
143
+ return pairs
144
+ }
145
+
146
+ /** The `Judged apart from` remedy, worded once for the finding and the gate. */
147
+ export const JUDGED_APART_REMEDY =
148
+ 'Fold the batch into its parent as contiguous commits on one branch, or write `Judged apart from `feature-<parent>`: <reason>` in its constraints.'
149
+
150
+ export function stackFindings(plans: readonly StackPlan[]): Finding[] {
151
+ return stackPairs(plans).map(({ child, parent, shared, union }) => ({
152
+ kind: 'stack-foldable',
153
+ record: `${child}.md`,
154
+ subject: parent,
155
+ message: `stacks on \`${parent}\` and shares ${shared} declared files with it, ${union} between the two. ${JUDGED_APART_REMEDY}`,
156
+ }))
157
+ }
158
+
159
+ /**
160
+ * The live plans folder as stem and text pairs. The archive is a folder below
161
+ * this one and is never walked, so a merged parent is absent and stays clear.
162
+ */
163
+ export async function readLivePlans(root: string): Promise<StackPlan[]> {
164
+ const dir = recordDir(root, 'plans')
165
+ if (!existsSync(dir)) return []
166
+
167
+ const names = (await readdir(dir)).filter((name) => name.endsWith('.md'))
168
+ names.sort()
169
+
170
+ return Promise.all(
171
+ names.map(async (name) => ({
172
+ stem: basename(name, '.md'),
173
+ text: await readFile(join(dir, name), 'utf8'),
174
+ })),
175
+ )
176
+ }
package/src/teach/nav.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync } from 'node:fs'
2
2
  import { readdir, readFile, rm } from 'node:fs/promises'
3
3
  import { join, posix, relative } from 'node:path'
4
4
  import {
@@ -6,10 +6,10 @@ import {
6
6
  TEACH_STYLESHEET_COMPONENTS,
7
7
  } from '@/design/components'
8
8
  import { buildDesignCss } from '@/design/css'
9
- import { FAVICON_COLORS, faviconLink, renderFavicon } from '@/design/favicon'
9
+ import { brandFavicon } from '@/design/brand-favicon'
10
+ import { faviconLink } from '@/design/favicon'
10
11
  import { stripFrontmatter } from '@/markdown/frontmatter'
11
12
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
12
- import { PROJECT_ROOT } from '@/roots/project'
13
13
  import { compileScript } from '@/teach/browser/compile'
14
14
  import { dismiss } from '@/teach/browser/dismiss'
15
15
  import { focusLine } from '@/teach/browser/focus-line'
@@ -39,8 +39,6 @@ import {
39
39
  } from '@/teach/workspace'
40
40
  import { writeIfChanged } from '@/teach/write-if-changed'
41
41
 
42
- const BRAND_MARK = 'assets/brand/mark.svg'
43
-
44
42
  /**
45
43
  * The link `teach-workspace` once had a session write by hand into every page.
46
44
  * A lesson keeps whatever sits outside its marked regions, so the old icon
@@ -68,14 +66,9 @@ function dropHandWrittenHead(html: string): string {
68
66
  )
69
67
  }
70
68
 
71
- /**
72
- * Built from the brand mark and the favicon's own pair, the same two inputs
73
- * `web/public/favicon.svg` is generated from, so every teach page carries the
74
- * icon the landing page does rather than a copy of its own.
75
- */
69
+ /** Every teach page carries the icon the landing page does rather than a copy of its own. */
76
70
  function teachFavicon(): string {
77
- const mark = readFileSync(join(PROJECT_ROOT, BRAND_MARK), 'utf8')
78
- return faviconLink(renderFavicon(mark, FAVICON_COLORS))
71
+ return faviconLink(brandFavicon())
79
72
  }
80
73
 
81
74
  /**
@@ -0,0 +1,112 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { COMMAND_GROUPS } from '@/cli/help'
4
+ import { listSkillsAt } from '@/claude/skills-list'
5
+ import { STAGES } from '@/gate/stages'
6
+
7
+ const MAX_TEXT = 140
8
+ const PLUGIN_SKILLS = 'claude/skills'
9
+ const INTERNAL_SKILLS = '.claude/skills'
10
+
11
+ export interface CatalogEntry {
12
+ readonly name: string
13
+ readonly text: string
14
+ }
15
+
16
+ export interface Catalog {
17
+ readonly skills: CatalogEntry[]
18
+ readonly internalSkills: CatalogEntry[]
19
+ readonly verbs: CatalogEntry[]
20
+ readonly hooks: CatalogEntry[]
21
+ readonly gate: CatalogEntry[]
22
+ /** Rows that exist but could not be described, so none drops out silently. */
23
+ readonly gaps: string[]
24
+ }
25
+
26
+ const firstSentence = (text: string): string =>
27
+ text.split(/\.\s/)[0]?.replace(/\.$/, '').slice(0, MAX_TEXT) ?? ''
28
+
29
+ function skillsAt(
30
+ root: string,
31
+ corpus: string,
32
+ gaps: string[],
33
+ ): CatalogEntry[] {
34
+ const dir = join(root, corpus)
35
+ if (!existsSync(dir)) return []
36
+
37
+ const listed = listSkillsAt(dir)
38
+ const named = new Set(listed.map((skill) => skill.name))
39
+ const folders = readdirSync(dir, { withFileTypes: true })
40
+ .filter((entry) => entry.isDirectory())
41
+ .map((entry) => entry.name)
42
+
43
+ for (const folder of folders) {
44
+ if (!named.has(folder)) gaps.push(`${corpus}/${folder}: no SKILL.md`)
45
+ }
46
+ for (const skill of listed) {
47
+ if (!skill.description) gaps.push(`${corpus}/${skill.name}: no description`)
48
+ }
49
+
50
+ return listed.map((skill) => ({
51
+ name: skill.name,
52
+ text: firstSentence(skill.description),
53
+ }))
54
+ }
55
+
56
+ function hooksAt(root: string): CatalogEntry[] {
57
+ const dir = join(root, '.claude', 'hooks')
58
+ if (!existsSync(dir)) return []
59
+
60
+ return readdirSync(dir)
61
+ .filter((file) => file.endsWith('.sh'))
62
+ .sort()
63
+ .map((file) => {
64
+ const comment = readFileSync(join(dir, file), 'utf8')
65
+ .split('\n')
66
+ .find((line) => /^#\s*\S/.test(line) && !line.startsWith('#!'))
67
+
68
+ return {
69
+ name: file,
70
+ text: (comment ?? '').replace(/^#\s*/, '').slice(0, MAX_TEXT),
71
+ }
72
+ })
73
+ }
74
+
75
+ export function buildCatalog(root: string): Catalog {
76
+ const gaps: string[] = []
77
+
78
+ return {
79
+ skills: skillsAt(root, PLUGIN_SKILLS, gaps),
80
+ internalSkills: skillsAt(root, INTERNAL_SKILLS, gaps),
81
+ verbs: COMMAND_GROUPS.flatMap((group) => group.rows).map(
82
+ ([signature, description]) => ({
83
+ name: `canon ${signature}`,
84
+ text: description,
85
+ }),
86
+ ),
87
+ hooks: hooksAt(root),
88
+ gate: STAGES.map((stage) => ({ name: stage.id, text: stage.label })),
89
+ gaps,
90
+ }
91
+ }
92
+
93
+ const section = (title: string, entries: readonly CatalogEntry[]): string[] => [
94
+ `### ${title}`,
95
+ ...entries.map((entry) =>
96
+ entry.text ? `- ${entry.name}: ${entry.text}` : `- ${entry.name}`,
97
+ ),
98
+ '',
99
+ ]
100
+
101
+ export function renderCatalog(catalog: Catalog): string {
102
+ return [
103
+ ...section('Plugin skills (claude/skills/)', catalog.skills),
104
+ ...section('Internal skills (.claude/skills/)', catalog.internalSkills),
105
+ ...section('CLI verbs', catalog.verbs),
106
+ ...section('Hooks (.claude/hooks/)', catalog.hooks),
107
+ ...section('Gate stages (canon gate run)', catalog.gate),
108
+ ...(catalog.gaps.length > 0
109
+ ? ['### Gaps', ...catalog.gaps.map((gap) => `- ${gap}`), '']
110
+ : []),
111
+ ].join('\n')
112
+ }
@@ -0,0 +1,120 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from 'node:fs'
8
+ import { dirname } from 'node:path'
9
+ import { recordDir } from '@/roots/record'
10
+
11
+ const FOLDER = 'upstream'
12
+ const VERSION = /^\d+(\.\d+)*$/
13
+
14
+ export interface Cursor {
15
+ readonly version: string
16
+ readonly date: string
17
+ readonly intake: string
18
+ }
19
+
20
+ export interface AdvanceInput {
21
+ readonly version: string
22
+ readonly intake: string
23
+ readonly today: string
24
+ /** The current `llms.txt`, or null to keep the copy already stored. */
25
+ readonly llms: string | null
26
+ }
27
+
28
+ export type AdvanceOutcome =
29
+ | { readonly kind: 'advanced' }
30
+ | {
31
+ readonly kind: 'refused'
32
+ readonly reason: 'older-than-cursor' | 'invalid-version'
33
+ }
34
+
35
+ export interface LlmsDifference {
36
+ readonly added: string[]
37
+ readonly removed: string[]
38
+ }
39
+
40
+ const cursorPath = (root: string): string =>
41
+ recordDir(root, FOLDER, 'cursor.json')
42
+
43
+ const llmsPath = (root: string): string => recordDir(root, FOLDER, 'llms.txt')
44
+
45
+ export function readCursor(root: string): Cursor | null {
46
+ const path = cursorPath(root)
47
+ if (!existsSync(path)) return null
48
+
49
+ try {
50
+ return JSON.parse(readFileSync(path, 'utf8')) as Cursor
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ export function readLastLlms(root: string): string | null {
57
+ const path = llmsPath(root)
58
+
59
+ return existsSync(path) ? readFileSync(path, 'utf8') : null
60
+ }
61
+
62
+ export function compareVersions(a: string, b: string): number {
63
+ const left = a.split('.').map(Number)
64
+ const right = b.split('.').map(Number)
65
+
66
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
67
+ const diff = (left[i] ?? 0) - (right[i] ?? 0)
68
+ if (diff !== 0) return diff
69
+ }
70
+
71
+ return 0
72
+ }
73
+
74
+ // `fetch` reads this file, so a write that half-lands would leave a reader on a
75
+ // truncated cursor. A rename within one folder is atomic.
76
+ async function writeAtomic(path: string, body: string): Promise<void> {
77
+ mkdirSync(dirname(path), { recursive: true })
78
+ const temp = `${path}.${process.pid}.tmp`
79
+ writeFileSync(temp, body)
80
+ renameSync(temp, path)
81
+ }
82
+
83
+ export async function advanceCursor(
84
+ root: string,
85
+ input: AdvanceInput,
86
+ ): Promise<AdvanceOutcome> {
87
+ if (!VERSION.test(input.version)) {
88
+ return { kind: 'refused', reason: 'invalid-version' }
89
+ }
90
+
91
+ const stored = readCursor(root)
92
+ if (stored && compareVersions(input.version, stored.version) < 0) {
93
+ return { kind: 'refused', reason: 'older-than-cursor' }
94
+ }
95
+
96
+ if (input.llms !== null) await writeAtomic(llmsPath(root), input.llms)
97
+ const next: Cursor = {
98
+ version: input.version,
99
+ date: input.today,
100
+ intake: input.intake,
101
+ }
102
+ await writeAtomic(cursorPath(root), `${JSON.stringify(next, null, 2)}\n`)
103
+
104
+ return { kind: 'advanced' }
105
+ }
106
+
107
+ const lines = (text: string): string[] => text.split('\n').filter(Boolean)
108
+
109
+ export function llmsDifference(
110
+ last: string | null,
111
+ current: string,
112
+ ): LlmsDifference {
113
+ const before = new Set(lines(last ?? ''))
114
+ const after = new Set(lines(current))
115
+
116
+ return {
117
+ added: [...after].filter((line) => !before.has(line)),
118
+ removed: [...before].filter((line) => !after.has(line)),
119
+ }
120
+ }