@erclx/canon 4.79.0 → 4.81.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 (36) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-rollout/SKILL.md +1 -1
  3. package/claude/skills/design-extract/SKILL.md +5 -1
  4. package/claude/skills/git-pr/SKILL.md +3 -3
  5. package/claude/skills/git-pr/references/labels.md +1 -1
  6. package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
  7. package/claude/skills/sketch-design/SKILL.md +92 -0
  8. package/claude/skills/teach-workspace/SKILL.md +23 -1
  9. package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
  10. package/docs/agents/audits.md +2 -2
  11. package/docs/agents/commands.md +6 -4
  12. package/docs/agents/design-board.md +13 -11
  13. package/docs/agents/index.md +1 -1
  14. package/docs/agents/install-and-sync.md +3 -3
  15. package/docs/agents/label-coverage.md +1 -1
  16. package/docs/agents/teach.md +16 -0
  17. package/docs/target-projects.md +1 -1
  18. package/docs/workflow/ai-workflow.md +1 -0
  19. package/docs/workflow/visual-design-workflow.md +1 -0
  20. package/package.json +1 -1
  21. package/src/audits/baseline.ts +17 -5
  22. package/src/claude/cases/misc.ts +5 -0
  23. package/src/commands/design.ts +24 -8
  24. package/src/commands/sync.ts +1 -1
  25. package/src/commands/teach.ts +118 -0
  26. package/src/design/board.ts +130 -47
  27. package/src/gate/measures.ts +4 -4
  28. package/src/labels/map.ts +19 -5
  29. package/src/legacy-path.ts +15 -0
  30. package/src/migrate/plan.ts +1 -0
  31. package/src/migrate/surface-roots.ts +5 -6
  32. package/src/project-root.ts +16 -0
  33. package/src/surface-root.ts +7 -14
  34. package/src/sync/stamp.ts +22 -17
  35. package/src/targets/sweep.ts +6 -2
  36. package/src/teach/render.ts +126 -0
@@ -56,26 +56,19 @@ export function spell(root: SurfaceRoot, entry: string): string {
56
56
  }
57
57
 
58
58
  /**
59
- * The creation default for one entry.
59
+ * The root a surface resolves at: the first that carries it, and
60
+ * `CREATION_ROOT` when neither does.
60
61
  *
61
- * The stamp folder keeps creating under `.claude/` until its own move lands,
62
- * since the install stamp and the audits baseline still write there by a
63
- * fixed path. A read resolving to `canon/config/` in a fresh project would
64
- * answer from a folder nothing writes.
65
- */
66
- function creationRootFor(entry: string): SurfaceRoot {
67
- return entry === 'canon' ? '.claude' : CREATION_ROOT
68
- }
69
-
70
- /**
71
- * The root a surface resolves at: the first that carries it, and the creation
72
- * default when neither does.
62
+ * The stamp folder used to take a carve-out here, staying at `.claude/` while
63
+ * the install stamp and the audits baseline still wrote there by a fixed path
64
+ * of their own. Both moved to `canon/config/` by a fixed path of their own, so
65
+ * every entry now shares the one default.
73
66
  */
74
67
  function rootOf(root: string, entry: string): SurfaceRoot {
75
68
  return (
76
69
  SURFACE_ROOTS.find((candidate) =>
77
70
  existsSync(join(root, candidate, spell(candidate, entry))),
78
- ) ?? creationRootFor(entry)
71
+ ) ?? CREATION_ROOT
79
72
  )
80
73
  }
81
74
 
package/src/sync/stamp.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { mkdir, writeFile } from 'node:fs/promises'
4
4
  import { dirname, join, sep } from 'node:path'
5
5
  import { execa } from 'execa'
6
+ import { resolveExisting } from '@/legacy-path'
6
7
  import { recordTarget } from '@/targets/registry'
7
8
 
8
9
  /**
@@ -56,7 +57,21 @@ export interface Stamp {
56
57
  readonly domains: Readonly<Partial<Record<StampDomain, DomainStamp>>>
57
58
  }
58
59
 
60
+ /**
61
+ * Where a stamp is written today, `canon/config/config.json`. Named
62
+ * `stampPath` rather than `surfaceStampPath` because the write side has only
63
+ * one current path, unlike the read side's fallback chain below.
64
+ */
59
65
  export function stampPath(target: string): string {
66
+ return join(target, 'canon', 'config', 'config.json')
67
+ }
68
+
69
+ /**
70
+ * Where the stamp wrote before this move, `.claude/canon/config.json`. Kept
71
+ * for the same reason as the two spellings below: a target stamped under it
72
+ * still carries its config here, and `readStamp` falls back to it.
73
+ */
74
+ export function claudeCanonStampPath(target: string): string {
60
75
  return join(target, '.claude', 'canon', 'config.json')
61
76
  }
62
77
 
@@ -84,18 +99,6 @@ export function retiredNameStampPath(target: string): string {
84
99
  return join(target, '.claude', 'aitk', 'config.json')
85
100
  }
86
101
 
87
- /**
88
- * The stamp path under the new surface root, `canon/config/config.json`.
89
- *
90
- * Read ahead of `stampPath`, extending the same mechanism rather than adding a
91
- * new one: a target that has moved reads its config from the root it moved
92
- * to, and one that has not falls through to the spellings below unchanged.
93
- * The write destination does not move to it in this batch.
94
- */
95
- function surfaceStampPath(target: string): string {
96
- return join(target, 'canon', 'config', 'config.json')
97
- }
98
-
99
102
  /**
100
103
  * Every spelling a stamp has been written under, current first. The order is
101
104
  * the read order, so a target carrying more than one resolves to the newest.
@@ -105,10 +108,10 @@ function surfaceStampPath(target: string): string {
105
108
  * second breaking change aimed at exactly the targets that were slowest to
106
109
  * migrate the first time.
107
110
  */
108
- export function stampPaths(target: string): readonly string[] {
111
+ export function stampPaths(target: string): readonly [string, ...string[]] {
109
112
  return [
110
- surfaceStampPath(target),
111
113
  stampPath(target),
114
+ claudeCanonStampPath(target),
112
115
  retiredNameStampPath(target),
113
116
  legacyStampPath(target),
114
117
  ]
@@ -120,7 +123,10 @@ export function stampPaths(target: string): readonly string[] {
120
123
  * there is nothing to migrate off of.
121
124
  */
122
125
  export function isLegacyStamped(target: string): boolean {
123
- if (existsSync(surfaceStampPath(target)) || existsSync(stampPath(target))) {
126
+ if (
127
+ existsSync(stampPath(target)) ||
128
+ existsSync(claudeCanonStampPath(target))
129
+ ) {
124
130
  return false
125
131
  }
126
132
  return (
@@ -158,8 +164,7 @@ export function toStampKey(rel: string): string {
158
164
  * was read from.
159
165
  */
160
166
  export function readStamp(target: string): Stamp | undefined {
161
- const found = stampPaths(target).find((path) => existsSync(path))
162
- return found === undefined ? undefined : readStampFile(found)
167
+ return readStampFile(resolveExisting(stampPaths(target)))
163
168
  }
164
169
 
165
170
  function readStampFile(path: string): Stamp | undefined {
@@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
5
  import {
6
+ claudeCanonStampPath,
6
7
  isLegacyStamped,
7
8
  legacyStampPath,
8
9
  retiredNameStampPath,
@@ -91,15 +92,18 @@ export interface SweepOptions {
91
92
  }
92
93
 
93
94
  /**
94
- * Whether a folder carries an install stamp at the current path or either
95
+ * Whether a folder carries an install stamp at the current path or any
95
96
  * retired one. `aitk@3.57.0` still writes the folder form,
96
97
  * `retiredNameStampPath`, as its current path, so a target a pre-rename
97
98
  * binary syncs after this check drops the folder form would otherwise vanish
98
- * from the walk with nothing saying so.
99
+ * from the walk with nothing saying so. `claudeCanonStampPath` is the spelling
100
+ * every target carried before the stamp moved to `canon/config/`, which a
101
+ * target this batch has not reached still writes to.
99
102
  */
100
103
  function isStamped(path: string): boolean {
101
104
  return (
102
105
  Bun.file(stampPath(path)).size > 0 ||
106
+ Bun.file(claudeCanonStampPath(path)).size > 0 ||
103
107
  Bun.file(retiredNameStampPath(path)).size > 0 ||
104
108
  Bun.file(legacyStampPath(path)).size > 0
105
109
  )
@@ -0,0 +1,126 @@
1
+ import { Heading } from '@/teach/components/heading'
2
+ import { List } from '@/teach/components/list'
3
+ import { Paragraph } from '@/teach/components/paragraph'
4
+ import { render } from '@/teach/html/jsx-runtime'
5
+ import type { TeachRefused } from '@/teach/workspace'
6
+
7
+ export type LessonBlock =
8
+ | { readonly type: 'heading'; readonly level: 1 | 2; readonly text: string }
9
+ | {
10
+ readonly type: 'paragraph'
11
+ readonly text: string
12
+ readonly lede?: boolean
13
+ }
14
+ | {
15
+ readonly type: 'list'
16
+ readonly items: readonly string[]
17
+ readonly ordered?: boolean
18
+ }
19
+ | { readonly type: 'raw'; readonly html: string }
20
+
21
+ export interface RenderRendered {
22
+ readonly ok: true
23
+ readonly html: string
24
+ }
25
+
26
+ export type RenderOutcome = RenderRendered | TeachRefused
27
+
28
+ const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'raw'] as const
29
+
30
+ function isBlockType(value: unknown): value is LessonBlock['type'] {
31
+ return (BLOCK_TYPES as readonly string[]).includes(value as string)
32
+ }
33
+
34
+ function badBlock(index: number, message: string): TeachRefused {
35
+ return {
36
+ ok: false,
37
+ reason: 'bad-input',
38
+ message: `Block ${index}: ${message}`,
39
+ detail: [],
40
+ }
41
+ }
42
+
43
+ function isString(value: unknown): value is string {
44
+ return typeof value === 'string'
45
+ }
46
+
47
+ function isStringArray(value: unknown): value is readonly string[] {
48
+ return Array.isArray(value) && value.every(isString)
49
+ }
50
+
51
+ function renderBlock(
52
+ block: unknown,
53
+ index: number,
54
+ ): { readonly html: string } | TeachRefused {
55
+ if (typeof block !== 'object' || block === null || !('type' in block)) {
56
+ return badBlock(index, 'not an object carrying a type')
57
+ }
58
+
59
+ const type = (block as { readonly type: unknown }).type
60
+
61
+ if (!isBlockType(type)) {
62
+ return badBlock(index, `unrecognized type ${JSON.stringify(type)}`)
63
+ }
64
+
65
+ const fields = block as Record<string, unknown>
66
+
67
+ switch (type) {
68
+ case 'heading': {
69
+ if (fields.level !== 1 && fields.level !== 2) {
70
+ return badBlock(index, 'heading needs level 1 or 2')
71
+ }
72
+ if (!isString(fields.text)) {
73
+ return badBlock(index, 'heading needs a string text')
74
+ }
75
+ return {
76
+ html: render(Heading({ level: fields.level, children: fields.text })),
77
+ }
78
+ }
79
+ case 'paragraph': {
80
+ if (!isString(fields.text)) {
81
+ return badBlock(index, 'paragraph needs a string text')
82
+ }
83
+ if (fields.lede !== undefined && typeof fields.lede !== 'boolean') {
84
+ return badBlock(index, 'paragraph lede must be a boolean')
85
+ }
86
+ return {
87
+ html: render(Paragraph({ lede: fields.lede, children: fields.text })),
88
+ }
89
+ }
90
+ case 'list': {
91
+ if (!isStringArray(fields.items)) {
92
+ return badBlock(index, 'list needs an items array of strings')
93
+ }
94
+ if (fields.ordered !== undefined && typeof fields.ordered !== 'boolean') {
95
+ return badBlock(index, 'list ordered must be a boolean')
96
+ }
97
+ return {
98
+ html: render(List({ ordered: fields.ordered, items: fields.items })),
99
+ }
100
+ }
101
+ case 'raw': {
102
+ if (!isString(fields.html)) {
103
+ return badBlock(index, 'raw needs a string html')
104
+ }
105
+ return { html: fields.html }
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Renders a lesson body's structural blocks through the same components
112
+ * `render-fixture.tsx` composes by hand, so a real lesson and the committed
113
+ * fixture share one rendering mechanism. Every block past the first refusal
114
+ * goes unread, matching how a malformed JSON parse refuses the whole call.
115
+ */
116
+ export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
117
+ const rendered: string[] = []
118
+
119
+ for (const [index, block] of blocks.entries()) {
120
+ const result = renderBlock(block, index)
121
+ if ('ok' in result) return result
122
+ rendered.push(result.html)
123
+ }
124
+
125
+ return { ok: true, html: rendered.join('\n') }
126
+ }