@erclx/canon 5.21.0 → 5.23.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/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/auto-ship/references/draft-mark.md +8 -6
  4. package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
  5. package/claude/skills/draft-doc/SKILL.md +77 -0
  6. package/claude/skills/draft-doc/references/context.md +31 -0
  7. package/claude/skills/draft-doc/references/docs.md +33 -0
  8. package/claude/skills/draft-doc/references/readme.md +58 -0
  9. package/claude/skills/draft-doc/references/wiki.md +53 -0
  10. package/claude/skills/draft-doc/references/wireframes.md +48 -0
  11. package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
  12. package/claude/skills/draft-figure/SKILL.md +1 -1
  13. package/claude/skills/git-pr/SKILL.md +5 -46
  14. package/claude/skills/git-pr/references/final-command.md +75 -0
  15. package/claude/skills/git-pr/references/labels.md +2 -2
  16. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
  17. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
  19. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  20. package/claude/skills/search-craft/SKILL.md +1 -1
  21. package/claude/skills/target-check/SKILL.md +9 -9
  22. package/claude/skills/target-check/references/domains.md +11 -5
  23. package/docs/agents/architecture-record.md +2 -2
  24. package/docs/agents/commands.md +1 -1
  25. package/docs/agents/drift-surfaces.md +20 -9
  26. package/docs/agents/index.md +3 -3
  27. package/docs/agents/install-and-sync.md +3 -3
  28. package/docs/agents/migrate.md +1 -1
  29. package/docs/agents/rule-citations.md +1 -1
  30. package/docs/agents/slides.md +47 -3
  31. package/docs/agents/tasks-validate.md +4 -2
  32. package/docs/agents/tasks.md +2 -2
  33. package/docs/target/migrations.md +3 -1
  34. package/docs/target/projects.md +1 -1
  35. package/docs/target/sync.md +1 -1
  36. package/docs/workflow/skill-map.md +14 -18
  37. package/governance/rules/canon/604-scratch.md +0 -1
  38. package/governance/rules/claude/575-hooks.md +1 -2
  39. package/governance/rules/claude/576-settings.md +1 -2
  40. package/governance/rules/standards/architecture.md +0 -1
  41. package/governance/rules/standards/context.md +0 -1
  42. package/governance/rules/standards/design.md +0 -1
  43. package/governance/rules/standards/diagrams.md +0 -3
  44. package/governance/rules/standards/requirements.md +0 -1
  45. package/governance/rules/standards/wireframes.md +0 -1
  46. package/package.json +1 -1
  47. package/scripts/manage-sandbox.sh +7 -8
  48. package/src/claude/cases/authoring.ts +5 -5
  49. package/src/commands/slides.ts +14 -4
  50. package/src/commands/sync.ts +10 -0
  51. package/src/commands/tasks.ts +16 -0
  52. package/src/gov/citations.ts +1 -2
  53. package/src/migrate/skill-names.ts +12 -0
  54. package/src/slides/convert/deck.ts +83 -3
  55. package/src/slides/convert/export.ts +67 -19
  56. package/src/slides/convert/shapes.ts +43 -3
  57. package/src/slides/convert/walk.ts +43 -1
  58. package/src/slides/package/fonts.ts +297 -0
  59. package/src/slides/package/motion.ts +316 -0
  60. package/src/slides/package/test-face.ts +113 -0
  61. package/src/sync/check.ts +9 -0
  62. package/src/sync/layout.ts +27 -1
  63. package/src/tasks/validate.ts +65 -0
  64. package/standards/architecture.md +2 -3
  65. package/standards/board.md +2 -0
  66. package/standards/context.md +0 -1
  67. package/standards/design.md +0 -1
  68. package/standards/diagrams.md +0 -3
  69. package/standards/requirements.md +3 -2
  70. package/standards/rule.md +1 -0
  71. package/standards/wireframes.md +0 -1
  72. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -2
  73. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +10 -5
  74. package/tooling/claude/seeds/.claude/hooks/{tasks-index.sh → records-index.sh} +42 -35
  75. package/tooling/claude/seeds/.claude/settings.json +1 -5
  76. package/tooling/claude/seeds/canon/ARCHITECTURE.md +1 -1
  77. package/tooling/claude/seeds/canon/REQUIREMENTS.md +2 -0
  78. package/claude/skills/draft-context/REQUIREMENT.md +0 -37
  79. package/claude/skills/draft-context/SKILL.md +0 -61
  80. package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
  81. package/claude/skills/draft-docs/SKILL.md +0 -59
  82. package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
  83. package/claude/skills/draft-readme/SKILL.md +0 -73
  84. package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
  85. package/claude/skills/draft-wiki/SKILL.md +0 -83
  86. package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
  87. package/claude/skills/draft-wireframes/SKILL.md +0 -69
  88. package/tooling/claude/seeds/.claude/hooks/memory-index.sh +0 -95
@@ -0,0 +1,316 @@
1
+ import type JSZip from 'jszip'
2
+
3
+ /**
4
+ * Slide transitions and entrance animations, written into a slide's XML after
5
+ * pptxgenjs has written it, since pptxgenjs declares neither. A slide states
6
+ * them in its HTML, `walk.ts` reads the attributes, and the converter resolves
7
+ * each animated element to the object names `planSlide` gave its shapes. This
8
+ * module turns those names into the shape ids the written XML assigned.
9
+ */
10
+
11
+ export interface TransitionSpec {
12
+ readonly effect: string
13
+ readonly duration?: string
14
+ }
15
+
16
+ export interface EntranceSpec {
17
+ /** The element that declared it, which every notice names. */
18
+ readonly selector: string
19
+ readonly effect: string
20
+ readonly order?: string
21
+ readonly duration?: string
22
+ /** Every shape drawn from the element and the elements inside it. */
23
+ readonly shapes: readonly string[]
24
+ }
25
+
26
+ export interface SlideMotion {
27
+ readonly transition?: TransitionSpec
28
+ readonly entrances: readonly EntranceSpec[]
29
+ }
30
+
31
+ export interface MotionResult {
32
+ readonly xml: string
33
+ readonly notices: readonly string[]
34
+ }
35
+
36
+ /** A slide's motion, numbered as the package numbers its slide parts. */
37
+ export interface SlideMotionEdit {
38
+ readonly slide: number
39
+ readonly motion: SlideMotion
40
+ }
41
+
42
+ export interface MotionNotice {
43
+ readonly slide: number
44
+ readonly message: string
45
+ }
46
+
47
+ /** The child element `<p:transition>` holds, each in its default direction. */
48
+ const TRANSITIONS: Readonly<Record<string, string>> = {
49
+ fade: '<p:fade/>',
50
+ push: '<p:push/>',
51
+ wipe: '<p:wipe/>',
52
+ cover: '<p:cover/>',
53
+ }
54
+
55
+ /**
56
+ * The strict schema states a transition's length only as one of three speeds,
57
+ * 0.5, 0.75, and 1 second. A length in milliseconds needs the PowerPoint 2010
58
+ * extension namespace, so a declared duration rounds to the nearest speed.
59
+ */
60
+ function speedOf(duration: number | undefined): 'fast' | 'med' | 'slow' {
61
+ if (duration === undefined) return 'med'
62
+ if (duration < 625) return 'fast'
63
+ return duration < 875 ? 'med' : 'slow'
64
+ }
65
+
66
+ type Behaviors = (spid: string, duration: number, next: () => number) => string
67
+
68
+ const target = (spid: string): string =>
69
+ `<p:tgtEl><p:spTgt spid="${spid}"/></p:tgtEl>`
70
+
71
+ const animEffect =
72
+ (filter: string): Behaviors =>
73
+ (spid, duration, next) =>
74
+ `<p:animEffect transition="in" filter="${filter}"><p:cBhvr><p:cTn id="${next()}" dur="${duration}"/>${target(spid)}</p:cBhvr></p:animEffect>`
75
+
76
+ function anim(
77
+ spid: string,
78
+ duration: number,
79
+ id: number,
80
+ attribute: string,
81
+ from: string,
82
+ to: string,
83
+ ): string {
84
+ return `<p:anim calcmode="lin" valueType="num"><p:cBhvr additive="base"><p:cTn id="${id}" dur="${duration}" fill="hold"/>${target(spid)}<p:attrNameLst><p:attrName>${attribute}</p:attrName></p:attrNameLst></p:cBhvr><p:tavLst><p:tav tm="0"><p:val><p:strVal val="${from}"/></p:val></p:tav><p:tav tm="100000"><p:val><p:strVal val="${to}"/></p:val></p:tav></p:tavLst></p:anim>`
85
+ }
86
+
87
+ interface Preset {
88
+ readonly id: number
89
+ readonly subtype: number
90
+ readonly behaviors: Behaviors
91
+ }
92
+
93
+ /**
94
+ * PowerPoint's own preset ids, so the effect shows by name in its animation
95
+ * pane. Fly comes in from the bottom and wipe from the left, the directions
96
+ * PowerPoint picks when none is chosen. A wipe filter names the edge the
97
+ * reveal starts from, so from the left pairs with `wipe(left)`.
98
+ */
99
+ const ENTRANCES: Readonly<Record<string, Preset>> = {
100
+ fade: { id: 10, subtype: 0, behaviors: animEffect('fade') },
101
+ fly: {
102
+ id: 2,
103
+ subtype: 4,
104
+ behaviors: (spid, duration, next) =>
105
+ anim(spid, duration, next(), 'ppt_x', '#ppt_x', '#ppt_x') +
106
+ anim(spid, duration, next(), 'ppt_y', '1+#ppt_h/2', '#ppt_y'),
107
+ },
108
+ wipe: { id: 22, subtype: 8, behaviors: animEffect('wipe(left)') },
109
+ zoom: {
110
+ id: 53,
111
+ subtype: 16,
112
+ behaviors: (spid, duration, next) =>
113
+ anim(spid, duration, next(), 'ppt_w', '0', '#ppt_w') +
114
+ anim(spid, duration, next(), 'ppt_h', '0', '#ppt_h') +
115
+ animEffect('fade')(spid, duration, next),
116
+ },
117
+ }
118
+
119
+ const DEFAULT_ENTRANCE_MS = 500
120
+
121
+ const ANCHOR = '</p:clrMapOvr>'
122
+
123
+ const list = (names: readonly string[]): string =>
124
+ `${names.slice(0, -1).join(', ')}, or ${names.at(-1)}`
125
+
126
+ /**
127
+ * The object names an entrance on `root` brings in: every shape drawn from it
128
+ * and from the elements inside it, so a card's box and its text arrive on one
129
+ * click. A descendant in `roots` declares its own entrance and keeps its
130
+ * subtree for that click. A record follows its parent in document order, which
131
+ * lets one pass collect the subtree.
132
+ */
133
+ export function entranceShapes(
134
+ root: number | null,
135
+ records: readonly { readonly id: number; readonly parent: number | null }[],
136
+ names: readonly { readonly record: number; readonly name: string }[],
137
+ roots: ReadonlySet<number>,
138
+ ): string[] {
139
+ if (root === null) return []
140
+ const subtree = new Set([root])
141
+ for (const record of records) {
142
+ const isOwnEntrance = roots.has(record.id)
143
+ if (
144
+ record.parent !== null &&
145
+ subtree.has(record.parent) &&
146
+ !isOwnEntrance
147
+ ) {
148
+ subtree.add(record.id)
149
+ }
150
+ }
151
+ return names
152
+ .filter((named) => subtree.has(named.record))
153
+ .map((named) => named.name)
154
+ }
155
+
156
+ /** Milliseconds from `500`, `500ms`, or `0.5s`, or undefined when unreadable. */
157
+ export function millisecondsOf(value: string | undefined): number | undefined {
158
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s)?\s*$/.exec(value ?? '')
159
+ if (!match?.[1]) return undefined
160
+ const amount = Number(match[1])
161
+ return Math.round(match[2] === 's' ? amount * 1000 : amount)
162
+ }
163
+
164
+ /**
165
+ * pptxgenjs numbers a table `n * slideNumber + 1` and every other shape by its
166
+ * index plus two, so a table can share an id with a shape beside it. A timing
167
+ * target has to name one shape, so each repeat after the first takes a fresh id.
168
+ */
169
+ function uniqueIds(xml: string): string {
170
+ const pattern = /<p:cNvPr id="(\d+)"/g
171
+ let highest = Math.max(
172
+ 0,
173
+ ...[...xml.matchAll(pattern)].map((match) => Number(match[1])),
174
+ )
175
+ const seen = new Set<string>()
176
+ return xml.replace(pattern, (whole, id: string) => {
177
+ if (!seen.has(id)) {
178
+ seen.add(id)
179
+ return whole
180
+ }
181
+ highest += 1
182
+ return `<p:cNvPr id="${highest}"`
183
+ })
184
+ }
185
+
186
+ function shapeIds(xml: string): Map<string, string> {
187
+ const ids = new Map<string, string>()
188
+ for (const [, id, name] of xml.matchAll(
189
+ /<p:cNvPr id="(\d+)" name="([^"]*)"/g,
190
+ )) {
191
+ if (id && name && !ids.has(name)) ids.set(name, id)
192
+ }
193
+ return ids
194
+ }
195
+
196
+ interface Click {
197
+ readonly preset: Preset
198
+ readonly duration: number
199
+ readonly spids: readonly string[]
200
+ }
201
+
202
+ function timingXml(clicks: readonly Click[]): string {
203
+ let counter = 2
204
+ const next = (): number => {
205
+ counter += 1
206
+ return counter
207
+ }
208
+ const pars = clicks.map((click) => {
209
+ const outer = next()
210
+ const inner = next()
211
+ const effects = click.spids.map((spid, index) => {
212
+ const nodeType = index === 0 ? 'clickEffect' : 'withEffect'
213
+ const effect = next()
214
+ const visible = `<p:set><p:cBhvr><p:cTn id="${next()}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn>${target(spid)}<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set>`
215
+ const behaviors = click.preset.behaviors(spid, click.duration, next)
216
+ return `<p:par><p:cTn id="${effect}" presetID="${click.preset.id}" presetClass="entr" presetSubtype="${click.preset.subtype}" fill="hold" nodeType="${nodeType}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>${visible}${behaviors}</p:childTnLst></p:cTn></p:par>`
217
+ })
218
+ return `<p:par><p:cTn id="${outer}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="${inner}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>${effects.join('')}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>`
219
+ })
220
+ return `<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>${pars.join('')}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>`
221
+ }
222
+
223
+ /** Declared order first, then document order for ties and for no order. */
224
+ function ordered(entrances: readonly EntranceSpec[]): EntranceSpec[] {
225
+ const rank = (entrance: EntranceSpec): number => {
226
+ const order = Number.parseFloat(entrance.order ?? '')
227
+ return Number.isFinite(order) ? order : Number.POSITIVE_INFINITY
228
+ }
229
+ return entrances.toSorted((a, b) => rank(a) - rank(b))
230
+ }
231
+
232
+ /**
233
+ * Inserts the transition and an on-click main sequence after the slide's color
234
+ * map override, where the schema orders them. Each entrance is one click, and
235
+ * every shape drawn from its element comes in on that click together. A slide
236
+ * missing the anchor is returned unchanged rather than patched elsewhere.
237
+ */
238
+ export function addMotion(xml: string, motion: SlideMotion): MotionResult {
239
+ const notices: string[] = []
240
+ let transition = ''
241
+ if (motion.transition) {
242
+ const element = TRANSITIONS[motion.transition.effect]
243
+ if (element) {
244
+ const speed = speedOf(millisecondsOf(motion.transition.duration))
245
+ transition = `<p:transition spd="${speed}">${element}</p:transition>`
246
+ } else {
247
+ notices.push(
248
+ `unknown transition ${motion.transition.effect}. Use ${list(Object.keys(TRANSITIONS))}`,
249
+ )
250
+ }
251
+ }
252
+
253
+ const numbered = uniqueIds(xml)
254
+ const ids = shapeIds(numbered)
255
+ const clicks: Click[] = []
256
+ for (const entrance of ordered(motion.entrances)) {
257
+ const preset = ENTRANCES[entrance.effect]
258
+ if (!preset) {
259
+ notices.push(
260
+ `${entrance.selector}: unknown entrance ${entrance.effect}. Use ${list(Object.keys(ENTRANCES))}`,
261
+ )
262
+ continue
263
+ }
264
+ const spids = entrance.shapes.flatMap((name) => {
265
+ const id = ids.get(name)
266
+ return id ? [id] : []
267
+ })
268
+ if (spids.length === 0) {
269
+ notices.push(
270
+ `${entrance.selector}: draws no shape, so its entrance has nothing to animate`,
271
+ )
272
+ continue
273
+ }
274
+ const duration = millisecondsOf(entrance.duration) ?? DEFAULT_ENTRANCE_MS
275
+ clicks.push({ preset, duration, spids })
276
+ }
277
+
278
+ const timing = clicks.length > 0 ? timingXml(clicks) : ''
279
+ if (!transition && !timing) return { xml, notices }
280
+ if (!xml.includes(ANCHOR)) {
281
+ return {
282
+ xml,
283
+ notices: [
284
+ ...notices,
285
+ `no ${ANCHOR} to anchor motion after, so none was written`,
286
+ ],
287
+ }
288
+ }
289
+ return {
290
+ xml: (timing ? numbered : xml).replace(
291
+ ANCHOR,
292
+ `${ANCHOR}${transition}${timing}`,
293
+ ),
294
+ notices,
295
+ }
296
+ }
297
+
298
+ /** Writes each slide's motion into the opened package and returns its notices. */
299
+ export async function writeMotion(
300
+ zip: JSZip,
301
+ edits: readonly SlideMotionEdit[],
302
+ ): Promise<MotionNotice[]> {
303
+ const notices: MotionNotice[] = []
304
+ for (const { slide, motion } of edits) {
305
+ const path = `ppt/slides/slide${slide}.xml`
306
+ const xml = await zip.file(path)?.async('string')
307
+ if (xml === undefined) {
308
+ notices.push({ slide, message: `no ${path} in the written deck` })
309
+ continue
310
+ }
311
+ const result = addMotion(xml, motion)
312
+ if (result.xml !== xml) zip.file(path, result.xml)
313
+ notices.push(...result.notices.map((message) => ({ slide, message })))
314
+ }
315
+ return notices
316
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Builds the smallest sfnt the font step reads, holding only the `OS/2`,
3
+ * `head`, and `name` tables an EOT header is built from, plus `fvar` when the
4
+ * face should read as variable. It carries no glyphs, so it embeds and never
5
+ * renders. Tests use it in place of a real face, whose license the repository
6
+ * would then have to carry.
7
+ */
8
+
9
+ export interface TestFaceOptions {
10
+ readonly family?: string
11
+ readonly subfamily?: string
12
+ readonly weight?: number
13
+ readonly isItalic?: boolean
14
+ readonly fsType?: number
15
+ readonly isVariable?: boolean
16
+ /** The first four bytes, `0x00010000` for TrueType. */
17
+ readonly version?: number
18
+ }
19
+
20
+ /** Values distinct enough that a header copying the wrong field shows it. */
21
+ export const TEST_FACE = {
22
+ panose: [2, 11, 5, 3, 3, 4, 3, 2, 2, 4],
23
+ unicodeRanges: [0xe0000aff, 0x5000e5ff, 0x00000009, 0x00000001],
24
+ codePages: [0x0000019f, 0x01020000],
25
+ checkSumAdjustment: 0x1234abcd,
26
+ } as const
27
+
28
+ function os2(options: TestFaceOptions): Buffer {
29
+ const table = Buffer.alloc(96)
30
+ table.writeUInt16BE(4, 0)
31
+ table.writeUInt16BE(options.weight ?? 400, 4)
32
+ table.writeUInt16BE(options.fsType ?? 0, 8)
33
+ Buffer.from(TEST_FACE.panose).copy(table, 32)
34
+ TEST_FACE.unicodeRanges.forEach((range, index) => {
35
+ table.writeUInt32BE(range, 42 + index * 4)
36
+ })
37
+ table.write('TEST', 58, 'latin1')
38
+ table.writeUInt16BE(options.isItalic ? 0x0001 : 0x0040, 62)
39
+ TEST_FACE.codePages.forEach((page, index) => {
40
+ table.writeUInt32BE(page, 78 + index * 4)
41
+ })
42
+ return table
43
+ }
44
+
45
+ function head(): Buffer {
46
+ const table = Buffer.alloc(54)
47
+ table.writeUInt32BE(0x00010000, 0)
48
+ table.writeUInt32BE(TEST_FACE.checkSumAdjustment, 8)
49
+ table.writeUInt32BE(0x5f0f3cf5, 12)
50
+ return table
51
+ }
52
+
53
+ function name(strings: ReadonlyMap<number, string>): Buffer {
54
+ const encoded = [...strings].map(([id, text]) => ({
55
+ id,
56
+ bytes: Buffer.from(text, 'utf16le').swap16(),
57
+ }))
58
+ const header = Buffer.alloc(6 + encoded.length * 12)
59
+ header.writeUInt16BE(0, 0)
60
+ header.writeUInt16BE(encoded.length, 2)
61
+ header.writeUInt16BE(header.length, 4)
62
+ let offset = 0
63
+ encoded.forEach(({ id, bytes }, index) => {
64
+ const at = 6 + index * 12
65
+ header.writeUInt16BE(3, at)
66
+ header.writeUInt16BE(1, at + 2)
67
+ header.writeUInt16BE(0x0409, at + 4)
68
+ header.writeUInt16BE(id, at + 6)
69
+ header.writeUInt16BE(bytes.length, at + 8)
70
+ header.writeUInt16BE(offset, at + 10)
71
+ offset += bytes.length
72
+ })
73
+ return Buffer.concat([header, ...encoded.map(({ bytes }) => bytes)])
74
+ }
75
+
76
+ export function testFace(options: TestFaceOptions = {}): Buffer {
77
+ const family = options.family ?? 'Fixture Sans'
78
+ const subfamily = options.subfamily ?? 'Regular'
79
+ const tables = new Map<string, Buffer>([
80
+ ['OS/2', os2(options)],
81
+ ['head', head()],
82
+ [
83
+ 'name',
84
+ name(
85
+ new Map([
86
+ [1, family],
87
+ [2, subfamily],
88
+ [4, `${family} ${subfamily}`],
89
+ [5, 'Version 1.000'],
90
+ ]),
91
+ ),
92
+ ],
93
+ ])
94
+ if (options.isVariable) tables.set('fvar', Buffer.alloc(16))
95
+
96
+ const entries = [...tables].sort(([a], [b]) => a.localeCompare(b))
97
+ const directory = Buffer.alloc(12 + entries.length * 16)
98
+ directory.writeUInt32BE(options.version ?? 0x00010000, 0)
99
+ directory.writeUInt16BE(entries.length, 4)
100
+ let offset = directory.length
101
+ const bodies: Buffer[] = []
102
+ entries.forEach(([tag, table], index) => {
103
+ const at = 12 + index * 16
104
+ directory.write(tag, at, 'latin1')
105
+ directory.writeUInt32BE(offset, at + 8)
106
+ directory.writeUInt32BE(table.length, at + 12)
107
+ const padded = Buffer.alloc(Math.ceil(table.length / 4) * 4)
108
+ table.copy(padded)
109
+ bodies.push(padded)
110
+ offset += padded.length
111
+ })
112
+ return Buffer.concat([directory, ...bodies])
113
+ }
package/src/sync/check.ts CHANGED
@@ -10,8 +10,10 @@ import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
10
10
  import {
11
11
  collectSuperseded,
12
12
  detectUnmigrated,
13
+ detectUnmovedSurfaces,
13
14
  type SupersededEntry,
14
15
  type UnmigratedDomain,
16
+ type UnmovedSurface,
15
17
  } from '@/sync/layout'
16
18
  import {
17
19
  buildReverseReport,
@@ -178,6 +180,11 @@ export interface CheckReport {
178
180
  readonly seeds: SeedsReport
179
181
  readonly superseded: readonly SupersededEntry[]
180
182
  readonly unmigrated: readonly UnmigratedDomain[]
183
+ /**
184
+ * Report-only like `superseded`, since a target's CI gating on `--exit-code`
185
+ * would otherwise start failing on a layout nobody asked it to move yet.
186
+ */
187
+ readonly unmovedSurfaces: readonly UnmovedSurface[]
181
188
  readonly newSkills: readonly string[]
182
189
  /**
183
190
  * Rules the toolkit authored after this target's governance anchor, filtered
@@ -440,6 +447,7 @@ export async function buildCheckReport(
440
447
  seeds: { entries: [], historyUnavailable: false },
441
448
  superseded: [],
442
449
  unmigrated: [],
450
+ unmovedSurfaces: [],
443
451
  newSkills: [],
444
452
  newRules: [],
445
453
  reverse: emptyReverseReport(),
@@ -456,6 +464,7 @@ export async function buildCheckReport(
456
464
  seeds: buildSeedsReport(toolkitRoot, target),
457
465
  superseded: collectSuperseded(target),
458
466
  unmigrated,
467
+ unmovedSurfaces: detectUnmovedSurfaces(target),
459
468
  newSkills: await readNewSkills(toolkitRoot, anchors),
460
469
  newRules: await readNewRules(toolkitRoot, target, stamp),
461
470
  reverse: buildReverseReport(toolkitRoot, target),
@@ -2,7 +2,7 @@ import { existsSync, statSync } from 'node:fs'
2
2
  import { basename, join, relative } from 'node:path'
3
3
  import { SUBDIRS } from '@/claude/seeds'
4
4
  import { creationRel, isRecordEntry } from '@/record-root'
5
- import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
5
+ import { SURFACE_ENTRIES, spell, surfaceDir } from '@/surface-root'
6
6
  import type { StampDomain } from '@/sync/stamp'
7
7
 
8
8
  const CLAUDE_DIR = '.claude'
@@ -122,6 +122,32 @@ export function detectUnmigrated(
122
122
  return found
123
123
  }
124
124
 
125
+ /**
126
+ * A tracked surface the target still reads at its `.claude/` spelling, with
127
+ * nothing at the `canon/` one, and where `canon migrate surface-roots` moves it.
128
+ */
129
+ export interface UnmovedSurface {
130
+ readonly rel: string
131
+ readonly movesTo: string
132
+ }
133
+
134
+ /**
135
+ * Surfaces a target holds only at the root the surface move retired. Kept
136
+ * apart from `detectUnmigrated`, which models a domain stranded at the project
137
+ * root under a stamp domain, while a surface carries neither. A rule names the
138
+ * current layout only, so a target here stops loading the rules scoped to these
139
+ * files, and this report is what points it at the verb.
140
+ */
141
+ export function detectUnmovedSurfaces(target: string): UnmovedSurface[] {
142
+ return SURFACE_ENTRIES.filter(
143
+ (entry) =>
144
+ relative(target, surfaceDir(target, entry)) === join(CLAUDE_DIR, entry),
145
+ ).map((entry) => ({
146
+ rel: join(CLAUDE_DIR, entry),
147
+ movesTo: join('canon', spell('canon', entry)),
148
+ }))
149
+ }
150
+
125
151
  /**
126
152
  * Root files whose basename matches something the toolkit ships for this domain.
127
153
  * Basenames rather than relative paths, because the root layout an older toolkit
@@ -89,6 +89,20 @@ export interface FolderClaim {
89
89
  readonly message: string
90
90
  }
91
91
 
92
+ /**
93
+ * A code span in a board cell that the markdown preview cannot break, so it
94
+ * pushes the table past the pane and makes it scroll sideways. Either the
95
+ * longest whitespace-free run is wider than `WIDE_LIMIT`, or the span is a
96
+ * brace-glob, which is written out as separate paths. The limit is a heuristic
97
+ * over one pane, which is why it reports beside the findings and moves no exit
98
+ * code.
99
+ */
100
+ export interface WideToken {
101
+ readonly group: BoardGroup
102
+ readonly subject: string
103
+ readonly message: string
104
+ }
105
+
92
106
  /**
93
107
  * A task file neither surface names. That is the normal state between a
94
108
  * session filing it and a live orchestrator placing it, so it reports beside
@@ -124,6 +138,8 @@ export interface BoardRow {
124
138
  readonly ordinal: OrdinalWord | 'last' | undefined
125
139
  /** Whether a `Waiting on` cell ranks its row against a sibling row or a class of rows, false when the group fixes no such column. */
126
140
  readonly ranked: boolean
141
+ /** Every cell of the row as `splitCells` read it, which the wide-token scan walks. */
142
+ readonly cells: readonly string[]
127
143
  }
128
144
 
129
145
  export interface ValidateReport {
@@ -135,6 +151,7 @@ export interface ValidateReport {
135
151
  readonly findings: readonly Finding[]
136
152
  readonly untested: readonly Untested[]
137
153
  readonly claims: readonly FolderClaim[]
154
+ readonly wide: readonly WideToken[]
138
155
  readonly unplaced: readonly Unplaced[]
139
156
  }
140
157
 
@@ -461,6 +478,7 @@ export function readBoard(text: string): {
461
478
  waiting,
462
479
  ordinal: waiting ? readOrdinal(waiting) : undefined,
463
480
  ranked: waiting ? readRank(waiting) : false,
481
+ cells,
464
482
  })
465
483
  }
466
484
 
@@ -992,6 +1010,52 @@ function checkFolderClaims(
992
1010
  return claims
993
1011
  }
994
1012
 
1013
+ /**
1014
+ * The widest the longest unbroken run in a code span may be before a table
1015
+ * scrolls in the preview pane. A 57-character span fit and a 78-character one
1016
+ * did not, so the limit sits at the nearest round number above the first.
1017
+ */
1018
+ const WIDE_LIMIT = 60
1019
+
1020
+ /**
1021
+ * Reports a code span a table cell cannot wrap. A span breaks only at
1022
+ * whitespace, so the longest whitespace-free run is what is measured, and a
1023
+ * link target is stripped first because only its label renders. Every group is
1024
+ * scanned, since the overflow this exists for happened under `## Up next`,
1025
+ * where the folder-claim scan would not have looked. Prose breaks at spaces
1026
+ * and bare words at hyphens, so only a span is read.
1027
+ */
1028
+ function checkWideTokens(rows: readonly BoardRow[]): WideToken[] {
1029
+ const wide: WideToken[] = []
1030
+
1031
+ for (const row of rows) {
1032
+ for (const cell of row.cells) {
1033
+ const spans = cell.replace(/\]\([^)]*\)/g, ']').match(/`[^`]+`/g) ?? []
1034
+
1035
+ for (const span of spans) {
1036
+ const text = span.slice(1, -1)
1037
+ const run = Math.max(...text.split(/\s+/).map((part) => part.length))
1038
+
1039
+ if (/\{[^}]*,[^}]*\}/.test(text)) {
1040
+ wide.push({
1041
+ group: row.group,
1042
+ subject: subjectOf(row),
1043
+ message: `holds the brace-glob ${span}, which renders as one unbroken token. Write it out as separate paths.`,
1044
+ })
1045
+ } else if (run > WIDE_LIMIT) {
1046
+ wide.push({
1047
+ group: row.group,
1048
+ subject: subjectOf(row),
1049
+ message: `holds a ${run}-character token, ${span}, past the ${WIDE_LIMIT} a preview pane fits, so its table scrolls sideways.`,
1050
+ })
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ return wide
1057
+ }
1058
+
995
1059
  /**
996
1060
  * Reports a task file neither the board nor the backlog names. Under the
997
1061
  * roster-checked hand-off, filing a task and placing its row are two acts a
@@ -1327,6 +1391,7 @@ export async function validateBoard(
1327
1391
  findings,
1328
1392
  untested: parked.untested,
1329
1393
  claims: checkFolderClaims(rows, root),
1394
+ wide: checkWideTokens(rows),
1330
1395
  unplaced: checkUnplaced(rows, backlog, stems),
1331
1396
  }
1332
1397
  }
@@ -3,7 +3,6 @@ title: Architecture reference
3
3
  description: Shape and content rules for canon/ARCHITECTURE.md
4
4
  paths:
5
5
  - 'canon/ARCHITECTURE.md'
6
- - '.claude/ARCHITECTURE.md'
7
6
  rule:
8
7
  - "Add a decision here only when it fills one of the standard's slots, and write any other decision into the context entry for the domain it constrains. At the record's stated cap, merge or retire an entry before adding one."
9
8
  ---
@@ -89,7 +88,7 @@ Every session pays for this file before any work starts, so a heavy read is a re
89
88
  - `This record holds at most <n> decisions.`
90
89
  - `at most <n> words a decision`, counting a decision's prose below its heading, fenced lines left out
91
90
  - `at most <n> risk bullets`, counting every list item under `## Risks / open questions`, nested ones included
92
- - The three can share one sentence, such as `This record holds at most 12 decisions, at most 150 words a decision, and at most 6 risk bullets.` Each cap is the record's own, so a record stating none is measured and never gated, and the template states the entry cap alone so the other two are adopted on purpose.
91
+ - The three can share one sentence, such as `This record holds at most 12 decisions, at most 150 words a decision, and at most 6 risk bullets.` Each cap is the record's own, so a record stating none is measured and never gated. The template and the seed a new project installs state all three, and a project loosens one by editing the number in its own record.
93
92
  - Set the decision cap near one decision per slot plus a small margin, the word cap near what one trade takes to state, about 150, and the risk cap at what a reader holds in mind at once, about 6.
94
93
  - At the decision cap, merge two decisions or retire one before adding another, and name which in the change that does it. Never pack two decisions under one heading, which the count cannot see.
95
94
  - At the word cap, cut a decision to the choice, the alternative that lost, the reason, and the revisit sentence. What it sheds is a mechanism or a measurement, which goes to the domain context entry it constrains, never under a second heading.
@@ -107,7 +106,7 @@ The revisit sentence closes the reasoning of every decision. The anchor sentence
107
106
 
108
107
  ## Overview
109
108
 
110
- This record holds at most 12 decisions. Every decision closes with a revisit sentence.
109
+ This record holds at most 12 decisions, at most 150 words a decision, and at most 6 risk bullets. Every decision closes with a revisit sentence.
111
110
 
112
111
  ## Key technical decisions
113
112
 
@@ -57,6 +57,8 @@ Each group fixes its own columns, which follow from the test above it rather tha
57
57
 
58
58
  A `Touches` cell is copied from its plan's own list, so it states what the branch sets out to write rather than a bound on it, and a branch outgrows it while the row still reads as it did at dispatch. Correct the cell from the branch rather than from the plan once one is running, since the plan is the prediction that already went stale and only the diff says what was written. `canon tasks plan-reach <plan>` reads that diff against every live plan and every cell in this group, and the row's owner is the one who writes the correction: a worker never edits this board.
59
59
 
60
+ A cell holds no code span whose longest unbroken run passes 60 characters, since a span has no break point and a wider one makes its table scroll sideways in the preview. Write a brace-glob out as separate paths for the same reason. `canon tasks validate` reports both in its `wide` array and moves no exit code.
61
+
60
62
  ### Row order
61
63
 
62
64
  Row position inside `## Needs a plan` is the order those tasks get planned in, top first. The three tests answer whether a task can start, which is mechanical, and none of them answers which task is worth starting, which is a judgment no column holds. Position is where that judgment is recorded, so the top row is the answer to what to plan next and a reader needs no other surface to get it. The other two groups take the same reading, and it costs them little, since a group holding what is already planned is short by construction.
@@ -3,7 +3,6 @@ title: Context entry reference
3
3
  description: Shape and content rules for canon/context/<domain>.md entries
4
4
  paths:
5
5
  - 'canon/context/**'
6
- - '.claude/context/**'
7
6
  rule:
8
7
  - 'Before non-trivial work in a domain, read its `canon/context/<domain>.md` entry. Use `canon/context/index.md` to find it.'
9
8
  - 'Leave the entry conforming when work in its domain changes what it describes.'
@@ -3,7 +3,6 @@ title: Design reference
3
3
  description: Shape and content rules for canon/DESIGN.md
4
4
  paths:
5
5
  - 'canon/DESIGN.md'
6
- - '.claude/DESIGN.md'
7
6
  ---
8
7
 
9
8
  # Design reference
@@ -3,12 +3,9 @@ title: Diagram reference
3
3
  description: Shape and content rules for .canon/diagrams/<kind>.md files
4
4
  paths:
5
5
  - '.canon/diagrams/**'
6
- - '.claude/DIAGRAMS.md'
7
6
  rule:
8
7
  - 'Layout, budgets, accessibility, labels, and render verification inside the fence are a separate topic. The mermaid standard routes them.'
9
8
  - 'A diagram entry carries structure and flow, not implementation. Read the standard before adding or revising a kind.'
10
- - 'Write a new diagram to `.canon/diagrams/<kind>.md`, never to `.claude/DIAGRAMS.md`'
11
- - 'Convert a `.claude/DIAGRAMS.md` left by an older install into per-kind entries before editing it'
12
9
  ---
13
10
 
14
11
  # Diagram reference