@erclx/canon 5.55.0 → 5.56.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 (67) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +8 -10
  4. package/claude/skills/auto-ship/SKILL.md +4 -5
  5. package/claude/skills/auto-ship/references/failure-recovery.md +37 -17
  6. package/claude/skills/auto-ship/references/tier-failures.md +9 -7
  7. package/claude/skills/auto-ship/references/worktree-name.md +8 -5
  8. package/claude/skills/backlog-triage/SKILL.md +10 -10
  9. package/claude/skills/backlog-triage/references/apply.md +6 -6
  10. package/claude/skills/context-fold/references/classify.md +17 -4
  11. package/claude/skills/create-rule/references/rule.md +4 -0
  12. package/claude/skills/create-skill/SKILL.md +2 -2
  13. package/claude/skills/deploy-app/SKILL.md +2 -0
  14. package/claude/skills/deprecation-migration/SKILL.md +7 -9
  15. package/claude/skills/document-health/references/axes.md +1 -1
  16. package/claude/skills/git-followup/SKILL.md +16 -5
  17. package/claude/skills/git-issue/references/issue.md +9 -11
  18. package/claude/skills/git-pr/references/publish.md +4 -5
  19. package/claude/skills/git-split/SKILL.md +11 -4
  20. package/claude/skills/markdown-craft/SKILL.md +1 -1
  21. package/claude/skills/markdown-craft/references/markdown.md +2 -2
  22. package/claude/skills/markdown-craft/references/tables.md +9 -7
  23. package/claude/skills/plan-feature/SKILL.md +2 -0
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/review-address/SKILL.md +9 -7
  26. package/claude/skills/review-craft/SKILL.md +7 -9
  27. package/claude/skills/review-craft/references/adopted.md +10 -8
  28. package/claude/skills/review-pr/SKILL.md +2 -0
  29. package/claude/skills/review-pr/references/key-changes.md +8 -6
  30. package/claude/skills/review-ui/references/driving.md +10 -8
  31. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +6 -4
  32. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +9 -3
  33. package/claude/skills/role-reviewer/SKILL.md +5 -5
  34. package/claude/skills/search-craft/SKILL.md +5 -7
  35. package/claude/skills/session-worktree/SKILL.md +2 -0
  36. package/claude/skills/session-worktree/references/main-root-writes.md +2 -0
  37. package/claude/skills/systematic-debugging/SKILL.md +9 -9
  38. package/claude/skills/systematic-debugging/references/adopted.md +1 -1
  39. package/claude/skills/task-board/SKILL.md +2 -4
  40. package/claude/skills/task-board/references/tasks.md +5 -5
  41. package/claude/skills/task-complete/references/archive.md +5 -2
  42. package/claude/skills/test-craft/SKILL.md +2 -0
  43. package/claude/skills/test-craft/references/adopted.md +2 -0
  44. package/claude/skills/ui-checklist/SKILL.md +8 -9
  45. package/claude/skills/write-doc/SKILL.md +8 -8
  46. package/claude/skills/write-doc/references/readme.md +2 -0
  47. package/claude/skills/write-doc/references/wiki-kind.md +11 -4
  48. package/docs/agents/context-audit.md +16 -14
  49. package/docs/agents/markdown-audit.md +11 -4
  50. package/docs/agents/migrate.md +15 -13
  51. package/docs/agents/output-shape.md +2 -0
  52. package/docs/target/migrations.md +3 -3
  53. package/docs/workflow/operating-model.md +2 -0
  54. package/package.json +1 -1
  55. package/src/commands/markdown.ts +25 -29
  56. package/src/labels/density.ts +5 -46
  57. package/src/markdown/density.ts +41 -6
  58. package/src/markdown/scope.ts +6 -1
  59. package/src/markdown/structure.ts +18 -9
  60. package/src/markdown/tables.ts +32 -18
  61. package/src/process/harness.ts +8 -0
  62. package/src/process/seeded-package.ts +21 -0
  63. package/src/serve/static.ts +2 -60
  64. package/tooling/base/reference.md +7 -6
  65. package/tooling/claude/reference.md +7 -5
  66. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +7 -6
  67. package/src/serve/live.ts +0 -113
@@ -26,24 +26,31 @@ The ban set and checkpoints ship in `src/markdown/bans.ts` and `src/markdown/str
26
26
  | --------------- | ------------------------------------------------------------------------------------ | ----------------- |
27
27
  | Bans | The two characters `markdown.md` bans, outside frontmatter, fences, spans, and links | Always |
28
28
  | Links | A relative link resolving to nothing on disk | Always |
29
- | Density | The six limits below, under `density` | On a gated prefix |
29
+ | Density | The six limits and two table limits below, under `density` | On a gated prefix |
30
30
  | Reference shape | A `kind: reference` page breaking its shape, under `referenceShape` | On a gated prefix |
31
- | Tables | The four table limits below, under `tables` | Never |
31
+ | Tables | The row count and wide token limits below, under `tables` | Never |
32
32
  | Colons | A colon followed by more text in a prose line, under `colons` | Never |
33
33
  | Heading case | A capital after the first word of a heading or `title`, under `headingCase` | Never |
34
34
 
35
35
  A page marked `kind: reference` holds its opening, then `Output`, `Refusals`, and `See also` as H2s, each at most once and in that order. It takes no H3 and no table headed `Flag` or `Option`. An unmarked page is never measured.
36
36
 
37
- Tables take the four limits in the `markdown-craft` skill's `tables.md`, on the density paths but never `CLAUDE.md`. A cell past 120 visible characters reports as `table-cell`, more than 8 body rows as `table-rows`, a list after a table as `table-then-list`, and a code span with a run past 30 characters as `table-token`. None sets the exit code.
37
+ Tables take the four limits in the `markdown-craft` skill's `tables.md`, on the density paths but never `CLAUDE.md`.
38
+
39
+ - A cell past 120 visible characters reports as `table-cell` and a list after a table as `table-then-list`, both under `density`
40
+ - More than 8 body rows reports as `table-rows` and a code span with a run past 30 characters as `table-token`, both under `tables` and neither setting the exit code
38
41
 
39
42
  | Density limit | Finding |
40
43
  | ------------------- | ---------------------------------------------------------- |
41
44
  | Paragraph length | A prose paragraph past 350 characters |
42
45
  | Paragraphs in a row | More than 2 paragraphs with nothing else between them |
43
- | Bullet length | A top-level bullet past 200 characters |
46
+ | Bullet length | A bullet past 200 characters, a nested one on its own |
44
47
  | Bullets in a row | More than 5 plain top-level bullets |
45
48
  | Heading depth | Any heading past H3 |
46
49
  | H3 opening | An H3 whose first block is neither a paragraph nor a table |
50
+ | Table cell | A table cell past 120 visible characters |
51
+ | List after table | A list as the block directly after a table |
52
+
53
+ Five notes fix how the limits count and where they apply.
47
54
 
48
55
  - A paragraph under a list item counts toward that bullet, never as a paragraph
49
56
  - A table, fence, list, heading, or bold section marker ends a run of paragraphs
@@ -8,21 +8,23 @@ kind: reference
8
8
 
9
9
  `canon migrate <verb>` moves a project off a retired name or layout. Each verb reports its plan and writes nothing until `--write`, takes `--json`, and, except `rename`, takes `--root`. A second run rewrites nothing, which confirms the exclusions and markers fired.
10
10
 
11
- | Verb | Moves |
12
- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
13
- | `status` | Nothing. It lists every migration the project still needs, in run order. |
14
- | `rename` | The retired `aitk` name. `--scope target` rewrites toolkit-owned folders only. |
15
- | `skill-names` | Plugin skills off their `claude-` and `draft-` prefixes and other retired names, with every citation |
16
- | `records` | Session records from `.claude/` to `.canon/`, with every tracked citation |
17
- | `surface-roots` | `context/`, `wireframes/`, `ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`, and the stamp folder from `.claude/` to `canon/`, by `git mv` |
18
- | `record-tree` | Citations inside `.canon/` folders a session still follows: `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, `teach` |
19
- | `rule-layout` | A flat installed-rule layout onto the `canon/`-wrapped one |
20
- | `scratch-evidence` | A folder a durable record cites out of `.tmp/`, with its citations |
21
- | `record-layout` | Review receipts, memory archives, feedback, renders, picks, and evidence into their current folders |
22
- | `session-defaults` | The seeded session defaults into an existing `.claude/settings.json`: `Agent`, `Task`, and both merge commands denied, auto memory off |
11
+ | Verb | Moves |
12
+ | ------------------ | --------------------------------------------------------------------------------------------------------------- |
13
+ | `status` | Nothing. It lists every migration the project still needs, in run order. |
14
+ | `rename` | The retired `aitk` name. `--scope target` rewrites toolkit-owned folders only. |
15
+ | `skill-names` | Plugin skills off their `claude-` and `draft-` prefixes and other retired names, with every citation |
16
+ | `records` | Session records from `.claude/` to `.canon/`, with every tracked citation |
17
+ | `surface-roots` | Records such as `context/` and `REQUIREMENTS.md`, and the stamp folder, from `.claude/` to `canon/` by `git mv` |
18
+ | `record-tree` | Citations inside the `.canon/` folders a session still follows, listed below the table |
19
+ | `rule-layout` | A flat installed-rule layout onto the `canon/`-wrapped one |
20
+ | `scratch-evidence` | A folder a durable record cites out of `.tmp/`, with its citations |
21
+ | `record-layout` | Review receipts, memory archives, feedback, renders, picks, and evidence into their current folders |
22
+ | `session-defaults` | The seeded session defaults into an existing `.claude/settings.json`, listed below the table |
23
+
24
+ `record-tree` follows citations in `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`. `session-defaults` denies `Agent`, `Task`, and both merge commands, and turns auto memory off. The bullets below add detail per verb.
23
25
 
24
26
  - `skill-names` takes no `--scope`, and skips the changelog, eval transcripts, and the pointer each renamed `draft-` skill leaves at its old name
25
- - `surface-roots` leaves `rules/`, `skills/`, `hooks/`, and `settings.json` where Claude Code reads them
27
+ - `surface-roots` moves `context/`, `wireframes/`, `ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`, and the stamp folder, and leaves `rules/`, `skills/`, `hooks/`, and `settings.json` in place
26
28
  - `rule-layout` classifies each flat file against the stamp as clean, edited, or unclaimed. Clean and edited files move untouched, and an unclaimed name stays.
27
29
  - `record-layout` labels each evidence folder `(pick)` or `(evidence)` in the dry run
28
30
  - `session-defaults` adds a key only when absent, merge entries included, and each older project runs it once. `--write` restores a removed `Agent` or `Task` and never removes a deny entry.
@@ -54,6 +54,8 @@ Discover names at runtime rather than hardcoding them.
54
54
  | `canon docs list --json` | Consumer docs plus per-domain context |
55
55
  | `canon audits list --json` | Audits the set runs, the corpus each reads, and whether it gates |
56
56
 
57
+ The table names the verbs, and three notes cover what some of them carry.
58
+
57
59
  - `gov list` expands each stack to its rules and carries `unreferenced`, the rules no stack reaches, which install only through `--add`
58
60
  - `claude skills list` reports shipped skills only. Each carries `family` (or `null`) and `requirement`, and a `false` requirement is a gap to close. `--names` and `--by-family` change the render.
59
61
  - A skill with unparseable frontmatter returns an empty description rather than failing the listing
@@ -62,19 +62,19 @@ The seven `draft-` skills took verb-first names for what each makes. `canon migr
62
62
  | `draft-ready` | `plan-ready` <!-- canon-keep-retired --> |
63
63
  | `draft-screencast` | `script-screencast` <!-- canon-keep-retired --> |
64
64
 
65
+ Replace a citation of a skill with no successor by hand, or delete the sentence.
66
+
65
67
  - `canvas` is now `canvas-board`. The sweep rewrites only `canon:canvas` and `skills/canvas`, so replace a bare prose mention by hand. <!-- canon-keep-retired -->
66
68
  - `draft-ready` is rewritten only as `canon:draft-ready` and `skills/draft-ready`, since bare it also describes a pull request. Replace a bare mention of the skill by hand. <!-- canon-keep-retired -->
67
69
  - `design-extract` and `sketch-design` are replaced by `canon:canvas-board`. <!-- canon-keep-retired -->
68
70
  - `review-branch`, `seed-sync`, `restate-plainly`, `bash-cli-script`, `canon-operator`, and `canon-rollout` have no successor <!-- canon-keep-retired -->
69
71
  - `create-standard`, `markdown-propose`, `memory-review`, `project-commands`, `repo-metadata`, and `ux-walkthrough` have no successor <!-- canon-keep-retired -->
70
72
 
71
- Replace a citation of a skill with no successor by hand, or delete the sentence.
73
+ The `memory` record kind left `canon records`, so `canon records validate memory` refuses as an unknown kind and `canon records migrate` is gone.
72
74
 
73
75
  - `canon repo metadata`, `canon autoship`, and the `120-bash` rule are gone, and `canon gov sync` removes the rule <!-- canon-keep-retired -->
74
76
  - `memory-capture`, the `603-memory` rule, and the `memory` standard are gone, so send a lesson to the context entry that owns the fact <!-- canon-keep-retired -->
75
77
 
76
- The `memory` record kind left `canon records`, so `canon records validate memory` refuses as an unknown kind and `canon records migrate` is gone.
77
-
78
78
  Two held files run, `.husky/post-merge` and `.claude/hooks/pr-create-log.sh`, and a stale copy calls a missing binary or names a removed skill. Resync what the toolkit owns before sweeping what the project wrote, and run the second line once per stack.
79
79
 
80
80
  ```bash
@@ -17,6 +17,8 @@ One warm session holds the cross-feature call and reviews. Cold worker sessions
17
17
  | Planner | One session per row, warm or cold | Measure the row against the tree, write one plan | Enter a worktree, write the board |
18
18
  | Worker | One cold worktree session per feature | Implement, self-check, open PR, answer the reviewer | Write the shared board, merge |
19
19
 
20
+ Each role keeps to its own writes, and five rules cover how the roles meet.
21
+
20
22
  - The orchestrator loads `role-orchestrator`, a worker loads `role-worker` through `auto-ship` Step 0, and a planner and a reviewer load theirs from the launch that dispatches them
21
23
  - The orchestrator decides which rows conflict, what merges first, and whether a row runs at all. Per-row planning can go cold.
22
24
  - A dispatched planner writes only the plan, and reports what the task file got wrong rather than repairing it
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.55.0",
4
+ "version": "5.56.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -141,9 +141,10 @@ export function register(program: Command): void {
141
141
  'Depth weight is a judgment a reader settles, so it reports and never',
142
142
  'fails a run.',
143
143
  '',
144
- 'Density holds six limits: a 350-character paragraph, 2 paragraphs in',
145
- 'a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a',
146
- 'paragraph or table opening every H3. Only context, docs, skills, tooling',
144
+ 'Density holds eight limits: a 350-character paragraph, 2 paragraphs in',
145
+ 'a row, a 200-character bullet, 5 plain bullets in a row, no H4, a',
146
+ 'paragraph or table opening every H3, a 120-character table cell, and no',
147
+ 'list as the block after a table. Only context, docs, skills, tooling',
147
148
  'seeds and references, the wiki, README.md, and CONTRIBUTING.md take a',
148
149
  'finding. CLAUDE.md and its seed take only the bullet run and the H3',
149
150
  'opening, inside Key paths. In a canon checkout one gates under a',
@@ -154,10 +155,9 @@ export function register(program: Command): void {
154
155
  'and none gates.',
155
156
  'A config that does not parse refuses with its path named.',
156
157
  '',
157
- 'Tables take four limits on the same paths, CLAUDE.md aside: a',
158
- '120-character cell, 8 body rows, a list as the next block, and a code',
159
- 'span with a run past 30 characters. A table finding reports and never',
160
- 'fails a run.',
158
+ 'Tables take two more limits on the same paths, CLAUDE.md aside: 8 body',
159
+ 'rows, and a code span with a run past 30 characters. Each reports and',
160
+ 'never fails a run.',
161
161
  '',
162
162
  'A page whose frontmatter carries kind: reference takes the reference',
163
163
  'shape: no H2 beyond Output, Refusals, and See also, each at most once',
@@ -637,15 +637,19 @@ const DENSITY_LABELS: Record<DensityKind, string> = {
637
637
  'bullet-run': 'bullets in a row',
638
638
  'heading-depth': 'heading depth',
639
639
  'h3-opening': 'H3 opening',
640
+ 'table-cell': 'cell length',
641
+ 'table-then-list': 'list after table',
640
642
  }
641
643
 
642
644
  function describeDensity(finding: GatedDensityFinding): string {
643
645
  const measured =
644
646
  finding.kind === 'h3-opening'
645
647
  ? 'opens with neither a paragraph nor a table'
646
- : finding.kind === 'heading-depth'
647
- ? `H${finding.value} past H${finding.limit}`
648
- : `${finding.value} past ${finding.limit}`
648
+ : finding.kind === 'table-then-list'
649
+ ? 'follows the table with no lead sentence'
650
+ : finding.kind === 'heading-depth'
651
+ ? `H${finding.value} past H${finding.limit}`
652
+ : `${finding.value} past ${finding.limit}`
649
653
  const gate = finding.gating ? ' gating' : ''
650
654
  return ` :${finding.line} ${DENSITY_LABELS[finding.kind]} ${measured}${gate}`
651
655
  }
@@ -662,10 +666,10 @@ function reportDensity(
662
666
  ): void {
663
667
  logStep('Density')
664
668
  logInfo(
665
- `Paragraphs past ${CHECKPOINTS.paragraph} characters, more than 2 paragraphs in a row, bullets past ${CHECKPOINTS.bullet} characters, more than 5 plain bullets in a row, a heading past H3, and an H3 opening with neither a paragraph nor a table.`,
669
+ `Paragraphs past ${CHECKPOINTS.paragraph} characters, more than 2 paragraphs in a row, bullets past ${CHECKPOINTS.bullet} characters, more than 5 plain bullets in a row, a heading past H3, an H3 opening with neither a paragraph nor a table, a table cell past ${TABLE_CELL} characters, and a list directly after a table.`,
666
670
  )
667
671
  logInfo(
668
- 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. CLAUDE.md and its seed take the bullet run and the H3 opening inside Key paths alone. Every other path takes no density finding.',
672
+ 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. CLAUDE.md and its seed take the bullet run and the H3 opening inside Key paths alone, with no table finding. Every other path takes no density finding.',
669
673
  )
670
674
  logInfo(densityGatingLine(gated, source))
671
675
 
@@ -689,7 +693,7 @@ function reportDensity(
689
693
  )
690
694
  if (gating > 0) logWarn('A gating finding fails the run.')
691
695
  logInfo(
692
- 'Split a long block at its seam, put a heading or a table between paragraphs, give a long list H3s, and open each H3 with a sentence or a table.',
696
+ 'Split a long block at its seam, put a heading or a table between paragraphs, give a long list H3s, open each H3 with a sentence or a table, move a long cell into a bullet under the lead, and give a trailing list its own lead sentence.',
693
697
  )
694
698
  pipeOutput(
695
699
  carrying
@@ -718,29 +722,23 @@ function densityGatingLine(
718
722
  }
719
723
 
720
724
  const TABLE_LABELS: Record<TableKind, string> = {
721
- 'table-cell': 'cell length',
722
725
  'table-rows': 'rows',
723
- 'table-then-list': 'list after table',
724
726
  'table-token': 'wide token',
725
727
  }
726
728
 
727
729
  function describeTable(finding: TableFinding): string {
728
- const measured =
729
- finding.kind === 'table-then-list'
730
- ? 'follows the table with no lead sentence'
731
- : `${finding.value} past ${finding.limit}`
732
- return ` :${finding.line} ${TABLE_LABELS[finding.kind]} ${measured}`
730
+ return ` :${finding.line} ${TABLE_LABELS[finding.kind]} ${finding.value} past ${finding.limit}`
733
731
  }
734
732
 
735
733
  /**
736
- * Lists every table finding on the density scope's paths. None gates yet,
737
- * since a gate is decided per path rather than per kind, and a new density
738
- * kind would fail every gated folder carrying a table hit at once.
734
+ * Lists the table findings that only report, on the density scope's paths. The
735
+ * cell and the trailing list gate as density kinds, so `reportDensity` lists
736
+ * them.
739
737
  */
740
738
  function reportTables(reports: readonly FileReport[]): void {
741
739
  logStep('Tables')
742
740
  logInfo(
743
- `Cells past ${TABLE_CELL} characters, tables past ${TABLE_ROWS} body rows, a list directly after a table, and a code span in a cell holding a run past ${TABLE_TOKEN} characters.`,
741
+ `Tables past ${TABLE_ROWS} body rows and a code span in a cell holding a run past ${TABLE_TOKEN} characters.`,
744
742
  )
745
743
  logInfo(
746
744
  'Measured on the paths density measures in full, so never on CLAUDE.md, outside fenced blocks. This never fails a run.',
@@ -760,7 +758,7 @@ function reportTables(reports: readonly FileReport[]): void {
760
758
  `${plural(total, 'finding')} across ${plural(carrying.length, 'file')}`,
761
759
  )
762
760
  logInfo(
763
- 'Move a long cell into a bullet under the lead, split a long table into groups each under a lead sentence, give a trailing list its own lead, and move a wide token out of the table.',
761
+ 'Split a long table into groups each under a lead sentence, and move a wide token out of the table.',
764
762
  )
765
763
  pipeOutput(
766
764
  carrying
@@ -917,11 +915,9 @@ function reportBullets(
917
915
  ): void {
918
916
  logStep('Bullets')
919
917
  logInfo(
920
- 'Top-level bullets measure characters, folding in continuation lines.',
921
- )
922
- logInfo(
923
- 'Nested items and fenced blocks are excluded. Weight is a judgment, never a defect.',
918
+ 'Bullets measure characters, folding in continuation lines, and a nested bullet measures on its own.',
924
919
  )
920
+ logInfo('Fenced blocks are excluded. Weight is a judgment, never a defect.')
925
921
 
926
922
  const carrying = reports
927
923
  .filter((report) => report.structure.heavyBullets.length > 0)
@@ -1,10 +1,6 @@
1
- import {
2
- type DensityFinding,
3
- type DensityKind,
4
- densityFindings,
5
- } from '@/markdown/density'
6
- import { type BodyLine, bodyLines, visibleText } from '@/markdown/scan'
7
- import { CHECKPOINTS, LIST_ITEM } from '@/markdown/structure'
1
+ import { type DensityKind, densityFindings } from '@/markdown/density'
2
+ import { type BodyLine, bodyLines } from '@/markdown/scan'
3
+ import { LIST_ITEM } from '@/markdown/structure'
8
4
 
9
5
  /**
10
6
  * The four counted limits a pull request section carries. The heading limits
@@ -119,7 +115,6 @@ export interface PostDensityFinding extends Omit<BodyDensityFinding, 'kind'> {
119
115
  }
120
116
 
121
117
  const LENGTHS: readonly DensityKind[] = ['paragraph-length', 'bullet-length']
122
- const NESTED_ITEM = /^(\s+)(?:[-*+]|\d+\.)\s+(.*)$/
123
118
  const CHECKBOX = /^\s*[-*+]\s+\[[ xX]\]/
124
119
  const FOOTER = /^🤖/
125
120
 
@@ -134,8 +129,8 @@ const FOOTER = /^🤖/
134
129
  * paragraph run, and a checklist box under `What to look at` takes no length
135
130
  * limit, since a driver needs its route, action, and result.
136
131
  *
137
- * A nested item is measured as a bullet of its own, which `densityFindings()`
138
- * leaves out of its parent, so a finding's subject and its evidence never sum.
132
+ * A nested item is measured as a bullet of its own by `densityFindings()`, which
133
+ * leaves it out of its parent, so a finding's subject and its evidence never sum.
139
134
  */
140
135
  export function postDensity(body: string): PostDensityFinding[] {
141
136
  const lines = bodyLines(body).map((line) =>
@@ -153,7 +148,6 @@ export function postDensity(body: string): PostDensityFinding[] {
153
148
  (chunk) => densityFindings(chunk),
154
149
  ),
155
150
  ...densityFindings(inside).filter(({ kind }) => LENGTHS.includes(kind)),
156
- ...nestedLengths(section.lines),
157
151
  ]
158
152
 
159
153
  for (const finding of measured) {
@@ -214,41 +208,6 @@ function foldedApart(lines: readonly BodyLine[]): {
214
208
  return { outside, inside }
215
209
  }
216
210
 
217
- /**
218
- * A `bullet-length` finding for each nested item past the bullet limit, its
219
- * length taken over the item and the lines indented under it that open no
220
- * item of their own.
221
- */
222
- function nestedLengths(lines: readonly BodyLine[]): DensityFinding[] {
223
- const found: DensityFinding[] = []
224
-
225
- for (const [index, line] of lines.entries()) {
226
- const match = line.fenced ? null : NESTED_ITEM.exec(line.text)
227
- if (!match) continue
228
-
229
- const indent = match[1].length
230
- const parts = [match[2].trim()]
231
- for (const next of lines.slice(index + 1)) {
232
- const lead = /^\s*/.exec(next.text)?.[0].length ?? 0
233
- if (next.fenced || next.text.trim() === '' || lead <= indent) break
234
- if (LIST_ITEM.test(next.text)) break
235
- parts.push(next.text.trim())
236
- }
237
-
238
- const length = visibleText(parts.join(' ')).length
239
- if (length > CHECKPOINTS.bullet) {
240
- found.push({
241
- kind: 'bullet-length',
242
- line: line.number,
243
- value: length,
244
- limit: CHECKPOINTS.bullet,
245
- })
246
- }
247
- }
248
-
249
- return found
250
- }
251
-
252
211
  function sectionsOf(lines: readonly BodyLine[]): Section[] {
253
212
  const sections: Section[] = []
254
213
  let current: { name: string | null; lines: BodyLine[] } = {
@@ -22,6 +22,8 @@ export type DensityKind =
22
22
  | 'bullet-run'
23
23
  | 'heading-depth'
24
24
  | 'h3-opening'
25
+ | 'table-cell'
26
+ | 'table-then-list'
25
27
 
26
28
  export interface DensityFinding {
27
29
  readonly kind: DensityKind
@@ -52,14 +54,16 @@ type Block =
52
54
  * here it belongs to the item: it counts toward the bullet's length and never
53
55
  * toward a paragraph run, since a reader sees it inside the list.
54
56
  *
55
- * A nested item is left out of its parent, as `heavyBullets` leaves it, and so
56
- * is everything indented under the nested item. An indented fence inside an
57
- * item stays with the item, and a column-zero one ends it.
57
+ * A nested item is a block of its own, taking the lines indented past its
58
+ * marker, and its parent leaves it out along with everything indented under it.
59
+ * An indented fence inside an item joins neither and ends the nested item, and
60
+ * a column-zero one ends both.
58
61
  */
59
62
  function blocksOf(lines: readonly BodyLine[]): Block[] {
60
63
  const blocks: Block[] = []
61
64
  let paragraph: { line: number; parts: string[] } | undefined
62
65
  let item: { line: number; parts: string[]; nested: boolean } | undefined
66
+ let child: { line: number; parts: string[]; indent: number } | undefined
63
67
  let inFence = false
64
68
  let afterBlank = false
65
69
 
@@ -74,7 +78,19 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
74
78
  paragraph = undefined
75
79
  }
76
80
 
81
+ const closeChild = (): void => {
82
+ if (child) {
83
+ blocks.push({
84
+ kind: 'item',
85
+ line: child.line,
86
+ text: child.parts.join(' '),
87
+ })
88
+ }
89
+ child = undefined
90
+ }
91
+
77
92
  const closeItem = (): void => {
93
+ closeChild()
78
94
  if (item) {
79
95
  blocks.push({ kind: 'item', line: item.line, text: item.parts.join(' ') })
80
96
  }
@@ -95,7 +111,8 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
95
111
  // The opening delimiter decides who owns the block. `bodyLines` marks a
96
112
  // delimiter and the body alike, so the opener is the first fenced line.
97
113
  if (!inFence) {
98
- if (!(item && indented)) {
114
+ if (item && indented) closeChild()
115
+ else {
99
116
  closeAll()
100
117
  blocks.push({ kind: 'other', line: line.number, table: false })
101
118
  }
@@ -108,13 +125,27 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
108
125
 
109
126
  if (text === '') {
110
127
  closeParagraph()
128
+ closeChild()
111
129
  afterBlank = true
112
130
  continue
113
131
  }
114
132
 
115
133
  if (indented && item) {
116
- if (LIST_ITEM.test(raw)) item.nested = true
117
- else if (!item.nested && !TABLE_ROW.test(raw)) item.parts.push(text)
134
+ const lead = leadOf(raw)
135
+ if (LIST_ITEM.test(raw)) {
136
+ closeChild()
137
+ item.nested = true
138
+ child = {
139
+ line: line.number,
140
+ parts: [text.replace(LIST_ITEM, '')],
141
+ indent: lead,
142
+ }
143
+ } else if (child && lead > child.indent) {
144
+ if (!TABLE_ROW.test(raw)) child.parts.push(text)
145
+ } else {
146
+ closeChild()
147
+ if (!item.nested && !TABLE_ROW.test(raw)) item.parts.push(text)
148
+ }
118
149
  afterBlank = false
119
150
  continue
120
151
  }
@@ -163,6 +194,10 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
163
194
  return blocks
164
195
  }
165
196
 
197
+ function leadOf(raw: string): number {
198
+ return raw.length - raw.trimStart().length
199
+ }
200
+
166
201
  /**
167
202
  * Every breach of the six density limits, in line order.
168
203
  *
@@ -4,6 +4,7 @@ import {
4
4
  densityFindings,
5
5
  } from '@/markdown/density'
6
6
  import type { BodyLine } from '@/markdown/scan'
7
+ import { tableDensityFindings } from '@/markdown/tables'
7
8
 
8
9
  /** The surfaces the retired bullet-stacks stage held, per `ALWAYS_GATED`. */
9
10
  const BULLET_RUN_SCOPE = [
@@ -68,12 +69,16 @@ function keyPathsSection(lines: readonly BodyLine[]): readonly BodyLine[] {
68
69
  * The density findings a path takes, being every finding on a density-scope
69
70
  * file and the Key paths findings on a `CLAUDE.md`. The audit, the init probe,
70
71
  * and `canon claude sync` all read this, so the three agree on what counts.
72
+ * The table cell and trailing-list findings join on a density-scope path only,
73
+ * since `densityFindings` stays what its direct callers grade.
71
74
  */
72
75
  export function scopedDensityFindings(
73
76
  rel: string,
74
77
  lines: readonly BodyLine[],
75
78
  ): DensityFinding[] {
76
- if (inDensityScope(rel)) return densityFindings(lines)
79
+ if (inDensityScope(rel)) {
80
+ return [...densityFindings(lines), ...tableDensityFindings(lines)]
81
+ }
77
82
  if (!KEY_PATHS_SCOPE.test(rel)) return []
78
83
  return densityFindings(keyPathsSection(lines)).filter((finding) =>
79
84
  KEY_PATHS_KINDS.includes(finding.kind),
@@ -415,10 +415,10 @@ export function longestRun(
415
415
  }
416
416
 
417
417
  /**
418
- * Finds the top-level bullets carrying more than a decision.
418
+ * Finds the bullets carrying more than a decision, nested ones included.
419
419
  *
420
- * A nested item is left out rather than folded into its parent, since the
421
- * checkpoint asks what one bullet carries and a child carries its own. Lines
420
+ * A nested item is measured on its own and never folded into its parent, since
421
+ * the checkpoint asks what one bullet carries and a child carries its own. Lines
422
422
  * continuing a bullet do fold in, so a heavy bullet cannot fall under the
423
423
  * checkpoint by being wrapped across two source lines. A fenced line closes the
424
424
  * open bullet, which keeps a bullet from absorbing the example below it.
@@ -436,6 +436,8 @@ export function heavyBullets(
436
436
  interface FoldedBullet extends BulletFinding {
437
437
  /** The bullet's source lines joined by the space a wrap would have carried. */
438
438
  readonly text: string
439
+ /** The width of the indent before the marker, which a continuation must pass. */
440
+ readonly indent: number
439
441
  }
440
442
 
441
443
  /** The walk `heavyBullets` reports from. */
@@ -459,18 +461,18 @@ function foldBullets(
459
461
 
460
462
  const item = line.text.match(LIST_ITEM)
461
463
  const text = line.text.trim()
464
+ const lead = line.text.length - line.text.trimStart().length
462
465
 
463
466
  // Structure is read off the raw line and only the weight is masked. A line
464
467
  // carrying nothing but an autolink has no visible text at all, and reading
465
468
  // its masked form as blank would close the bullet it continues.
466
469
  if (item) {
467
470
  close()
468
- if (item[1].length === 0) {
469
- open = {
470
- line: line.number,
471
- characters: visibleText(text).length,
472
- text,
473
- }
471
+ open = {
472
+ line: line.number,
473
+ characters: visibleText(text).length,
474
+ text,
475
+ indent: item[1].length,
474
476
  }
475
477
  continue
476
478
  }
@@ -480,6 +482,13 @@ function foldBullets(
480
482
  continue
481
483
  }
482
484
 
485
+ // An indented line that does not pass the marker's indent belongs to a
486
+ // parent or to nothing, so it ends a nested item rather than extending it.
487
+ if (open && lead > 0 && lead <= open.indent) {
488
+ close()
489
+ continue
490
+ }
491
+
483
492
  // The joining space a wrapped line would have carried, so folding two
484
493
  // source lines measures what one unwrapped line would have.
485
494
  if (open)
@@ -1,3 +1,4 @@
1
+ import type { DensityFinding } from '@/markdown/density'
1
2
  import { type BodyLine, visibleText } from '@/markdown/scan'
2
3
  import { LIST_ITEM, TABLE_ROW, TABLE_SEPARATOR } from '@/markdown/structure'
3
4
 
@@ -10,19 +11,14 @@ export const TABLE_ROWS = 8
10
11
  /** The longest unbroken run a code span in a table cell may carry. */
11
12
  export const TABLE_TOKEN = 30
12
13
 
13
- export type TableKind =
14
- | 'table-cell'
15
- | 'table-rows'
16
- | 'table-then-list'
17
- | 'table-token'
14
+ export type TableKind = 'table-rows' | 'table-token'
18
15
 
19
16
  export interface TableFinding {
20
17
  readonly kind: TableKind
21
- /** The row holding the cell or span, the header for a row count, or the list's first item. */
18
+ /** The span's row, or the header for a row count. */
22
19
  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
20
+ readonly value: number
21
+ readonly limit: number
26
22
  }
27
23
 
28
24
  /**
@@ -55,16 +51,22 @@ function cellsOf(row: string): string[] {
55
51
  }
56
52
 
57
53
  /**
58
- * Every breach of the four table limits, in line order.
54
+ * Every breach of the four table limits, in line order, split by whether it
55
+ * gates.
59
56
  *
60
57
  * A table is a run of pipe rows whose second row is a delimiter, the shape a
61
58
  * renderer draws. Pipe lines with no delimiter render as paragraph text, and a
62
59
  * fenced line is an example, so neither is measured. The limits are a
63
60
  * 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.
61
+ * holding a run past 30 characters. The cell and the trailing list are density
62
+ * findings, and the row count and the wide token report and never gate.
65
63
  */
66
- export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
67
- const found: TableFinding[] = []
64
+ function measureTables(lines: readonly BodyLine[]): {
65
+ reported: TableFinding[]
66
+ density: DensityFinding[]
67
+ } {
68
+ const reported: TableFinding[] = []
69
+ const density: DensityFinding[] = []
68
70
  let index = 0
69
71
 
70
72
  while (index < lines.length) {
@@ -96,7 +98,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
96
98
  for (const cell of cellsOf(row.text)) {
97
99
  const length = visibleText(cell).length
98
100
  if (length > TABLE_CELL) {
99
- found.push({
101
+ density.push({
100
102
  kind: 'table-cell',
101
103
  line: row.number,
102
104
  value: length,
@@ -106,7 +108,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
106
108
  for (const span of codeSpans(cell)) {
107
109
  const run = unbrokenRun(span)
108
110
  if (run > TABLE_TOKEN) {
109
- found.push({
111
+ reported.push({
110
112
  kind: 'table-token',
111
113
  line: row.number,
112
114
  value: run,
@@ -119,7 +121,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
119
121
 
120
122
  const bodyRows = tableRows.length - 1
121
123
  if (bodyRows > TABLE_ROWS) {
122
- found.push({
124
+ reported.push({
123
125
  kind: 'table-rows',
124
126
  line: header.number,
125
127
  value: bodyRows,
@@ -135,7 +137,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
135
137
  !following.fenced &&
136
138
  LIST_ITEM.test(following.text)
137
139
  ) {
138
- found.push({
140
+ density.push({
139
141
  kind: 'table-then-list',
140
142
  line: following.number,
141
143
  value: null,
@@ -146,5 +148,17 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
146
148
  index = end
147
149
  }
148
150
 
149
- return found.sort((a, b) => a.line - b.line)
151
+ return { reported, density }
152
+ }
153
+
154
+ /** The row count and wide token findings, which report and never gate. */
155
+ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
156
+ return measureTables(lines).reported.sort((a, b) => a.line - b.line)
157
+ }
158
+
159
+ /** The cell and trailing-list findings, which gate as density kinds do. */
160
+ export function tableDensityFindings(
161
+ lines: readonly BodyLine[],
162
+ ): DensityFinding[] {
163
+ return measureTables(lines).density.sort((a, b) => a.line - b.line)
150
164
  }