@erclx/canon 5.20.0 → 5.22.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 (98) 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/auto-ship/references/verb-absent.md +1 -1
  5. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  6. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  7. package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
  8. package/claude/skills/draft-doc/SKILL.md +77 -0
  9. package/claude/skills/draft-doc/references/context.md +31 -0
  10. package/claude/skills/draft-doc/references/docs.md +33 -0
  11. package/claude/skills/draft-doc/references/readme.md +58 -0
  12. package/claude/skills/draft-doc/references/wiki.md +53 -0
  13. package/claude/skills/draft-doc/references/wireframes.md +48 -0
  14. package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
  15. package/claude/skills/draft-figure/SKILL.md +1 -1
  16. package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
  17. package/claude/skills/draft-ready/SKILL.md +6 -4
  18. package/claude/skills/git-pr/SKILL.md +5 -46
  19. package/claude/skills/git-pr/references/final-command.md +75 -0
  20. package/claude/skills/git-pr/references/labels.md +2 -2
  21. package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
  22. package/claude/skills/markdown-propose/SKILL.md +1 -1
  23. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
  25. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
  26. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  27. package/claude/skills/search-craft/SKILL.md +1 -1
  28. package/docs/agents/commands.md +1 -0
  29. package/docs/agents/drift-surfaces.md +20 -9
  30. package/docs/agents/index.md +2 -1
  31. package/docs/agents/install-and-sync.md +3 -3
  32. package/docs/agents/markdown-audit.md +1 -3
  33. package/docs/agents/migrate.md +1 -1
  34. package/docs/agents/rule-citations.md +1 -1
  35. package/docs/agents/slides.md +164 -0
  36. package/docs/agents/tasks-validate.md +1 -1
  37. package/docs/agents/tasks.md +2 -2
  38. package/docs/target/migrations.md +4 -2
  39. package/docs/target/sync.md +1 -1
  40. package/docs/workflow/ai-workflow.md +1 -1
  41. package/docs/workflow/skill-map.md +25 -31
  42. package/governance/rules/canon/604-scratch.md +0 -1
  43. package/governance/rules/claude/575-hooks.md +1 -2
  44. package/governance/rules/claude/576-settings.md +1 -2
  45. package/governance/rules/lib/330-testing-py.md +1 -1
  46. package/governance/rules/lib/336-testing-php.md +1 -1
  47. package/governance/rules/standards/architecture.md +0 -1
  48. package/governance/rules/standards/context.md +0 -1
  49. package/governance/rules/standards/design.md +0 -1
  50. package/governance/rules/standards/diagrams.md +0 -3
  51. package/governance/rules/standards/requirements.md +0 -1
  52. package/governance/rules/standards/wireframes.md +0 -1
  53. package/package.json +1 -1
  54. package/scripts/manage-sandbox.sh +7 -8
  55. package/src/claude/cases/authoring.ts +5 -5
  56. package/src/claude/cases/workflow.ts +4 -20
  57. package/src/commands/slides.ts +23 -3
  58. package/src/commands/sync.ts +10 -0
  59. package/src/commands/tasks.ts +46 -2
  60. package/src/gov/citations.ts +1 -2
  61. package/src/migrate/skill-names.ts +12 -7
  62. package/src/slides/convert/chart.ts +165 -0
  63. package/src/slides/convert/deck.ts +244 -0
  64. package/src/slides/convert/export.ts +281 -23
  65. package/src/slides/convert/master.ts +195 -0
  66. package/src/slides/convert/shapes.ts +44 -4
  67. package/src/slides/convert/walk.ts +229 -2
  68. package/src/slides/package/fonts.ts +297 -0
  69. package/src/slides/package/motion.ts +316 -0
  70. package/src/slides/package/test-face.ts +113 -0
  71. package/src/sync/check.ts +9 -0
  72. package/src/sync/layout.ts +27 -1
  73. package/src/tasks/record.ts +11 -1
  74. package/src/tasks/validate.ts +14 -0
  75. package/standards/architecture.md +0 -1
  76. package/standards/context.md +0 -1
  77. package/standards/design.md +0 -1
  78. package/standards/diagrams.md +0 -3
  79. package/standards/requirements.md +0 -1
  80. package/standards/rule.md +1 -0
  81. package/standards/tasks.md +1 -1
  82. package/standards/wireframes.md +0 -1
  83. package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
  84. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
  85. package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
  86. package/claude/skills/docs-fold/SKILL.md +0 -9
  87. package/claude/skills/draft-context/REQUIREMENT.md +0 -37
  88. package/claude/skills/draft-context/SKILL.md +0 -61
  89. package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
  90. package/claude/skills/draft-docs/SKILL.md +0 -59
  91. package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
  92. package/claude/skills/draft-readme/SKILL.md +0 -73
  93. package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
  94. package/claude/skills/draft-wiki/SKILL.md +0 -83
  95. package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
  96. package/claude/skills/draft-wireframes/SKILL.md +0 -69
  97. package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
  98. package/claude/skills/standards-audit/SKILL.md +0 -99
@@ -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
@@ -512,12 +512,18 @@ export async function recordPlan(
512
512
  * line, so the merge of an earlier slice cannot archive the task in the window
513
513
  * before this branch's own number is recorded. A rerun that ticks nothing adds
514
514
  * no marker, since its branch has already recorded or is already listed.
515
+ *
516
+ * `branchPulls` holds the pull request numbers the branch's own head carries.
517
+ * One already on the task's `Pull request:` line means the branch recorded its
518
+ * number, so a marker would outlive the branch with nothing left to clear it.
519
+ * An absent or empty list keeps the marker, which is the first slice's case.
515
520
  */
516
521
  export async function closeOutcomes(
517
522
  root: string,
518
523
  selector: RecordSelector,
519
524
  positions: readonly number[],
520
525
  branch?: string,
526
+ branchPulls: readonly number[] = [],
521
527
  ): Promise<CloseOutcome> {
522
528
  const opened = await openTask(root, selector)
523
529
  if ('ok' in opened) return opened
@@ -541,8 +547,12 @@ export async function closeOutcomes(
541
547
  )
542
548
  }
543
549
 
550
+ const recorded = readPullRequest(result.text).some((number) =>
551
+ branchPulls.includes(number),
552
+ )
553
+
544
554
  const text =
545
- result.closed.length > 0 && branch
555
+ result.closed.length > 0 && branch && !recorded
546
556
  ? writePendingBranch(result.text, branch).text
547
557
  : result.text
548
558
 
@@ -6,6 +6,7 @@ import {
6
6
  declinedDir,
7
7
  isReservedStem,
8
8
  readOutcomes,
9
+ readPendingBranches,
9
10
  readPlanTargets,
10
11
  readPullRequest,
11
12
  resolveLivePlan,
@@ -1065,6 +1066,10 @@ function nothing(): CitedResult {
1065
1066
  * about, and a task naming none leaves the row untested rather than settled,
1066
1067
  * because the only local signal left is the checkbox that produced the defect.
1067
1068
  *
1069
+ * A task still carrying a pending branch is untested whatever its recorded
1070
+ * pull request says, since a slice is in flight that has recorded no number yet
1071
+ * and the landed one may belong to an earlier slice.
1072
+ *
1068
1073
  * The outcome list comes off `readOutcomes` rather than a pattern of its own,
1069
1074
  * so this check cannot disagree with the archive and outcome verbs about which
1070
1075
  * checkboxes are outcomes and which sit inside a block a task displays.
@@ -1114,6 +1119,15 @@ async function checkCitedTask(
1114
1119
  const { open, closed } = readOutcomes(text)
1115
1120
  if (open.length > 0 || closed.length === 0) return nothing()
1116
1121
 
1122
+ const pending = readPendingBranches(text)
1123
+ if (pending.length > 0) {
1124
+ return untestedRow(
1125
+ group,
1126
+ subject,
1127
+ `waits on ${cited}, which closed every outcome but holds a pending branch (${pending.join(', ')}) that has yet to record its pull request.`,
1128
+ )
1129
+ }
1130
+
1117
1131
  // The last number is the newest slice, and an earlier one closed without
1118
1132
  // merging never lands, so only the last says whether the work reached trunk.
1119
1133
  const pullRequest = readPullRequest(text).at(-1)
@@ -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
  ---
@@ -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
@@ -3,7 +3,6 @@ title: Requirements reference
3
3
  description: Shape and content rules for canon/REQUIREMENTS.md
4
4
  paths:
5
5
  - 'canon/REQUIREMENTS.md'
6
- - '.claude/REQUIREMENTS.md'
7
6
  ---
8
7
 
9
8
  # Requirements reference
package/standards/rule.md CHANGED
@@ -89,6 +89,7 @@ paths:
89
89
  - Cut any rule that resists crisp one-line phrasing. Vague guidance is worse than none.
90
90
  - Keep the file to one topic. A second topic is a second rule file.
91
91
  - Do not restate a rule that a sibling rule or `CLAUDE.md` already owns. Point once, never duplicate.
92
+ - Name the current layout only, in a glob and in the body alike. A layout change ships a migration verb, as `canon migrate surface-roots` and `canon migrate records` did, rather than a rule line or a glob that keeps the old spelling loading.
92
93
 
93
94
  ## Examples
94
95
 
@@ -141,7 +141,7 @@ One plan per task, or one per slice, which the board check reads as distinct pla
141
141
 
142
142
  `Pull request:` records which pull requests carry the task's work, each as a bare `#NNN` the way `Issue:` does. It lists every pull request that shipped part of the task, oldest first and separated by commas, as in `Pull request: #NNN, #NNN`, and a task shipped whole lists one. It is not an origin, so a task without one is well-formed. `git-pr` appends to it when a pull request opens.
143
143
 
144
- The line is what lets a merge close its own task. `canon tasks archive --pull-request <n>` finds the task by any number the line lists and archives it only on the last. One pull request, one task: two tasks naming the same number refuse to archive rather than both moving. `Pending branch: <branch>` holds that merge back while a branch that ticked an outcome waits on its own number, since `canon tasks outcome` writes it and `canon tasks pull-request` clears it.
144
+ The line is what lets a merge close its own task. `canon tasks archive --pull-request <n>` finds the task by any number the line lists and archives it only on the last. One pull request, one task: two tasks naming the same number refuse to archive rather than both moving. `Pending branch: <branch>` holds that merge back while a branch that ticked an outcome waits on its own number, since `canon tasks outcome` writes it and `canon tasks pull-request` clears it. `canon tasks outcome` skips the write when the branch's own pull request is already on the line.
145
145
 
146
146
  ## What goes in
147
147
 
@@ -3,7 +3,6 @@ title: Wireframe reference
3
3
  description: Shape and content rules for canon/wireframes/<surface>.md files
4
4
  paths:
5
5
  - 'canon/wireframes/**'
6
- - '.claude/wireframes/**'
7
6
  rule:
8
7
  - 'Read it before adding or revising a surface.'
9
8
  ---
@@ -20,12 +20,12 @@
20
20
  * `scripts/check-card-exclusion.sh` fails a build that carries the marker
21
21
  * below, which is what keeps the route out of the published site.
22
22
  */
23
- import '@/styles/global.css'
24
23
 
25
- // Retarget the import above when this project keeps its global stylesheet
24
+ // Point the import below at this project's global stylesheet when it lives
26
25
  // somewhere else. An unresolved path refuses the card server by name, which is
27
26
  // the loud direction: a card captured against no stylesheet renders as an
28
27
  // unstyled box and reports success.
28
+ import '@/styles/global.css'
29
29
  ---
30
30
 
31
31
  <html lang="en">