@erclx/canon 5.17.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 (37) 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/docs/agents/architecture-record.md +12 -4
  7. package/docs/agents/canvas.md +13 -1
  8. package/docs/agents/commands.md +2 -2
  9. package/docs/agents/context-audit-checks.md +1 -1
  10. package/docs/agents/context-audit.md +4 -4
  11. package/docs/agents/index.md +2 -2
  12. package/docs/workflow/skill-map.md +9 -9
  13. package/package.json +1 -1
  14. package/src/audits/catalog.ts +46 -1
  15. package/src/canvas/address.ts +73 -39
  16. package/src/canvas/client/inspector.tsx +241 -33
  17. package/src/canvas/client/pages-panel.tsx +51 -0
  18. package/src/canvas/client/shell.css +126 -1
  19. package/src/canvas/client/state.ts +120 -4
  20. package/src/canvas/client/theme-panel.tsx +55 -0
  21. package/src/canvas/content.ts +14 -3
  22. package/src/canvas/edit.ts +360 -0
  23. package/src/canvas/server.ts +50 -5
  24. package/src/canvas/tokens.ts +82 -0
  25. package/src/claude/cases/misc.ts +5 -1
  26. package/src/commands/canvas.ts +95 -1
  27. package/src/commands/context.ts +85 -5
  28. package/src/context/architecture.ts +98 -18
  29. package/src/context/gate.ts +30 -3
  30. package/src/context/requirements.ts +77 -0
  31. package/src/gate/measures.ts +69 -28
  32. package/src/gate/stages.ts +5 -5
  33. package/standards/architecture.md +13 -7
  34. package/standards/requirements.md +4 -0
  35. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  36. package/tooling/cloudflare/reference.md +1 -1
  37. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -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
@@ -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
@@ -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.
@@ -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.