@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +8 -6
- package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
- package/claude/skills/draft-doc/SKILL.md +77 -0
- package/claude/skills/draft-doc/references/context.md +31 -0
- package/claude/skills/draft-doc/references/docs.md +33 -0
- package/claude/skills/draft-doc/references/readme.md +58 -0
- package/claude/skills/draft-doc/references/wiki.md +53 -0
- package/claude/skills/draft-doc/references/wireframes.md +48 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +5 -46
- package/claude/skills/git-pr/references/final-command.md +75 -0
- package/claude/skills/git-pr/references/labels.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
- package/claude/skills/search-craft/REQUIREMENT.md +4 -4
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/claude/skills/target-check/SKILL.md +9 -9
- package/claude/skills/target-check/references/domains.md +11 -5
- package/docs/agents/architecture-record.md +2 -2
- package/docs/agents/commands.md +1 -1
- package/docs/agents/drift-surfaces.md +20 -9
- package/docs/agents/index.md +3 -3
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/slides.md +47 -3
- package/docs/agents/tasks-validate.md +4 -2
- package/docs/agents/tasks.md +2 -2
- package/docs/target/migrations.md +3 -1
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/skill-map.md +14 -18
- package/governance/rules/canon/604-scratch.md +0 -1
- package/governance/rules/claude/575-hooks.md +1 -2
- package/governance/rules/claude/576-settings.md +1 -2
- package/governance/rules/standards/architecture.md +0 -1
- package/governance/rules/standards/context.md +0 -1
- package/governance/rules/standards/design.md +0 -1
- package/governance/rules/standards/diagrams.md +0 -3
- package/governance/rules/standards/requirements.md +0 -1
- package/governance/rules/standards/wireframes.md +0 -1
- package/package.json +1 -1
- package/scripts/manage-sandbox.sh +7 -8
- package/src/claude/cases/authoring.ts +5 -5
- package/src/commands/slides.ts +14 -4
- package/src/commands/sync.ts +10 -0
- package/src/commands/tasks.ts +16 -0
- package/src/gov/citations.ts +1 -2
- package/src/migrate/skill-names.ts +12 -0
- package/src/slides/convert/deck.ts +83 -3
- package/src/slides/convert/export.ts +67 -19
- package/src/slides/convert/shapes.ts +43 -3
- package/src/slides/convert/walk.ts +43 -1
- package/src/slides/package/fonts.ts +297 -0
- package/src/slides/package/motion.ts +316 -0
- package/src/slides/package/test-face.ts +113 -0
- package/src/sync/check.ts +9 -0
- package/src/sync/layout.ts +27 -1
- package/src/tasks/validate.ts +65 -0
- package/standards/architecture.md +2 -3
- package/standards/board.md +2 -0
- package/standards/context.md +0 -1
- package/standards/design.md +0 -1
- package/standards/diagrams.md +0 -3
- package/standards/requirements.md +3 -2
- package/standards/rule.md +1 -0
- package/standards/wireframes.md +0 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +10 -5
- package/tooling/claude/seeds/.claude/hooks/{tasks-index.sh → records-index.sh} +42 -35
- package/tooling/claude/seeds/.claude/settings.json +1 -5
- package/tooling/claude/seeds/canon/ARCHITECTURE.md +1 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +2 -0
- package/claude/skills/draft-context/REQUIREMENT.md +0 -37
- package/claude/skills/draft-context/SKILL.md +0 -61
- package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
- package/claude/skills/draft-docs/SKILL.md +0 -59
- package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
- package/claude/skills/draft-readme/SKILL.md +0 -73
- package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
- package/claude/skills/draft-wiki/SKILL.md +0 -83
- package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
- package/claude/skills/draft-wireframes/SKILL.md +0 -69
- 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),
|
package/src/sync/layout.ts
CHANGED
|
@@ -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
|
package/src/tasks/validate.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/standards/board.md
CHANGED
|
@@ -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.
|
package/standards/context.md
CHANGED
|
@@ -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.'
|
package/standards/design.md
CHANGED
package/standards/diagrams.md
CHANGED
|
@@ -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
|