@erclx/canon 5.16.0 → 5.18.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 (57) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  3. package/claude/skills/deploy-app/SKILL.md +45 -0
  4. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  5. package/claude/skills/deploy-app/references/vercel.md +72 -0
  6. package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
  7. package/claude/skills/draft-figure/SKILL.md +19 -4
  8. package/claude/skills/draft-wireframes/SKILL.md +1 -1
  9. package/claude/skills/teach-workspace/SKILL.md +1 -1
  10. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  11. package/docs/agents/architecture-record.md +12 -4
  12. package/docs/agents/canvas.md +13 -1
  13. package/docs/agents/commands.md +2 -2
  14. package/docs/agents/context-audit-checks.md +1 -1
  15. package/docs/agents/context-audit.md +4 -4
  16. package/docs/agents/demo.md +3 -1
  17. package/docs/agents/index.md +2 -2
  18. package/docs/agents/teach.md +2 -2
  19. package/docs/workflow/skill-map.md +9 -9
  20. package/docs/workflow/visual-design-workflow.md +8 -15
  21. package/governance/rules/claude/566-output.md +3 -5
  22. package/governance/rules/claude/575-hooks.md +1 -1
  23. package/package.json +1 -1
  24. package/src/audits/catalog.ts +46 -1
  25. package/src/canvas/address.ts +73 -39
  26. package/src/canvas/client/inspector.tsx +241 -33
  27. package/src/canvas/client/pages-panel.tsx +51 -0
  28. package/src/canvas/client/shell.css +126 -1
  29. package/src/canvas/client/state.ts +120 -4
  30. package/src/canvas/client/theme-panel.tsx +55 -0
  31. package/src/canvas/content.ts +14 -3
  32. package/src/canvas/edit.ts +360 -0
  33. package/src/canvas/server.ts +50 -5
  34. package/src/canvas/tokens.ts +82 -0
  35. package/src/claude/cases/misc.ts +5 -1
  36. package/src/commands/canvas.ts +95 -1
  37. package/src/commands/context.ts +85 -5
  38. package/src/commands/demo.ts +2 -0
  39. package/src/commands/teach.ts +3 -1
  40. package/src/context/architecture.ts +98 -18
  41. package/src/context/gate.ts +30 -3
  42. package/src/context/requirements.ts +77 -0
  43. package/src/demo/drive.ts +36 -7
  44. package/src/demo/timeline.ts +64 -0
  45. package/src/gate/measures.ts +69 -28
  46. package/src/gate/stages.ts +5 -5
  47. package/src/teach/workspace.ts +61 -6
  48. package/standards/architecture.md +13 -7
  49. package/standards/figures.md +1 -0
  50. package/standards/requirements.md +4 -0
  51. package/tooling/claude/reference.md +1 -1
  52. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
  53. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
  54. package/tooling/claude/seeds/.claude/settings.json +9 -0
  55. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  56. package/tooling/cloudflare/reference.md +1 -1
  57. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
package/src/demo/drive.ts CHANGED
@@ -8,6 +8,8 @@ import { deriveSteps } from '@/demo/compile'
8
8
  import type { DemoPlan, DemoStep } from '@/demo/compile'
9
9
  import type { CursorSet } from '@/demo/pointer'
10
10
  import { pointerSource } from '@/demo/pointer'
11
+ import type { StepTiming, TargetBox } from '@/demo/timeline'
12
+ import { timelineEntry, timelinePath, writeTimeline } from '@/demo/timeline'
11
13
 
12
14
  declare global {
13
15
  interface Window {
@@ -84,6 +86,7 @@ export type DriveResult =
84
86
  status: 'recorded'
85
87
  videoPath?: string
86
88
  stillPath?: string
89
+ timelinePath?: string
87
90
  steps: number
88
91
  durationMs: number
89
92
  }
@@ -150,6 +153,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
150
153
 
151
154
  let stillPath: string | undefined
152
155
  let videoPath: string | undefined
156
+ let timeline: string | undefined
153
157
 
154
158
  try {
155
159
  await context.addInitScript({
@@ -157,7 +161,12 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
157
161
  })
158
162
  await context.addInitScript({ content: captionInitScript() })
159
163
  const page = await context.newPage()
164
+ // The recording begins with the page, so this is the instant a frame of
165
+ // the take is zero. Taking it at the first step instead would drift every
166
+ // entry by the opening page load.
167
+ const recordingStart = performance.now()
160
168
  const video = page.video()
169
+ const timings: StepTiming[] = []
161
170
  const pace: PointerPace = {}
162
171
 
163
172
  // The opening navigate is skipped when the plan already starts with one,
@@ -170,8 +179,16 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
170
179
  }
171
180
  }
172
181
 
173
- for (const step of plan.steps) {
174
- await runStep(page, plan, step, pace)
182
+ for (const [index, step] of plan.steps.entries()) {
183
+ const startedAt = performance.now()
184
+ const box = await runStep(page, plan, step, pace)
185
+ timings.push({
186
+ index,
187
+ kind: step.kind,
188
+ startedAt,
189
+ endedAt: performance.now(),
190
+ ...(box ? { box } : {}),
191
+ })
175
192
  // The first marked step wins. One file holds one frame, so a plan a
176
193
  // person edited to mark several would otherwise write each over the last
177
194
  // and keep whichever ran last, with nothing saying so.
@@ -191,6 +208,11 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
191
208
  // The engine keeps the auto-named recording beside the saved copy, so a
192
209
  // run that skipped this would leave two files for every demo.
193
210
  await video.delete()
211
+ timeline = timelinePath(videoPath)
212
+ writeTimeline(
213
+ timeline,
214
+ timings.map((timing) => timelineEntry(timing, recordingStart)),
215
+ )
194
216
  }
195
217
  } catch (error) {
196
218
  return failed('drive-failed', error)
@@ -203,6 +225,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
203
225
  status: 'recorded',
204
226
  ...(videoPath ? { videoPath } : {}),
205
227
  ...(stillPath ? { stillPath } : {}),
228
+ ...(timeline ? { timelinePath: timeline } : {}),
206
229
  steps: plan.steps.length,
207
230
  durationMs: Date.now() - started,
208
231
  }
@@ -237,13 +260,17 @@ export interface PointerPace {
237
260
  * Exported so `drive.e2e.test.ts` can drive one real step against a real
238
261
  * caption and read it back, which is the integration a full `drive()` call
239
262
  * cannot assert without decoding the video it writes.
263
+ *
264
+ * Returns the box the pointer moved to, for the timeline, and nothing for
265
+ * a step that points at no target.
240
266
  */
241
267
  export async function runStep(
242
268
  page: Page,
243
269
  plan: DemoPlan,
244
270
  step: DemoStep,
245
271
  pace: PointerPace,
246
- ): Promise<void> {
272
+ ): Promise<TargetBox | undefined> {
273
+ let box: TargetBox | undefined
247
274
  switch (step.kind) {
248
275
  case 'navigate':
249
276
  await page.goto(step.target || plan.url)
@@ -252,18 +279,18 @@ export async function runStep(
252
279
  }
253
280
  break
254
281
  case 'click':
255
- await moveTo(page, plan, step, pace)
282
+ box = await moveTo(page, plan, step, pace)
256
283
  await page.mouse.down()
257
284
  await page.mouse.up()
258
285
  break
259
286
  case 'fill':
260
- await moveTo(page, plan, step, pace)
287
+ box = await moveTo(page, plan, step, pace)
261
288
  await page.mouse.down()
262
289
  await page.mouse.up()
263
290
  await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
264
291
  break
265
292
  case 'hover':
266
- await moveTo(page, plan, step, pace)
293
+ box = await moveTo(page, plan, step, pace)
267
294
  break
268
295
  case 'scroll':
269
296
  // Centred rather than `scrollIntoViewIfNeeded`, which scrolls the least
@@ -307,6 +334,7 @@ export async function runStep(
307
334
  await setCaption(page, step.caption)
308
335
  if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
309
336
  await page.waitForTimeout(step.holdMs)
337
+ return box
310
338
  }
311
339
 
312
340
  /**
@@ -333,7 +361,7 @@ async function moveTo(
333
361
  plan: DemoPlan,
334
362
  step: DemoStep,
335
363
  pace: PointerPace,
336
- ): Promise<void> {
364
+ ): Promise<TargetBox> {
337
365
  pace.roundTripMs ??= await calibrateRoundTrip(page)
338
366
  const locator = page.locator(step.target).first()
339
367
  await locator.waitFor()
@@ -342,6 +370,7 @@ async function moveTo(
342
370
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
343
371
  steps: deriveSteps(plan.pointer.travelMs, pace.roundTripMs),
344
372
  })
373
+ return box
345
374
  }
346
375
 
347
376
  /**
@@ -0,0 +1,64 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname, extname, join } from 'node:path'
3
+ import type { DemoStep } from '@/demo/compile'
4
+
5
+ /**
6
+ * A step's target in viewport pixels, which are the video frame's own
7
+ * coordinates because the recording is sized to the viewport.
8
+ */
9
+ export interface TargetBox {
10
+ readonly x: number
11
+ readonly y: number
12
+ readonly width: number
13
+ readonly height: number
14
+ }
15
+
16
+ /** One step as the run clocked it, in wall-clock milliseconds. */
17
+ export interface StepTiming {
18
+ readonly index: number
19
+ readonly kind: DemoStep['kind']
20
+ readonly startedAt: number
21
+ readonly endedAt: number
22
+ readonly box?: TargetBox
23
+ }
24
+
25
+ /**
26
+ * Times count from the recording's start rather than the first step, since
27
+ * that is the clock a composition places the take on. A step ends once its
28
+ * hold has played, so the span covers what the viewer sees of it.
29
+ */
30
+ export interface TimelineEntry {
31
+ readonly index: number
32
+ readonly kind: DemoStep['kind']
33
+ readonly startMs: number
34
+ readonly endMs: number
35
+ readonly box?: TargetBox
36
+ }
37
+
38
+ export function timelineEntry(
39
+ timing: StepTiming,
40
+ recordingStart: number,
41
+ ): TimelineEntry {
42
+ return {
43
+ index: timing.index,
44
+ kind: timing.kind,
45
+ startMs: Math.round(timing.startedAt - recordingStart),
46
+ endMs: Math.round(timing.endedAt - recordingStart),
47
+ ...(timing.box ? { box: timing.box } : {}),
48
+ }
49
+ }
50
+
51
+ export function timelinePath(videoPath: string): string {
52
+ return join(
53
+ dirname(videoPath),
54
+ `${basename(videoPath, extname(videoPath))}.timeline.json`,
55
+ )
56
+ }
57
+
58
+ export function writeTimeline(
59
+ path: string,
60
+ entries: readonly TimelineEntry[],
61
+ ): void {
62
+ mkdirSync(dirname(path), { recursive: true })
63
+ writeFileSync(path, `${JSON.stringify({ entries }, null, 2)}\n`)
64
+ }
@@ -10,9 +10,12 @@ import {
10
10
  import {
11
11
  isOverCount,
12
12
  isOverLength,
13
+ isOverRisks,
13
14
  measureArchitecture,
14
15
  missingRevisit,
16
+ overWords,
15
17
  } from '@/context/architecture'
18
+ import { isOverWordCap, measureRequirements } from '@/context/requirements'
16
19
  import {
17
20
  auditSkills,
18
21
  PRACTICE_LEDGER,
@@ -246,42 +249,80 @@ function citedPaths(record: { paths?: unknown } | undefined): string[] {
246
249
  }
247
250
 
248
251
  /**
249
- * The architecture record against the three rules it states for itself: the
250
- * line ceiling its allowances derive, the entry cap, and the revisit sentence
251
- * closing each decision.
252
+ * The canonical records against the caps each states for itself. The
253
+ * architecture record carries five: the line ceiling its allowances derive,
254
+ * the entry cap, the revisit sentence closing each decision, the words a
255
+ * decision may hold, and the bullets its Risks section may hold. The
256
+ * requirements record carries one, its own word count.
252
257
  *
253
258
  * Read in-process rather than through `context audit`, whose one gating stage
254
- * here runs `--citations-only` and never opens the record, which is why the
255
- * line ceiling went unenforced by `bun run check` until this stage. A project
256
- * carrying no record, or a record stating none of the three, passes, since
257
- * each rule belongs to the record rather than to the toolkit.
259
+ * here runs `--citations-only` and never opens either record, which is why
260
+ * the line ceiling went unenforced by `bun run check` until this stage. A
261
+ * project carrying neither record, or records stating none of the caps,
262
+ * passes, since each rule belongs to the record rather than to the toolkit.
258
263
  */
259
- export const architectureRecord: Measure = async (ctx) => {
260
- const report = await measureArchitecture(ctx.root)
264
+ export const canonicalRecords: Measure = async (ctx) => {
265
+ const [report, requirements] = await Promise.all([
266
+ measureArchitecture(ctx.root),
267
+ measureRequirements(ctx.root),
268
+ ])
269
+
270
+ const failures: string[] = []
271
+ const emissions: Emission[] = []
272
+
261
273
  if (report === undefined) {
262
- return { emissions: [info('No architecture record to measure')] }
274
+ emissions.push(info('No architecture record to measure'))
275
+ } else {
276
+ const decisions = report.decisions.length
277
+ const lackingRevisit = missingRevisit(report)
278
+ const longDecisions = overWords(report)
279
+ failures.push(
280
+ ...[
281
+ isOverCount(report) &&
282
+ `${decisions} decisions against a cap of ${report.entryCap}. Merge two or retire one in ${report.rel}, never compress.`,
283
+ isOverLength(report) &&
284
+ `${report.lines} lines against a ceiling of ${report.ceiling} in ${report.rel}.`,
285
+ lackingRevisit.length > 0 &&
286
+ `No revisit sentence in ${lackingRevisit.map((heading) => `"${heading}"`).join(', ')}. Close each with one opening "Revisit when" in ${report.rel}.`,
287
+ longDecisions.length > 0 &&
288
+ `${longDecisions.map((entry) => `"${entry.heading}" (${entry.words})`).join(', ')} past ${report.wordCap} words a decision in ${report.rel}. Cut the reasoning to the trade, never split one decision across two headings.`,
289
+ isOverRisks(report) &&
290
+ `${report.risksBullets} risk bullets against a cap of ${report.riskCap} in ${report.rel}. Drop what is no longer open.`,
291
+ ].filter((failure): failure is string => typeof failure === 'string'),
292
+ )
293
+
294
+ const cap =
295
+ report.entryCap === undefined
296
+ ? 'no entry cap stated'
297
+ : `a cap of ${report.entryCap}`
298
+ emissions.push(info(`${decisions} decisions against ${cap}`))
299
+ if (report.riskCap !== undefined && report.risksBullets === undefined) {
300
+ emissions.push(
301
+ info(
302
+ `No Risks section to count against a cap of ${report.riskCap} in ${report.rel}`,
303
+ ),
304
+ )
305
+ }
263
306
  }
264
307
 
265
- const decisions = report.decisions.length
266
- const lackingRevisit = missingRevisit(report)
267
- const failures = [
268
- isOverCount(report) &&
269
- `${decisions} decisions against a cap of ${report.entryCap}. Merge two or retire one in ${report.rel}, never compress.`,
270
- isOverLength(report) &&
271
- `${report.lines} lines against a ceiling of ${report.ceiling} in ${report.rel}.`,
272
- lackingRevisit.length > 0 &&
273
- `No revisit sentence in ${lackingRevisit.map((heading) => `"${heading}"`).join(', ')}. Close each with one opening "Revisit when" in ${report.rel}.`,
274
- ].filter((failure): failure is string => typeof failure === 'string')
308
+ if (requirements === undefined) {
309
+ emissions.push(info('No requirements record to measure'))
310
+ } else if (isOverWordCap(requirements)) {
311
+ failures.push(
312
+ `${requirements.words} words against a cap of ${requirements.wordCap} in ${requirements.rel}.`,
313
+ )
314
+ } else {
315
+ const cap =
316
+ requirements.wordCap === undefined
317
+ ? 'no word cap stated'
318
+ : `a cap of ${requirements.wordCap}`
319
+ emissions.push(
320
+ info(`${requirements.words} requirements words against ${cap}`),
321
+ )
322
+ }
275
323
 
276
324
  if (failures.length > 0) return { emissions: [], failure: failures.join(' ') }
277
-
278
- const cap =
279
- report.entryCap === undefined
280
- ? 'no entry cap stated'
281
- : `a cap of ${report.entryCap}`
282
- return {
283
- emissions: [info(`${decisions} decisions against ${cap}`)],
284
- }
325
+ return { emissions }
285
326
  }
286
327
 
287
328
  /**
@@ -6,7 +6,7 @@ import {
6
6
  skillPaths,
7
7
  } from '@/gate/boundaries'
8
8
  import {
9
- architectureRecord,
9
+ canonicalRecords,
10
10
  auditSet,
11
11
  captureStamps,
12
12
  clientCommandCitations,
@@ -346,11 +346,11 @@ export const STAGES: readonly Stage[] = [
346
346
  success: 'Context citations resolve',
347
347
  },
348
348
  {
349
- // The record's three rules are facts it states about itself, unlike the
349
+ // Each record's caps are facts it states about itself, unlike the
350
350
  // judgment thresholds the stage above leaves out, so they gate here.
351
- id: 'architecture-record',
352
- label: 'Architecture record',
353
- checks: [{ kind: 'measure', measure: architectureRecord }],
351
+ id: 'canonical-records',
352
+ label: 'Canonical records',
353
+ checks: [{ kind: 'measure', measure: canonicalRecords }],
354
354
  },
355
355
  {
356
356
  // Unscoped, since any branch can grow any document. A document past the
@@ -885,12 +885,60 @@ async function openFile(
885
885
  return path
886
886
  }
887
887
 
888
+ /** The entry lines of the file, or of one heading's section. */
889
+ function listedEntries(text: string, heading?: string): string[] {
890
+ const body = unfenced(text)
891
+ const section = heading
892
+ ? sectionRange(body, heading, text.split('\n').length)
893
+ : undefined
894
+ if (heading && !section) return []
895
+
896
+ return body
897
+ .filter(
898
+ (line) =>
899
+ isEntry(line.text) &&
900
+ (!section ||
901
+ (line.number - 1 >= section.start && line.number - 1 < section.end)),
902
+ )
903
+ .map((line) => line.text)
904
+ }
905
+
906
+ function lists(entries: readonly string[], source: Source): boolean {
907
+ return entries.some((line) => line.includes(`(${source.url})`))
908
+ }
909
+
910
+ /**
911
+ * Drops the lead entries naming the given URLs, wrapped continuation lines
912
+ * included, and restores the placeholder when the section empties.
913
+ */
914
+ function withoutLeads(text: string, urls: ReadonlySet<string>): string {
915
+ const lines = text.split('\n')
916
+ const section = sectionRange(unfenced(text), LEADS_HEADING, lines.length)
917
+ if (!section) return text
918
+
919
+ const bullets = bulletRange(unfenced(text), section)
920
+ if (!bullets) return text
921
+
922
+ const kept = bulletBlocks(lines.slice(bullets.start, bullets.end)).filter(
923
+ (block) => ![...urls].some((url) => block[0].includes(`(${url})`)),
924
+ )
925
+
926
+ return [
927
+ ...lines.slice(0, bullets.start),
928
+ ...(kept.length > 0 ? kept.flat() : [PLACEHOLDER]),
929
+ ...lines.slice(bullets.end),
930
+ ].join('\n')
931
+ }
932
+
888
933
  /**
889
934
  * Appends sources to `RESOURCES.md`, keeping what was read apart from what was
890
935
  * only found.
891
936
  *
892
937
  * A URL already listed under either heading is refused rather than repeated,
893
938
  * since a second entry for one source splits what rests on it across two lines.
939
+ * The one exception is a read source found under `## Leads`, which moves to
940
+ * `## Read` under its new title, since opening a lead is how it becomes read.
941
+ * Any refusal leaves the file untouched, so a batch lands whole or not at all.
894
942
  */
895
943
  export async function recordSources(
896
944
  root: string,
@@ -905,13 +953,16 @@ export async function recordSources(
905
953
  if (typeof path !== 'string') return path
906
954
 
907
955
  let text = await readFile(path, 'utf8')
908
- const listed = unfenced(text)
909
- .map((line) => line.text)
910
- .filter(isEntry)
956
+ const listed = listedEntries(text)
957
+ const leadEntries = listedEntries(text, LEADS_HEADING)
911
958
 
912
- const repeated = [...read, ...leads].filter((source) =>
913
- listed.some((line) => line.includes(`(${source.url})`)),
914
- )
959
+ const isPromotion = (source: Source) => lists(leadEntries, source)
960
+ const promoted = read.filter(isPromotion)
961
+
962
+ const repeated = [
963
+ ...read.filter((source) => !isPromotion(source)),
964
+ ...leads,
965
+ ].filter((source) => lists(listed, source))
915
966
 
916
967
  if (repeated.length > 0) {
917
968
  return refuse(
@@ -921,6 +972,10 @@ export async function recordSources(
921
972
  )
922
973
  }
923
974
 
975
+ if (promoted.length > 0) {
976
+ text = withoutLeads(text, new Set(promoted.map((source) => source.url)))
977
+ }
978
+
924
979
  for (const [heading, sources] of [
925
980
  [READ_HEADING, read],
926
981
  [LEADS_HEADING, leads],
@@ -73,18 +73,24 @@ A decision says why it won and rarely says when that reason stops holding, so an
73
73
  - Draft it from the harm the decision prevents, never from the mechanism it uses. A mechanism can keep working long after the reason for choosing it is gone, so re-checking it confirms the wrong fact.
74
74
  - Place the sentence after the reasoning and before any verification anchor, so the anchor stays the entry's last sentence.
75
75
  - Give a heading holding several principles one sentence, for the premise they share, rather than one per principle.
76
- - Adopt the requirement by stating the clause `Every decision closes with a revisit sentence.` in the record itself. The clause is the record's own, like the cap, so a checker reads it from the file and gates only a record that states it. A record stating none is reported and never gated.
76
+ - Adopt the requirement by stating the clause `Every decision closes with a revisit sentence.` in the record itself. The clause is the record's own, like the caps, so a checker reads it from the file and gates only a record that states it. A record stating none is reported and never gated.
77
77
  - A checker reads presence alone. A vacuous sentence passes it, so the sentence is only as useful as the finding it names.
78
78
 
79
- ## Entry cap
79
+ ## Caps
80
80
 
81
- Every session pays for this file before any work starts, so a heavy read is a real cost. A file that reads heavy is carrying too many decisions, not decisions written too long, so the bound is a count of decisions rather than of words.
81
+ Every session pays for this file before any work starts, so a heavy read is a real cost. Weight comes from two places, too many decisions and decisions written too long, so the record caps both, and caps the open risks beside them.
82
82
 
83
- - State the cap in the record itself as a clause of the form `This record holds at most <n> decisions.` The cap is the record's own, so a checker reads it from the file, and a record stating none is measured and never gated.
84
- - Set the cap near one decision per slot plus a small margin.
85
- - At the cap, merge two decisions or retire one before adding another, and name which in the change that does it. Never compress a decision's prose to fit, and never pack two decisions under one heading, which the count cannot see.
83
+ - State each cap in the record itself. A checker reads only these spellings, so a clause worded another way is reported and never gated:
84
+ - `This record holds at most <n> decisions.`
85
+ - `at most <n> words a decision`, counting a decision's prose below its heading, fenced lines left out
86
+ - `at most <n> risk bullets`, counting every list item under `## Risks / open questions`, nested ones included
87
+ - 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.
88
+ - 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.
89
+ - 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.
90
+ - 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.
91
+ - At the risk cap, drop what is no longer open before adding a risk.
86
92
  - Retire a decision by moving it to the domain context entry it constrains, not by deleting its reasoning.
87
- - A word count, for the file and for each decision, is read alongside the judgment when one is available, and it never gates.
93
+ - The whole file's word count stays a report, read alongside the judgment, and never gates.
88
94
  - Yield to the paragraph weight checkpoint in `markdown.md`. A paragraph past the checkpoint is a defect no length guideline licenses.
89
95
 
90
96
  ## Template
@@ -38,6 +38,7 @@ A figure failing these is non-conforming even when it satisfies every shape rule
38
38
 
39
39
  - Route a graph-shaped subject, one whose relationship, boundary, or path a flowchart already expresses, through Mermaid's own hand-drawn look rather than through model-authored SVG coordinates. `mermaid.md` governs the fence itself.
40
40
  - Reserve freehand inline SVG, plain shapes and lines authored directly in the markup, for a subject that is not graph-shaped. A figure that cannot express its subject through the deterministic path is a worse failure than an inconsistent one drawn by hand, so the hatch stays open rather than forcing every figure through the renderer.
41
+ - Draw a figure in a teach lesson freehand whatever its shape. A lesson letters its figures in a hand font the renderer cannot load, so Mermaid measures every label in a narrower fallback face, and the page then clips the wider text it actually shows.
41
42
  - State this as a render-first policy rather than a ban on a tool-exported drawing. Routing a graph-shaped figure through Mermaid is itself running it through an external tool, so a rule banning any diagram "exported from a diagramming tool" would forbid the path this policy requires.
42
43
 
43
44
  ## Wrapping and captioning
@@ -36,6 +36,10 @@ Does not govern:
36
36
 
37
37
  Use `## Problem`, `## Goals`, `## Non-goals`, `## MVP features`, and `## Constraints`. Add `## Distribution` when the rule below applies. Add `## Worldview` when the project holds beliefs that shape every decision, and keep a belief there only when it changes a decision, since a padded worldview turns into a second goals list. Add `## Premise` when the project exists to answer a question, stated as the question and what would count as an answer, never as the answer measured so far. Drop a section rather than pad it with filler.
38
38
 
39
+ ## Length
40
+
41
+ This file loads into every session, so its weight is paid before any work starts. Cap it by stating the clause `This record holds at most <n> words.` in the record itself, where a checker reads it and counts every word below the frontmatter. The cap is the record's own, so a record stating none is measured and never gated. Set it near 600, and at the cap cut a goal or a constraint that no longer changes a decision before adding one.
42
+
39
43
  ## Lifecycle
40
44
 
41
45
  Once every entry in the MVP list, or in a later scope section, has shipped, delete the section. Git keeps the old text, and the document states the project as it stands. Do not annotate an entry as shipped and leave it in place. While any entry is unshipped, do not renumber the list or append to it.
@@ -91,6 +91,6 @@ The first entry covers the whole record root, so a record folder added later nee
91
91
 
92
92
  ## settings.json
93
93
 
94
- - Project seed at `tooling/claude/seeds/.claude/settings.json` ships two PreToolUse hooks (pairing with `.claude/hooks/index-reminder.sh` and `.claude/hooks/scratch-guard.sh`) and a PostToolUse hook (pairs with `.claude/hooks/standards-audit.sh`). Project settings layer over user settings, so per-project files only carry what is genuinely project-specific.
94
+ - Project seed at `tooling/claude/seeds/.claude/settings.json` ships three PreToolUse hooks (pairing with `.claude/hooks/index-reminder.sh`, `.claude/hooks/search-reminder.sh`, and `.claude/hooks/scratch-guard.sh`) and a PostToolUse hook (pairs with `.claude/hooks/standards-audit.sh`). Project settings layer over user settings, so per-project files only carry what is genuinely project-specific.
95
95
  - User-level template at `tooling/claude/user/settings.template.json` carries `attribution.commit` and `attribution.pr` (empty strings to suppress co-author tags), `permissions.allow` with `Bash(bun run *)`, and `permissions.deny` with `Read(**/.env)` and `Read(**/.env.*)` to block accidental secret exposure across any project. Installed once per machine via `canon claude setup`.
96
96
  - The deprecated `includeCoAuthoredBy` key is not used.
@@ -8,10 +8,8 @@
8
8
  # and no error appears. This computes the fact a session was asking itself
9
9
  # to infer on every response and hands the answer back instead.
10
10
  #
11
- # The entrypoint branch (bare vs. `file://` link) stays prose in
12
- # governance/rules/claude/566-output.md. CLAUDE_CODE_ENTRYPOINT is not among
13
- # the environment variables Claude Code documents as passed to a hook
14
- # subprocess, unlike CLAUDE_PROJECT_DIR, so a hook cannot read it reliably.
11
+ # The link form a response uses comes from the harness's own instruction for
12
+ # its surface, not from this hook or the output rule.
15
13
  #
16
14
  # The worktree branch comes from the path rather than from `git rev-parse`,
17
15
  # the way `tasks-index.sh` and `memory-index.sh` already derive their main
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Claude Code sends a payload and closes stdin. A bare read with nothing feeding
4
+ # it blocks forever and holds the session open, so the read is bounded. `read`
5
+ # rather than `timeout cat`, which macOS does not ship.
6
+ IFS= read -r -d '' -t 2 input
7
+ [ -n "$input" ] || {
8
+ printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
9
+ exit 1
10
+ }
11
+
12
+ tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
13
+ [ "$tool" = "WebSearch" ] || exit 0
14
+
15
+ # Without a session id there is no stable key to dedupe on, and a shared marker
16
+ # would silence every later session, so stay quiet rather than guess one.
17
+ session=$(printf '%s' "$input" | jq -r '.session_id // empty')
18
+ [ -n "$session" ] || exit 0
19
+
20
+ key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9' '_')
21
+ # The marker is scratch, so it follows the scratch folder to whichever record
22
+ # root the project carries rather than creating a second one beside it.
23
+ project="${CLAUDE_PROJECT_DIR:-.}"
24
+ if [ -d "$project/.canon" ]; then
25
+ marker_dir="$project/.canon/tmp/hooks/search-reminder"
26
+ else
27
+ marker_dir="$project/.claude/.tmp/hooks/search-reminder"
28
+ fi
29
+ marker="$marker_dir/$key"
30
+ [ -f "$marker" ] && exit 0
31
+ mkdir -p "$marker_dir"
32
+ : >"$marker"
33
+
34
+ msg='Load the canon:search-craft skill before this web search. Report it rather than proceeding silently when the skill does not resolve.'
35
+ jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'
@@ -10,6 +10,15 @@
10
10
  }
11
11
  ]
12
12
  },
13
+ {
14
+ "matcher": "WebSearch",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/search-reminder.sh"
19
+ }
20
+ ]
21
+ },
13
22
  {
14
23
  "matcher": "Write|Edit",
15
24
  "hooks": [
@@ -75,7 +75,7 @@ jobs:
75
75
 
76
76
  - name: Skip notice
77
77
  if: steps.secrets-check.outputs.skip == 'true'
78
- run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-cloudflare skill to finish setup."
78
+ run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-app skill to finish setup."
79
79
 
80
80
  # This job builds nothing and still needs a toolchain. The checkout
81
81
  # below brings bun.lock, wrangler-action reads it, picks bun on the
@@ -110,7 +110,7 @@ jobs:
110
110
  accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
111
111
  wranglerVersion: '3.90.0'
112
112
  # Fill in this project's Pages project name, set when the
113
- # deploy-cloudflare skill runs wrangler pages project create.
113
+ # deploy-app skill runs wrangler pages project create.
114
114
  command: pages deploy dist --project-name=REPLACE_WITH_PROJECT_NAME --branch=${{ github.ref_name }}
115
115
 
116
116
  # `canon pr preview` reads this line out of the run log, so the marker is
@@ -20,7 +20,7 @@ Three values in the seeded file have no generic default and are set once, right
20
20
 
21
21
  - The `paths:` filter under the `push` trigger. The shipped default is a single `src/**` glob. Widen it to cover every path the build reads when the build draws on catalogs or generated content living outside `src/`.
22
22
  - The `concurrency.group` name. The shipped default is `deploy-${{ github.ref_name }}`, which is enough for a single-deploy project. Give the `deploy` prefix a project-specific name only if the project runs more than one deploy workflow, and keep the ref suffix.
23
- - The Pages project name, written twice as `REPLACE_WITH_PROJECT_NAME`: once in the `--project-name` flag on the `wrangler pages deploy` command and once as `PROJECT_NAME` in the cleanup job. Set both to the name the `deploy-cloudflare` skill creates the Pages project under.
23
+ - The Pages project name, written twice as `REPLACE_WITH_PROJECT_NAME`: once in the `--project-name` flag on the `wrangler pages deploy` command and once as `PROJECT_NAME` in the cleanup job. Set both to the name the `deploy-app` skill creates the Pages project under.
24
24
 
25
25
  ## Re-sync
26
26
 
@@ -1,37 +0,0 @@
1
- ---
2
- name: deploy-cloudflare
3
- description: What the Cloudflare Pages setup gap is, which of the four manual steps this skill closes, and why the credential and the custom domain stay the operator's
4
- ---
5
-
6
- # Deploy Cloudflare requirement
7
-
8
- ## Gap
9
-
10
- Without this skill, a Cloudflare Pages setup is a sequence performed from memory. Three of its four repeated acts touch a service rather than the tree, so no workflow file can absorb them, and one of the acts has already cost a broken deploy: the account ID displayed in the Cloudflare dashboard is truncated, and a value copied from there is wrong in a way that only shows up at deploy time.
11
-
12
- ## Must
13
-
14
- - Create the Pages project through `wrangler pages project create`
15
- - Fetch the account ID from `wrangler`'s own authenticated session rather than have it typed or pasted
16
- - Stop for the operator to issue the API token and run `gh secret set CLOUDFLARE_API_TOKEN` themselves
17
- - Verify both secrets are present with `gh secret list` before continuing
18
- - Stop for the operator to attach the custom domain in the dashboard
19
- - Call `wrangler` and `gh` directly for every step those tools already cover
20
-
21
- ## Must not
22
-
23
- - Accept a Cloudflare API token as input in any form. The toolkit verifies a secret exists, never what it contains.
24
- - Reimplement `wrangler pages project create` or `gh secret set` as a hand-built API call
25
- - Open a pull request or merge
26
-
27
- ## Guards
28
-
29
- - `wrangler` not authenticated: stop and name the login command
30
- - `gh` not authenticated: stop and name the login command
31
- - A secret missing after the token stop: stop and name which one
32
-
33
- ## Out of scope
34
-
35
- - Attaching a custom domain through the Cloudflare REST API instead of the dashboard. Measured absent from `wrangler pages` at plan time and the REST API was not read, so the dashboard stop stays for this pass.
36
- - Running the deploy itself once secrets and the domain are set. That is the seeded `tooling/cloudflare/configs/.github/workflows/deploy.yml` workflow, triggered by a push to main.
37
- - Reconfiguring an existing Pages project's build settings.