@erclx/canon 5.17.0 → 5.19.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 (51) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  4. package/claude/skills/deploy-app/SKILL.md +45 -0
  5. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  6. package/claude/skills/deploy-app/references/vercel.md +72 -0
  7. package/docs/agents/architecture-record.md +12 -4
  8. package/docs/agents/canvas.md +13 -1
  9. package/docs/agents/commands.md +3 -2
  10. package/docs/agents/context-audit-checks.md +1 -1
  11. package/docs/agents/context-audit.md +4 -4
  12. package/docs/agents/index.md +2 -2
  13. package/docs/agents/teach.md +7 -1
  14. package/docs/workflow/skill-map.md +9 -9
  15. package/package.json +2 -1
  16. package/src/audits/catalog.ts +46 -1
  17. package/src/canvas/address.ts +73 -39
  18. package/src/canvas/client/inspector.tsx +241 -33
  19. package/src/canvas/client/pages-panel.tsx +51 -0
  20. package/src/canvas/client/shell.css +126 -1
  21. package/src/canvas/client/state.ts +120 -4
  22. package/src/canvas/client/theme-panel.tsx +55 -0
  23. package/src/canvas/content.ts +14 -3
  24. package/src/canvas/edit.ts +360 -0
  25. package/src/canvas/server.ts +50 -5
  26. package/src/canvas/tokens.ts +82 -0
  27. package/src/claude/cases/misc.ts +5 -1
  28. package/src/commands/canvas.ts +95 -1
  29. package/src/commands/context.ts +85 -5
  30. package/src/commands/slides.ts +73 -4
  31. package/src/commands/teach.ts +112 -4
  32. package/src/context/architecture.ts +98 -18
  33. package/src/context/gate.ts +30 -3
  34. package/src/context/requirements.ts +77 -0
  35. package/src/gate/measures.ts +69 -28
  36. package/src/gate/stages.ts +5 -5
  37. package/src/serve/live.ts +113 -0
  38. package/src/serve/static.ts +60 -2
  39. package/src/slides/convert/export.ts +323 -0
  40. package/src/slides/convert/shapes.ts +764 -0
  41. package/src/slides/convert/svg.ts +38 -0
  42. package/src/slides/convert/walk.ts +444 -0
  43. package/src/teach/browser/reload.ts +9 -0
  44. package/src/teach/nav.ts +7 -6
  45. package/src/teach/workspace.ts +2 -1
  46. package/src/teach/write-if-changed.ts +17 -0
  47. package/standards/architecture.md +13 -7
  48. package/standards/requirements.md +4 -0
  49. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  50. package/tooling/cloudflare/reference.md +1 -1
  51. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -1,12 +1,16 @@
1
- import { isAbsolute, relative, resolve, sep } from 'node:path'
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
2
3
  import type { Command } from 'commander'
4
+ import { LiveStream } from '@/serve/live'
3
5
  import {
4
6
  logServing,
5
7
  parsePort,
6
8
  serveFields,
7
9
  waitForInterrupt,
8
10
  } from '@/serve/report'
9
- import { DEFAULT_PORT, startServer } from '@/serve/static'
11
+ import { DEFAULT_PORT, LIVE_EVENTS_PATH, startServer } from '@/serve/static'
12
+ import { compileScript } from '@/teach/browser/compile'
13
+ import { reload } from '@/teach/browser/reload'
10
14
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
11
15
  import { type NavGenerated, type NavOutcome, generateNav } from '@/teach/nav'
12
16
  import type { RenderOutcome } from '@/teach/render'
@@ -942,8 +946,17 @@ async function runUp(
942
946
  ? 'index.html'
943
947
  : relative(dir, resolve(root, contents)).split(sep).join('/')
944
948
 
945
- const served = startServer(dir, { port, entry })
949
+ const stream = refreshOnChange(root, dir, topic, emitJson)
950
+ const served = startServer(dir, {
951
+ port,
952
+ entry,
953
+ live: {
954
+ stream,
955
+ script: compileScript(reload, [LIVE_EVENTS_PATH]),
956
+ },
957
+ })
946
958
  if (!served.ok) {
959
+ stream.close()
947
960
  // A serve refusal carries a reason the teach refusals do not list, so it
948
961
  // is written here in the teach record's shape rather than through one.
949
962
  if (emitJson) {
@@ -970,10 +983,105 @@ async function runUp(
970
983
  logServing(served)
971
984
  }
972
985
 
973
- await waitForInterrupt(served.stop)
986
+ await waitForInterrupt(async () => {
987
+ stream.close()
988
+ await served.stop()
989
+ })
974
990
  return 0
975
991
  }
976
992
 
993
+ /**
994
+ * An editor's swap or backup file, or anything under a dot folder. It changes
995
+ * nothing a page shows, so it neither starts a pass nor counts as served.
996
+ */
997
+ function isScratch(path: string): boolean {
998
+ return path
999
+ .split(/[\\/]/)
1000
+ .some((segment) => segment.startsWith('.') || segment.endsWith('~'))
1001
+ }
1002
+
1003
+ /**
1004
+ * What the served folder holds, as each file's path, size, and mtime. A pass
1005
+ * whose `nav` wrote nothing leaves it unchanged, which is how the echo of a
1006
+ * pass's own writes settles without a second reload.
1007
+ */
1008
+ function servedState(dir: string): string {
1009
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
1010
+ .filter((entry) => entry.isFile())
1011
+ .map((entry) => relative(dir, join(entry.parentPath, entry.name)))
1012
+ .filter((path) => !isScratch(path))
1013
+ .sort()
1014
+ .map((path) => {
1015
+ const stat = statSync(join(dir, path), { throwIfNoEntry: false })
1016
+ return `${path}:${stat?.size}:${stat?.mtimeMs}`
1017
+ })
1018
+ .join('\n')
1019
+ }
1020
+
1021
+ /**
1022
+ * Re-runs `nav` on each settled change under the teach folder and tells every
1023
+ * open page to reload once a pass changed what is served. Passes run one at a
1024
+ * time, since `nav` is not reentrant over the files it rewrites, and a change
1025
+ * arriving mid-pass is coalesced into exactly one more. A refused or failed
1026
+ * pass sends no reload, so a page never reloads onto a half-written state.
1027
+ */
1028
+ function refreshOnChange(
1029
+ root: string,
1030
+ dir: string,
1031
+ topic: string | undefined,
1032
+ emitJson: boolean,
1033
+ ): LiveStream<string> {
1034
+ let isRunning = false
1035
+ let isDirty = false
1036
+ let state = servedState(dir)
1037
+
1038
+ const report = (message: string, isWarning: boolean) => {
1039
+ if (emitJson) process.stderr.write(`${message}\n`)
1040
+ else if (isWarning) logWarn(message)
1041
+ else logInfo(message)
1042
+ }
1043
+
1044
+ const pass = async (): Promise<void> => {
1045
+ const nav = await generateNav(root, topic)
1046
+ if (!nav.ok) {
1047
+ report(`Refresh refused: ${nav.message}`, true)
1048
+ return
1049
+ }
1050
+ const next = servedState(dir)
1051
+ if (next === state) return
1052
+ state = next
1053
+ stream.send({ reload: true })
1054
+ report(`Refreshed ${nav.lessons} lessons, reloading open pages`, false)
1055
+ }
1056
+
1057
+ const schedule = async (): Promise<void> => {
1058
+ if (isRunning) {
1059
+ isDirty = true
1060
+ return
1061
+ }
1062
+ isRunning = true
1063
+ do {
1064
+ isDirty = false
1065
+ // A throw, such as the teach folder removed mid-pass, is reported and
1066
+ // the loop carries on, since a rejection left here would end the
1067
+ // process and a stuck flag would end every later refresh.
1068
+ try {
1069
+ await pass()
1070
+ } catch (error) {
1071
+ const code = (error as NodeJS.ErrnoException).code ?? String(error)
1072
+ report(`Refresh failed: ${code}`, true)
1073
+ }
1074
+ } while (isDirty)
1075
+ isRunning = false
1076
+ }
1077
+
1078
+ const stream = new LiveStream<string>(dir, {
1079
+ map: (path) => (isScratch(path) ? undefined : path),
1080
+ onChange: () => void schedule(),
1081
+ })
1082
+ return stream
1083
+ }
1084
+
977
1085
  function reportRefusal(
978
1086
  title: string,
979
1087
  refused: TeachRefused,
@@ -1,7 +1,7 @@
1
1
  import { access, readFile } from 'node:fs/promises'
2
2
  import { join, relative } from 'node:path'
3
3
  import { AUDITS } from '@/audits/catalog'
4
- import { bodyLines } from '@/markdown/scan'
4
+ import { type BodyLine, bodyLines } from '@/markdown/scan'
5
5
  import { surfaceDir } from '@/surface-root'
6
6
 
7
7
  /**
@@ -58,9 +58,9 @@ export interface DecisionReport {
58
58
  */
59
59
  readonly checks: readonly string[]
60
60
  /**
61
- * The entry's own word count, read alongside the weight judgment the
62
- * standard asks a session to make by reading the file rather than counting
63
- * it. Never gates, per `standards/architecture.md`'s `## Length` section.
61
+ * The entry's prose word count, its heading and fenced lines left out. Gates
62
+ * only against a per-decision cap the record states, so the report and the
63
+ * stage read one number.
64
64
  */
65
65
  readonly words: number
66
66
  /**
@@ -82,6 +82,12 @@ export interface ArchitectureReport {
82
82
  * only what is still open, so its weight is read the same way the file's is.
83
83
  */
84
84
  readonly risksWords?: number
85
+ /**
86
+ * List items in the Risks section, nested ones included, since a nested
87
+ * bullet is weight the reader pays for. Absent alongside `risksWords`, so a
88
+ * record with no section never reads as one holding zero bullets.
89
+ */
90
+ readonly risksBullets?: number
85
91
  /** What the record declared, absent when it states no length rule. */
86
92
  readonly allowances?: Allowances
87
93
  /** The frame plus the per-decision allowance, absent alongside it. */
@@ -92,6 +98,10 @@ export interface ArchitectureReport {
92
98
  * project that never adopted a cap is measured and never gated.
93
99
  */
94
100
  readonly entryCap?: number
101
+ /** The most words a decision may hold, absent when the record states none. */
102
+ readonly wordCap?: number
103
+ /** The most Risks bullets the record allows, absent when it states none. */
104
+ readonly riskCap?: number
95
105
  /**
96
106
  * Whether the record states that every decision closes with a revisit
97
107
  * sentence. Declared by the record for the reason the cap is, so a record
@@ -103,7 +113,9 @@ export interface ArchitectureReport {
103
113
 
104
114
  const DECISION_HEADING = /^###\s+(.+?)\s*$/
105
115
  const SECTION_HEADING = /^##\s+\S/
106
- /** The one H2 the standard's `## Length` section asks to be weighed by words. */
116
+ /** A markdown list item at any depth, which is what a risk bullet is. */
117
+ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+\S/
118
+ /** The H2 whose bullets the standard's `## Caps` section counts. */
107
119
  const RISKS_HEADING = /^##\s+Risks\s*\/\s*open questions\s*$/i
108
120
  const CODE_SPAN = /`[^`]*`/g
109
121
  /** Dropped ahead of the figure scan, since an anchor date is not a claim. */
@@ -291,13 +303,12 @@ export function splitDecisions(source: string): RawDecision[] {
291
303
  }
292
304
 
293
305
  /**
294
- * Counts whitespace-delimited tokens, which is the unit the standard's
295
- * `## Length` section reads alongside the weight judgment a session makes by
296
- * reading the file. It is a report figure rather than a gate, so a fenced
297
- * example or a code span inflating the count costs nothing a reader corrects
298
- * for by reading, the same trade the line count above it already takes.
306
+ * Counts whitespace-delimited tokens, the unit a record's word caps are stated
307
+ * in. A code span counts as the tokens it holds, so a decision citing a long
308
+ * path pays for it the way a reader does. A caller gating on the count hands
309
+ * in prose with fenced lines already dropped.
299
310
  */
300
- function wordCount(text: string): number {
311
+ export function wordCount(text: string): number {
301
312
  return text.match(/\S+/g)?.length ?? 0
302
313
  }
303
314
 
@@ -310,14 +321,30 @@ function wordCount(text: string): number {
310
321
  * when the section is last, which is where the standard's template puts it.
311
322
  */
312
323
  export function risksSection(source: string): string | undefined {
313
- const lines = bodyLines(source)
314
- const body: string[] = []
324
+ return risksLines(source)
325
+ ?.map((line) => line.text)
326
+ .join('\n')
327
+ }
328
+
329
+ /**
330
+ * Counts the list items in the Risks section, nested ones included, or
331
+ * nothing when the record carries no such heading. A fenced line is skipped,
332
+ * since a template bullet is not a risk.
333
+ */
334
+ export function riskBullets(source: string): number | undefined {
335
+ return risksLines(source)?.filter(
336
+ (line) => !line.fenced && LIST_ITEM.test(line.text),
337
+ ).length
338
+ }
339
+
340
+ function risksLines(source: string): BodyLine[] | undefined {
341
+ const body: BodyLine[] = []
315
342
  let capturing = false
316
343
  let found = false
317
344
 
318
- for (const line of lines) {
345
+ for (const line of bodyLines(source)) {
319
346
  if (line.fenced) {
320
- if (capturing) body.push(line.text)
347
+ if (capturing) body.push(line)
321
348
  continue
322
349
  }
323
350
 
@@ -332,10 +359,10 @@ export function risksSection(source: string): string | undefined {
332
359
  continue
333
360
  }
334
361
 
335
- if (capturing) body.push(line.text)
362
+ if (capturing) body.push(line)
336
363
  }
337
364
 
338
- return found ? body.join('\n') : undefined
365
+ return found ? body : undefined
339
366
  }
340
367
 
341
368
  /** Cardinals a record spells rather than writes, which the corpus does for both. */
@@ -376,6 +403,14 @@ const ENTRY_CAP_CLAUSE = new RegExp(
376
403
  String.raw`\bat\s+most\s+${CARDINAL}\s+decisions?\b`,
377
404
  'i',
378
405
  )
406
+ const WORD_CAP_CLAUSE = new RegExp(
407
+ String.raw`\bat\s+most\s+${CARDINAL}\s+words\s+a\s+decision\b`,
408
+ 'i',
409
+ )
410
+ const RISK_CAP_CLAUSE = new RegExp(
411
+ String.raw`\bat\s+most\s+${CARDINAL}\s+risk\s+bullets?\b`,
412
+ 'i',
413
+ )
379
414
 
380
415
  function readCardinal(token: string | undefined): number | undefined {
381
416
  if (token === undefined) return undefined
@@ -413,6 +448,20 @@ export function readEntryCap(source: string): number | undefined {
413
448
  return readCardinal(source.match(ENTRY_CAP_CLAUSE)?.[1])
414
449
  }
415
450
 
451
+ /**
452
+ * Reads the per-decision word cap a record declares, spelled `at most <n>
453
+ * words a decision`, or nothing. One spelling, the way the line allowance
454
+ * reads `<n> lines a decision`, so a drift falls back to reporting.
455
+ */
456
+ export function readWordCap(source: string): number | undefined {
457
+ return readCardinal(source.match(WORD_CAP_CLAUSE)?.[1])
458
+ }
459
+
460
+ /** Reads the Risks cap a record declares, spelled `at most <n> risk bullets`. */
461
+ export function readRiskCap(source: string): number | undefined {
462
+ return readCardinal(source.match(RISK_CAP_CLAUSE)?.[1])
463
+ }
464
+
416
465
  const REVISIT_CLAUSE =
417
466
  /\bevery\s+decision\s+closes\s+with\s+a\s+revisit\s+sentence\b/i
418
467
  /**
@@ -486,6 +535,8 @@ export async function measureArchitecture(
486
535
 
487
536
  const allowances = readAllowances(source)
488
537
  const entryCap = readEntryCap(source)
538
+ const wordCap = readWordCap(source)
539
+ const riskCap = readRiskCap(source)
489
540
  const revisitRequired = readRevisitRule(source)
490
541
  const raw = splitDecisions(source)
491
542
  const decisions = await Promise.all(
@@ -498,13 +549,14 @@ export async function measureArchitecture(
498
549
  figures,
499
550
  ...(quantified !== undefined && { quantified }),
500
551
  checks: await namedChecks(root, entry.body),
501
- words: wordCount(entry.body),
552
+ words: wordCount(entry.prose),
502
553
  revisit: hasRevisitSentence(entry.prose),
503
554
  }
504
555
  }),
505
556
  )
506
557
 
507
558
  const risks = risksSection(source)
559
+ const bullets = riskBullets(source)
508
560
 
509
561
  return {
510
562
  rel,
@@ -515,11 +567,14 @@ export async function measureArchitecture(
515
567
  .join('\n'),
516
568
  ),
517
569
  ...(risks !== undefined && { risksWords: wordCount(risks) }),
570
+ ...(bullets !== undefined && { risksBullets: bullets }),
518
571
  ...(allowances !== undefined && {
519
572
  allowances,
520
573
  ceiling: ceilingFor(allowances, raw.length),
521
574
  }),
522
575
  ...(entryCap !== undefined && { entryCap }),
576
+ ...(wordCap !== undefined && { wordCap }),
577
+ ...(riskCap !== undefined && { riskCap }),
523
578
  revisitRequired,
524
579
  decisions,
525
580
  }
@@ -538,6 +593,31 @@ export function isOverCount(report: ArchitectureReport): boolean {
538
593
  )
539
594
  }
540
595
 
596
+ /**
597
+ * The decisions holding more prose words than the cap the record states.
598
+ *
599
+ * Empty for a record stating no word cap, for the reason `isOverCount` gives.
600
+ */
601
+ export function overWords(report: ArchitectureReport): DecisionReport[] {
602
+ const cap = report.wordCap
603
+ if (cap === undefined) return []
604
+ return report.decisions.filter((entry) => entry.words > cap)
605
+ }
606
+
607
+ /**
608
+ * Whether the Risks section holds more bullets than the cap the record states.
609
+ *
610
+ * False for a record stating no cap, and false for one carrying no Risks
611
+ * section, which the stage reports as absent rather than as zero bullets.
612
+ */
613
+ export function isOverRisks(report: ArchitectureReport): boolean {
614
+ return (
615
+ report.riskCap !== undefined &&
616
+ report.risksBullets !== undefined &&
617
+ report.risksBullets > report.riskCap
618
+ )
619
+ }
620
+
541
621
  /**
542
622
  * The headings of decisions lacking a revisit sentence the record requires.
543
623
  *
@@ -25,6 +25,19 @@ export interface GateInput {
25
25
  * same two cases, plus a record stating no such clause.
26
26
  */
27
27
  readonly recordMissingRevisit: boolean
28
+ /**
29
+ * Whether a decision holds more words than the cap the record states, which
30
+ * gates under the same two modes and is false in the same two cases, plus a
31
+ * record stating no word cap.
32
+ */
33
+ readonly recordOverWords: boolean
34
+ /** The same reading for the Risks section's bullet cap. */
35
+ readonly recordOverRisks: boolean
36
+ /**
37
+ * Whether the requirements record holds more words than the cap it states,
38
+ * which gates for the reason the architecture record's caps do.
39
+ */
40
+ readonly requirementsOverWords: boolean
28
41
  readonly sections: readonly SectionFinding[]
29
42
  readonly drift: readonly FolderDrift[]
30
43
  /**
@@ -79,8 +92,10 @@ export function hasSketchWithEvidence(
79
92
  * Whether the audit found something that should fail the caller.
80
93
  *
81
94
  * An unresolved citation is a broken pointer and gates unconditionally, and so
82
- * does a record past its own ceiling or its own entry cap, or missing a
83
- * revisit sentence it requires: the record states each rule for itself, which makes those the measures here that are facts
95
+ * does a record past its own ceiling, entry cap, word cap, or bullet cap, or
96
+ * missing a revisit sentence it requires, and a requirements record past its
97
+ * own word cap: each record states its rules for itself, which makes those the
98
+ * measures here that are facts
84
99
  * rather than thresholds a reader weighs. The findings `--gate` adds are
85
100
  * the ones answerable from the file itself: a required section it does not
86
101
  * declare, an index disagreeing with its folder, and a wireframe's States
@@ -95,13 +110,25 @@ export function isGating({
95
110
  recordOverLength,
96
111
  recordOverCount,
97
112
  recordMissingRevisit,
113
+ recordOverWords,
114
+ recordOverRisks,
115
+ requirementsOverWords,
98
116
  sections,
99
117
  drift,
100
118
  wireframes,
101
119
  widened,
102
120
  }: GateInput): boolean {
103
121
  if (unresolvedCitations > 0) return true
104
- if (recordOverLength || recordOverCount || recordMissingRevisit) return true
122
+ if (
123
+ recordOverLength ||
124
+ recordOverCount ||
125
+ recordMissingRevisit ||
126
+ recordOverWords ||
127
+ recordOverRisks ||
128
+ requirementsOverWords
129
+ ) {
130
+ return true
131
+ }
105
132
  if (!widened) return false
106
133
 
107
134
  return sections.length > 0 || hasDrift(drift) || hasStatesMismatch(wireframes)
@@ -0,0 +1,77 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join, relative } from 'node:path'
3
+ import { wordCount } from '@/context/architecture'
4
+ import { bodyLines } from '@/markdown/scan'
5
+ import { surfaceDir } from '@/surface-root'
6
+
7
+ /**
8
+ * The record this measures, relative to `root`, resolved at either surface
9
+ * root for the reason `architectureRel` gives.
10
+ */
11
+ export function requirementsRel(root: string): string {
12
+ return relative(root, surfaceDir(root, 'REQUIREMENTS.md'))
13
+ }
14
+
15
+ export interface RequirementsReport {
16
+ readonly rel: string
17
+ /** The whole record's word count, frontmatter left out. */
18
+ readonly words: number
19
+ /**
20
+ * The most words the record says it holds, absent when it states no cap.
21
+ * Declared by the record rather than held here, so a project that never
22
+ * adopted a cap is measured and never gated.
23
+ */
24
+ readonly wordCap?: number
25
+ }
26
+
27
+ const WORD_CAP_CLAUSE = /\bat\s+most\s+(\d+)\s+words\b/i
28
+
29
+ /**
30
+ * Reads the word cap a record declares, spelled `at most <n> words`, or
31
+ * nothing. Digits only, since a cap in the hundreds is never spelled out.
32
+ */
33
+ export function readRequirementsWordCap(source: string): number | undefined {
34
+ const digits = source.match(WORD_CAP_CLAUSE)?.[1]
35
+ return digits === undefined ? undefined : Number.parseInt(digits, 10)
36
+ }
37
+
38
+ function isMissing(error: unknown): boolean {
39
+ const code = (error as { code?: unknown }).code
40
+ return code === 'ENOENT' || code === 'ENOTDIR'
41
+ }
42
+
43
+ /**
44
+ * Measures the record, or reports nothing when the project carries none.
45
+ *
46
+ * Absent rather than zeroed, for the reason `measureArchitecture` gives, and a
47
+ * record present and unreadable propagates the same way.
48
+ */
49
+ export async function measureRequirements(
50
+ root: string,
51
+ ): Promise<RequirementsReport | undefined> {
52
+ const rel = requirementsRel(root)
53
+
54
+ let source: string
55
+ try {
56
+ source = await readFile(join(root, rel), 'utf8')
57
+ } catch (error) {
58
+ if (!isMissing(error)) throw error
59
+ return undefined
60
+ }
61
+
62
+ const wordCap = readRequirementsWordCap(source)
63
+ return {
64
+ rel,
65
+ words: wordCount(
66
+ bodyLines(source)
67
+ .map((line) => line.text)
68
+ .join('\n'),
69
+ ),
70
+ ...(wordCap !== undefined && { wordCap }),
71
+ }
72
+ }
73
+
74
+ /** Whether the record holds more words than the cap it states. */
75
+ export function isOverWordCap(report: RequirementsReport): boolean {
76
+ return report.wordCap !== undefined && report.words > report.wordCap
77
+ }
@@ -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