@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-rollout/SKILL.md +1 -1
- package/claude/skills/design-extract/SKILL.md +5 -1
- package/claude/skills/git-pr/SKILL.md +3 -3
- package/claude/skills/git-pr/references/labels.md +1 -1
- package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
- package/claude/skills/sketch-design/SKILL.md +92 -0
- package/claude/skills/teach-workspace/SKILL.md +23 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
- package/docs/agents/audits.md +2 -2
- package/docs/agents/commands.md +6 -4
- package/docs/agents/design-board.md +13 -11
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/label-coverage.md +1 -1
- package/docs/agents/teach.md +16 -0
- package/docs/target-projects.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -0
- package/docs/workflow/visual-design-workflow.md +1 -0
- package/package.json +1 -1
- package/src/audits/baseline.ts +17 -5
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/design.ts +24 -8
- package/src/commands/sync.ts +1 -1
- package/src/commands/teach.ts +118 -0
- package/src/design/board.ts +130 -47
- package/src/gate/measures.ts +4 -4
- package/src/labels/map.ts +19 -5
- package/src/legacy-path.ts +15 -0
- package/src/migrate/plan.ts +1 -0
- package/src/migrate/surface-roots.ts +5 -6
- package/src/project-root.ts +16 -0
- package/src/surface-root.ts +7 -14
- package/src/sync/stamp.ts +22 -17
- package/src/targets/sweep.ts +6 -2
- package/src/teach/render.ts +126 -0
package/src/surface-root.ts
CHANGED
|
@@ -56,26 +56,19 @@ export function spell(root: SurfaceRoot, entry: string): string {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* The
|
|
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
) ??
|
|
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 (
|
|
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
|
-
|
|
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 {
|
package/src/targets/sweep.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|