@erclx/canon 5.49.0 → 5.50.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 (46) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
  3. package/claude/skills/canvas-board/SKILL.md +8 -9
  4. package/claude/skills/context-fold/references/context.md +4 -3
  5. package/claude/skills/design-record/REQUIREMENT.md +33 -0
  6. package/claude/skills/design-record/SKILL.md +84 -0
  7. package/claude/skills/design-record/references/folder.md +69 -0
  8. package/claude/skills/design-taste/SKILL.md +1 -1
  9. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
  10. package/claude/skills/markdown-craft/SKILL.md +6 -1
  11. package/claude/skills/markdown-craft/references/markdown.md +3 -3
  12. package/claude/skills/markdown-craft/references/tables.md +30 -0
  13. package/claude/skills/pick-by-eye/SKILL.md +10 -8
  14. package/claude/skills/plan-feature/SKILL.md +13 -1
  15. package/claude/skills/session-worktree/SKILL.md +1 -1
  16. package/docs/agents/index.md +1 -1
  17. package/docs/agents/markdown-audit.md +5 -2
  18. package/docs/target/migrations.md +7 -0
  19. package/docs/target/projects.md +1 -1
  20. package/docs/workflow/skill-map.md +2 -1
  21. package/docs/workflow/visual-design-workflow.md +2 -1
  22. package/governance/ledger/ui/design-reference.md +1 -0
  23. package/governance/rules/ui/design-reference.md +1 -0
  24. package/package.json +1 -1
  25. package/src/claude/cases/misc.ts +5 -0
  26. package/src/commands/claude.ts +26 -1
  27. package/src/commands/gate.ts +57 -16
  28. package/src/commands/markdown.ts +98 -19
  29. package/src/context/audit.ts +6 -5
  30. package/src/design/budget.ts +1 -1
  31. package/src/design/components.ts +1 -1
  32. package/src/design/document.ts +1 -1
  33. package/src/design/regen.ts +1 -1
  34. package/src/design/render.ts +1 -1
  35. package/src/gate/hand-suite.ts +52 -0
  36. package/src/gate/measures.ts +9 -2
  37. package/src/gate/sequencer.ts +17 -8
  38. package/src/markdown/gated.ts +5 -7
  39. package/src/markdown/scope.ts +60 -10
  40. package/src/markdown/structure.ts +1 -1
  41. package/src/markdown/tables.ts +150 -0
  42. package/src/tasks/validate.ts +15 -12
  43. package/tooling/claude/reference.md +1 -0
  44. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
  45. package/tooling/claude/seeds/CLAUDE.md +9 -0
  46. /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
@@ -1,4 +1,9 @@
1
- import type { DensityKind } from '@/markdown/density'
1
+ import {
2
+ type DensityFinding,
3
+ type DensityKind,
4
+ densityFindings,
5
+ } from '@/markdown/density'
6
+ import type { BodyLine } from '@/markdown/scan'
2
7
 
3
8
  /** The surfaces the retired bullet-stacks stage held, per `ALWAYS_GATED`. */
4
9
  const BULLET_RUN_SCOPE = [
@@ -21,11 +26,12 @@ const REQUIREMENT_SCOPE =
21
26
  * pages, the skill bodies, references, and requirements of both skill trees,
22
27
  * the tooling seeds and stack references, the wiki, and the two root guides.
23
28
  *
24
- * Rules stay out, since a rule is one imperative per bullet. So do
25
- * `CLAUDE.md`, the project requirement and design records, a seed carrying one
26
- * of those three names, the changelog, the examples, and every `.canon/`
27
- * record, each with a shape and cap of its own. Named here and nowhere else, so
28
- * the audit, the gate stage reading its exit, and their tests read one scope.
29
+ * Rules stay out, since a rule is one imperative per bullet, and so does every
30
+ * `CLAUDE.md` section but Key paths, which `KEY_PATHS_SCOPE` holds. So do the
31
+ * project requirement and design records, a seed carrying one of those three
32
+ * names, the changelog, the examples, and every `.canon/` record, each with a
33
+ * shape and cap of its own. Named here and nowhere else, so the audit, the gate
34
+ * stage reading its exit, and their tests read one scope.
29
35
  */
30
36
  const DENSITY_SCOPE = [
31
37
  ...BULLET_RUN_SCOPE,
@@ -36,10 +42,49 @@ const DENSITY_SCOPE = [
36
42
  /^(?:README|CONTRIBUTING)\.md$/,
37
43
  ]
38
44
 
45
+ /**
46
+ * The root `CLAUDE.md` and its seed, measured inside `## Key paths` alone. That
47
+ * section is a catalog rather than rules, so it takes the run limit and the H3
48
+ * opening a grouped catalog needs, and nothing else a rule would breach.
49
+ */
50
+ const KEY_PATHS_SCOPE = /^(?:tooling\/claude\/seeds\/)?CLAUDE\.md$/
51
+ const KEY_PATHS_HEADING = /^## Key paths\s*$/
52
+ const KEY_PATHS_KINDS: readonly DensityKind[] = ['bullet-run', 'h3-opening']
53
+
54
+ /** The lines under `## Key paths` up to the next H1 or H2, keeping their numbers. */
55
+ function keyPathsSection(lines: readonly BodyLine[]): readonly BodyLine[] {
56
+ const start = lines.findIndex(
57
+ (line) => !line.fenced && KEY_PATHS_HEADING.test(line.text),
58
+ )
59
+ if (start === -1) return []
60
+ const rest = lines.slice(start + 1)
61
+ const end = rest.findIndex(
62
+ (line) => !line.fenced && /^#{1,2}\s/.test(line.text),
63
+ )
64
+ return end === -1 ? rest : rest.slice(0, end)
65
+ }
66
+
67
+ /**
68
+ * The density findings a path takes, being every finding on a density-scope
69
+ * file and the Key paths findings on a `CLAUDE.md`. The audit, the init probe,
70
+ * and `canon claude sync` all read this, so the three agree on what counts.
71
+ */
72
+ export function scopedDensityFindings(
73
+ rel: string,
74
+ lines: readonly BodyLine[],
75
+ ): DensityFinding[] {
76
+ if (inDensityScope(rel)) return densityFindings(lines)
77
+ if (!KEY_PATHS_SCOPE.test(rel)) return []
78
+ return densityFindings(keyPathsSection(lines)).filter((finding) =>
79
+ KEY_PATHS_KINDS.includes(finding.kind),
80
+ )
81
+ }
82
+
39
83
  /**
40
84
  * The roots `canon init` gates in a new project when each passes at init time:
41
- * every density surface a target authors itself, leaving out the toolkit's own
42
- * `claude/skills/` and `tooling/` trees.
85
+ * every density surface a target authors itself and the Key paths of its
86
+ * `CLAUDE.md`, leaving out the toolkit's own `claude/skills/` and `tooling/`
87
+ * trees.
43
88
  */
44
89
  export const GATED_SCOPE_ROOTS: readonly string[] = [
45
90
  'canon/context/',
@@ -48,6 +93,7 @@ export const GATED_SCOPE_ROOTS: readonly string[] = [
48
93
  'wiki/',
49
94
  'README.md',
50
95
  'CONTRIBUTING.md',
96
+ 'CLAUDE.md',
51
97
  ]
52
98
 
53
99
  /**
@@ -93,7 +139,6 @@ export const GATED_PATHS: readonly string[] = [
93
139
  'claude/skills/canvas-board/REQUIREMENT.md',
94
140
  'claude/skills/canvas-board/SKILL.md',
95
141
  'claude/skills/canvas-board/references/canvas.md',
96
- 'claude/skills/canvas-board/references/design.md',
97
142
  'claude/skills/ci-workflow/REQUIREMENT.md',
98
143
  'claude/skills/ci-workflow/SKILL.md',
99
144
  'claude/skills/ci-workflow/references/workflows.md',
@@ -133,6 +178,10 @@ export const GATED_PATHS: readonly string[] = [
133
178
  'claude/skills/deprecation-migration/REQUIREMENT.md',
134
179
  'claude/skills/deprecation-migration/SKILL.md',
135
180
  'claude/skills/deprecation-migration/references/adopted.md',
181
+ 'claude/skills/design-record/REQUIREMENT.md',
182
+ 'claude/skills/design-record/SKILL.md',
183
+ 'claude/skills/design-record/references/design.md',
184
+ 'claude/skills/design-record/references/folder.md',
136
185
  'claude/skills/design-taste/REQUIREMENT.md',
137
186
  'claude/skills/design-taste/SKILL.md',
138
187
  'claude/skills/design-taste/references/adopted.md',
@@ -394,6 +443,7 @@ export const GATED_PATHS: readonly string[] = [
394
443
  'wiki/',
395
444
  'README.md',
396
445
  'CONTRIBUTING.md',
446
+ 'CLAUDE.md',
397
447
  ]
398
448
 
399
449
  /**
@@ -425,7 +475,7 @@ export function densityGates(
425
475
  gated: readonly string[] = GATED_PATHS,
426
476
  alwaysGated = true,
427
477
  ): boolean {
428
- if (!inDensityScope(rel)) return false
478
+ if (!inDensityScope(rel) && !KEY_PATHS_SCOPE.test(rel)) return false
429
479
  if (REQUIREMENT_SCOPE.test(rel)) return gated.includes(rel)
430
480
  if (
431
481
  alwaysGated &&
@@ -67,7 +67,7 @@ const MARKER_WIDTH = 20
67
67
 
68
68
  export const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
69
69
  export const TABLE_ROW = /^\s*\|/
70
- const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
70
+ export const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
71
71
  export const BLOCKQUOTE = /^\s*>/
72
72
 
73
73
  /**
@@ -0,0 +1,150 @@
1
+ import { type BodyLine, visibleText } from '@/markdown/scan'
2
+ import { LIST_ITEM, TABLE_ROW, TABLE_SEPARATOR } from '@/markdown/structure'
3
+
4
+ /** The most visible characters one table cell may hold. */
5
+ export const TABLE_CELL = 120
6
+
7
+ /** The most body rows a table may carry before it splits into groups. */
8
+ export const TABLE_ROWS = 8
9
+
10
+ /** The longest unbroken run a code span in a table cell may carry. */
11
+ export const TABLE_TOKEN = 30
12
+
13
+ export type TableKind =
14
+ | 'table-cell'
15
+ | 'table-rows'
16
+ | 'table-then-list'
17
+ | 'table-token'
18
+
19
+ export interface TableFinding {
20
+ readonly kind: TableKind
21
+ /** The row holding the cell or span, the header for a row count, or the list's first item. */
22
+ readonly line: number
23
+ /** What was measured, or null for a trailing list, which is a shape rather than a count. */
24
+ readonly value: number | null
25
+ readonly limit: number | null
26
+ }
27
+
28
+ /**
29
+ * The body of every code span in a cell, read after link destinations are
30
+ * dropped, since only a link's label renders.
31
+ */
32
+ export function codeSpans(cell: string): string[] {
33
+ return (cell.replace(/\]\([^)]*\)/g, ']').match(/`[^`]+`/g) ?? []).map(
34
+ (span) => span.slice(1, -1),
35
+ )
36
+ }
37
+
38
+ /**
39
+ * The longest whitespace-free run in a code span. A preview also breaks a span
40
+ * after a hyphen or a slash, but a run this long still splits mid-token or
41
+ * widens its column, so whitespace is the break this counts.
42
+ */
43
+ export function unbrokenRun(span: string): number {
44
+ return Math.max(...span.split(/\s+/).map((part) => part.length))
45
+ }
46
+
47
+ /** Splits a row on its unescaped pipes, dropping the outer two. */
48
+ function cellsOf(row: string): string[] {
49
+ return row
50
+ .trim()
51
+ .replace(/^\|/, '')
52
+ .replace(/(?<!\\)\|$/, '')
53
+ .split(/(?<!\\)\|/)
54
+ .map((cell) => cell.trim().replaceAll('\\|', '|'))
55
+ }
56
+
57
+ /**
58
+ * Every breach of the four table limits, in line order.
59
+ *
60
+ * A table is a run of pipe rows whose second row is a delimiter, the shape a
61
+ * renderer draws. Pipe lines with no delimiter render as paragraph text, and a
62
+ * fenced line is an example, so neither is measured. The limits are a
63
+ * 120-character cell, 8 body rows, a list as the next block, and a code span
64
+ * holding a run past 30 characters. Every finding reports and none gates.
65
+ */
66
+ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
67
+ const found: TableFinding[] = []
68
+ let index = 0
69
+
70
+ while (index < lines.length) {
71
+ const header = lines[index]
72
+ const delimiter = lines[index + 1]
73
+ if (
74
+ header.fenced ||
75
+ !TABLE_ROW.test(header.text) ||
76
+ delimiter === undefined ||
77
+ delimiter.fenced ||
78
+ !TABLE_SEPARATOR.test(delimiter.text)
79
+ ) {
80
+ index++
81
+ continue
82
+ }
83
+
84
+ const tableRows = [header]
85
+ let end = index + 2
86
+ while (
87
+ end < lines.length &&
88
+ !lines[end].fenced &&
89
+ TABLE_ROW.test(lines[end].text)
90
+ ) {
91
+ tableRows.push(lines[end])
92
+ end++
93
+ }
94
+
95
+ for (const row of tableRows) {
96
+ for (const cell of cellsOf(row.text)) {
97
+ const length = visibleText(cell).length
98
+ if (length > TABLE_CELL) {
99
+ found.push({
100
+ kind: 'table-cell',
101
+ line: row.number,
102
+ value: length,
103
+ limit: TABLE_CELL,
104
+ })
105
+ }
106
+ for (const span of codeSpans(cell)) {
107
+ const run = unbrokenRun(span)
108
+ if (run > TABLE_TOKEN) {
109
+ found.push({
110
+ kind: 'table-token',
111
+ line: row.number,
112
+ value: run,
113
+ limit: TABLE_TOKEN,
114
+ })
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ const bodyRows = tableRows.length - 1
121
+ if (bodyRows > TABLE_ROWS) {
122
+ found.push({
123
+ kind: 'table-rows',
124
+ line: header.number,
125
+ value: bodyRows,
126
+ limit: TABLE_ROWS,
127
+ })
128
+ }
129
+
130
+ let next = end
131
+ while (next < lines.length && lines[next].text.trim() === '') next++
132
+ const following = lines[next]
133
+ if (
134
+ following !== undefined &&
135
+ !following.fenced &&
136
+ LIST_ITEM.test(following.text)
137
+ ) {
138
+ found.push({
139
+ kind: 'table-then-list',
140
+ line: following.number,
141
+ value: null,
142
+ limit: null,
143
+ })
144
+ }
145
+
146
+ index = end
147
+ }
148
+
149
+ return found.sort((a, b) => a.line - b.line)
150
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
+ import { codeSpans, unbrokenRun } from '@/markdown/tables'
4
5
  import {
5
6
  archiveDir,
6
7
  declinedDir,
@@ -1084,28 +1085,30 @@ function checkFolderClaims(
1084
1085
  /**
1085
1086
  * The widest the longest unbroken run in a code span may be before a table
1086
1087
  * scrolls in the preview pane. A 57-character span fit and a 78-character one
1087
- * did not, so the limit sits at the nearest round number above the first.
1088
+ * did not, so the limit sits at the nearest round number above the first. It
1089
+ * is still too high: a 36-character path with no hyphen widened a board column
1090
+ * until the next one ran off the pane, which is why a table cell reads the same
1091
+ * measure at 30. The board keeps 60 until its own report is revisited.
1088
1092
  */
1089
1093
  const WIDE_LIMIT = 60
1090
1094
 
1091
1095
  /**
1092
- * Reports a code span a table cell cannot wrap. A span breaks only at
1093
- * whitespace, so the longest whitespace-free run is what is measured, and a
1094
- * link target is stripped first because only its label renders. Every group is
1095
- * scanned, since the overflow this exists for happened under `## Up next`,
1096
- * where the folder-claim scan would not have looked. Prose breaks at spaces
1097
- * and bare words at hyphens, so only a span is read.
1096
+ * Reports a code span a table cell cannot wrap, measured by `unbrokenRun`,
1097
+ * which `canon markdown audit` reads for a table too. A rendered preview breaks
1098
+ * a span at whitespace and after a hyphen or a slash, so a long run either
1099
+ * widens its column or splits mid-token. Every group is scanned, since the
1100
+ * overflow this exists for happened under `## Up next`, where the folder-claim
1101
+ * scan would not have looked. Prose breaks at spaces and bare words at hyphens,
1102
+ * so only a span is read.
1098
1103
  */
1099
1104
  function checkWideTokens(rows: readonly BoardRow[]): WideToken[] {
1100
1105
  const wide: WideToken[] = []
1101
1106
 
1102
1107
  for (const row of rows) {
1103
1108
  for (const cell of row.cells) {
1104
- const spans = cell.replace(/\]\([^)]*\)/g, ']').match(/`[^`]+`/g) ?? []
1105
-
1106
- for (const span of spans) {
1107
- const text = span.slice(1, -1)
1108
- const run = Math.max(...text.split(/\s+/).map((part) => part.length))
1109
+ for (const text of codeSpans(cell)) {
1110
+ const span = `\`${text}\``
1111
+ const run = unbrokenRun(text)
1109
1112
 
1110
1113
  if (/\{[^}]*,[^}]*\}/.test(text)) {
1111
1114
  wide.push({
@@ -57,6 +57,7 @@ A project installed before the board became a folder still holds `.claude/TASKS.
57
57
 
58
58
  - Fill in the seed's placeholder project name, description, and key paths after init
59
59
  - Keep its sections `Context`, `Commands`, and `Key paths`, since skills detect, extend, or skip by heading
60
+ - Group `Key paths` under H3s, each opening on a lead sentence, before it runs past 5 bullets. `canon markdown audit` and `canon claude sync` both report a longer run.
60
61
  - Keep the `Context` section to one line pointing at the state docs, since `plan-feature` does the parallel read when invoked
61
62
 
62
63
  ## Seed docs
@@ -81,6 +81,11 @@ colons=$(printf '%s' "$record" |
81
81
  density=$(printf '%s' "$record" |
82
82
  jq -r '.entries[]?.density[]? | ":\(.line) \(.kind) " + (if .limit == null then "opens with neither a paragraph nor a table" else "\(.value) past \(.limit)" end) + (if .gating then " gating" else "" end)' 2>/dev/null)
83
83
 
84
+ # Table findings report and never gate, so each kind is handed back with the
85
+ # fix its shape takes rather than the seam a density finding splits at.
86
+ tables=$(printf '%s' "$record" |
87
+ jq -r '.entries[]?.tables[]? | ":\(.line) \(.kind) " + (if .limit == null then "a list follows the table with no lead sentence" else "\(.value) past \(.limit)" end)' 2>/dev/null)
88
+
84
89
  # A page marked `kind: reference` carries reference shape findings, handed back
85
90
  # whole the same way, since a gated one fails the push like a density finding.
86
91
  shape=$(printf '%s' "$record" |
@@ -92,7 +97,7 @@ shape=$(printf '%s' "$record" |
92
97
  empty=$(printf '%s' "$record" |
93
98
  jq -r '[.bans.emptySets[]?] | join(", ")' 2>/dev/null)
94
99
 
95
- [ -z "$hits" ] && [ -z "$headings" ] && [ -z "$colons" ] && [ -z "$density" ] && [ -z "$shape" ] && [ -z "$unread" ] && [ -z "$empty" ] && exit 0
100
+ [ -z "$hits" ] && [ -z "$headings" ] && [ -z "$colons" ] && [ -z "$density" ] && [ -z "$tables" ] && [ -z "$shape" ] && [ -z "$unread" ] && [ -z "$empty" ] && exit 0
96
101
 
97
102
  nl=$'\n'
98
103
  msg=""
@@ -125,6 +130,11 @@ if [ -n "$density" ]; then
125
130
  msg="${msg:+$msg$nl}$found"
126
131
  fi
127
132
 
133
+ if [ -n "$tables" ]; then
134
+ found=$(printf 'Standards-audit: %s breaks the markdown table limits, being a 120-character cell, 8 body rows, a list directly after a table, and a code span holding a run past 30 characters. These report and never fail a push. Move a long cell into a bullet under the table'"'"'s lead, split a long table into groups each under a lead sentence, give a trailing list its own lead sentence, and move a wide token out of the table.\n%s' "$file" "$tables")
135
+ msg="${msg:+$msg$nl}$found"
136
+ fi
137
+
128
138
  if [ -n "$shape" ]; then
129
139
  found=$(printf 'Standards-audit: %s is marked kind: reference and breaks its shape, being an opening, then Output, Refusals, and See also, each at most once and in that order, no H3, and no flag table. Point at the command'"'"'s own --help for flags. A finding marked gating fails the push.\n%s' "$file" "$shape")
130
140
  msg="${msg:+$msg$nl}$found"
@@ -15,5 +15,14 @@
15
15
 
16
16
  ## Key paths
17
17
 
18
+ ### Code
19
+
20
+ [One sentence on what the code paths below hold]
21
+
18
22
  - `src/`: [description]
23
+
24
+ ### Content
25
+
26
+ [One sentence on what the content paths below hold]
27
+
19
28
  - `canon/DESIGN.md`: visual intent, pointing at the stylesheet that holds the values