@erclx/canon 5.22.0 → 5.23.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.22.0",
4
+ "version": "5.23.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -26,7 +26,7 @@ CANON_NON_INTERACTIVE=1 canon sync --check . --json
26
26
  CANON_NON_INTERACTIVE=1 canon context audit --json
27
27
  ```
28
28
 
29
- The first answers five domains from one read, being `skew`, `domains`, `seeds`, `tooling`, and the layout fields `unmigrated`, `superseded`, and `reverse`. The second deepens a domain that first read only counts.
29
+ The first answers five domains from one read, being `skew`, `domains`, `seeds`, `tooling`, and the layout fields `unmigrated`, `superseded`, `unmovedSurfaces`, and `reverse`. The second deepens a domain that first read only counts.
30
30
 
31
31
  Then run `CANON_NON_INTERACTIVE=1 canon records validate <kind> --json` once per record folder the target carries, taking `<kind>` from what is present under the record root rather than from a list held here.
32
32
 
@@ -76,14 +76,14 @@ Lead with the table, then one detail block per domain that is not current:
76
76
  ```markdown
77
77
  Target check: `<path>`, against canon `<version>`.
78
78
 
79
- | Domain | State | Evidence |
80
- | ---------------- | ------------------------- | --------------------------------- |
81
- | Toolkit version | current / behind / unread | `skew.installed` against `latest` |
82
- | `canon/` folder | current / behind / unread | what the layout fields named |
83
- | Governance rules | current / behind / unread | the per-state counts |
84
- | Tooling | current / behind / unread | what a sync would change |
85
- | Seeds | current / behind / unread | the per-state counts |
86
- | Claude harness | unchecked | no verb reads it |
79
+ | Domain | State | Evidence |
80
+ | ---------------- | ------------------------- | ---------------------------------------------- |
81
+ | Toolkit version | current / behind / unread | `skew.installed` against `latest` |
82
+ | `canon/` folder | current / behind / unread | the layout fields, including `unmovedSurfaces` |
83
+ | Governance rules | current / behind / unread | the per-state counts |
84
+ | Tooling | current / behind / unread | what a sync would change |
85
+ | Seeds | current / behind / unread | the per-state counts |
86
+ | Claude harness | unchecked | no verb reads it |
87
87
 
88
88
  No overall verdict. The harness row is unchecked rather than passing.
89
89
  ```
@@ -21,19 +21,25 @@ This domain is first because it conditions the other five. A target a release be
21
21
 
22
22
  ## 2. The `canon/` folder
23
23
 
24
- Read three fields off the same report, then two verbs:
24
+ Read four fields off the same report, then two verbs:
25
25
 
26
26
  - `unmigrated`: a domain still at the root layout with nothing under `.claude/`. The most urgent finding, because such a domain reports no drift of its own while being entirely behind.
27
27
  - `superseded`: a file a newer seed folder replaced. No command moves it, since the content is the project's own.
28
+ - `unmovedSurfaces`: a surface held only at its `.claude/` spelling, each entry carrying `rel` and `movesTo`. Every other field reads clean for such a target, and the report's drift status ignores it by design, so read the array itself rather than an exit status.
28
29
  - `reverse`: `unclaimed` names folders at roots the toolkit stopped shipping, each with an `attribution` of `dropped`, `unattributed`, or `project`.
29
30
  - `canon context audit --json`: required sections, entry length, citations, and index drift across the context folder.
30
31
  - `canon records validate <kind> --json`, once per record folder the target carries.
31
32
 
32
- - Current: `unmigrated` and `superseded` both empty, `reverse.unclaimed` carrying nothing outside `project` attribution, and both verbs finding nothing.
33
- - Behind: any of those carrying an entry. Name the entry and its attribution.
34
- - Unread: `historyUnavailable` set on `reverse`, a `reverse` key the report does not carry at all, or either verb refusing. A missing `reverse` key is a binary predating the field rather than a target with nothing unclaimed.
33
+ - Current: `unmigrated`, `superseded`, and `unmovedSurfaces` all empty, `reverse.unclaimed` carrying nothing outside `project` attribution, and both verbs finding nothing.
34
+ - Behind: any of those carrying an entry. Name the entry and its attribution, and for `unmovedSurfaces` each `rel` with its `movesTo`.
35
+ - Unread: `historyUnavailable` set on `reverse`, a `reverse` or `unmovedSurfaces` key the report does not carry at all, or either verb refusing. A missing key is a binary predating the field rather than a target with nothing to report, so name the installed version beside it.
35
36
 
36
- Routes by finding. `unmigrated` routes to a relocation the user runs, since no command moves it. `superseded` routes to a split the user makes, since no command moves the content and no skill proposes it. An `unclaimed` folder carrying `project` attribution routes nowhere, since history proved the project owns it.
37
+ Routes by finding.
38
+
39
+ - `unmovedSurfaces` routes to `canon migrate surface-roots`, which only reports its plan until `--write` is passed. Name the verb with each entry's `rel` and `movesTo`, and run neither form.
40
+ - `unmigrated` routes to a relocation the user runs, since no command moves it.
41
+ - `superseded` routes to a split the user makes, since no command moves the content and no skill proposes it.
42
+ - An `unclaimed` folder carrying `project` attribution routes nowhere, since history proved the project owns it.
37
43
 
38
44
  ## 3. Governance rules
39
45
 
@@ -19,7 +19,7 @@ What the derivation costs where it does apply is that the ceiling rises when a d
19
19
 
20
20
  The decision word check counts each decision's prose against the cap the record states in a clause of the form `at most 150 words a decision`. The count runs below the `###` heading with fenced lines dropped, so neither the heading nor a template block inside a decision counts, and the report and the gate read the same number. The JSON record carries the clause as `architecture.wordCap`, and the audit catalog counts the decisions past it as `recordOverWords`, absent where the record states no clause.
21
21
 
22
- The risk bullet check counts every list item under `## Risks / open questions`, nested ones included, against a clause of the form `at most 6 risk bullets`, since a nested bullet is weight the reader pays for like any other. Both clauses can share the entry cap's sentence, and each gates only a record stating it, for the reason the cap does. A record stating a risk cap and carrying no Risks section is reported as absent rather than passed as zero bullets. The JSON record carries the clause as `architecture.riskCap` and the count as `architecture.risksBullets`, and the audit catalog counts a section past its cap as `recordOverRisks`, absent where the record states no clause.
22
+ The risk bullet check counts every list item under `## Risks / open questions`, nested ones included, against a clause of the form `at most 6 risk bullets`, since a nested bullet is weight the reader pays for like any other. Both clauses can share the entry cap's sentence, and each gates only a record stating it, for the reason the cap does. The record `canon claude init` seeds into a new project states all three caps in that one sentence, so a new project gates on them from its first decision, and a project that installed earlier keeps whatever its own record states. A record stating a risk cap and carrying no Risks section is reported as absent rather than passed as zero bullets. The JSON record carries the clause as `architecture.riskCap` and the count as `architecture.risksBullets`, and the audit catalog counts a section past its cap as `recordOverRisks`, absent where the record states no clause.
23
23
 
24
24
  The coverage report classifies each decision as carrying a countable claim, a structural invariant, or neither, then reports each testable entry against whether it names a check that exists. A countable claim carries a figure a run could recompute, and an invariant quantifies over a named tree closely enough that a walk could falsify it. A check is a `scripts/**.sh` path the entry spells that is on disk, or a `canon` invocation matching a registered audit, so coverage reads the entry rather than the tree and a claim some check happens to cover without the entry saying so reads as unchecked.
25
25
 
@@ -33,4 +33,4 @@ The `## Risks / open questions` section is weighed the same way, reported apart
33
33
 
34
34
  ## The requirements record
35
35
 
36
- The audit reads `canon/REQUIREMENTS.md` under its own step and counts every word below the frontmatter against the cap the record states in a clause of the form `This record holds at most 600 words.` The cap is the record's own, so a record stating none is measured and never gated, and a project with no record is reported as out of scope. The JSON record carries the reading as `requirements`, null where the project carries no record and absent under `--citations-only`, and the audit catalog counts a record past its cap as `requirementsOverWords`.
36
+ The audit reads `canon/REQUIREMENTS.md` under its own step and counts every word below the frontmatter against the cap the record states in a clause of the form `This record holds at most 600 words.` The cap is the record's own, so a record stating none is measured and never gated, and a project with no record is reported as out of scope. The seeded record states the 600-word cap. The JSON record carries the reading as `requirements`, null where the project carries no record and absent under `--citations-only`, and the audit catalog counts a record past its cap as `requirementsOverWords`.
@@ -62,7 +62,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
62
62
  - [Targets](targets.md): The projects this toolkit installed into, the record the install writes against the sweep that backs it, what bounds each answer, and the cross-target pull request read
63
63
  - [Tasks archive](tasks-archive.md): Archiving a shipped task by stem or pull request, declining one decided against, the refusal reasons each carries, and how the plan and ready folder move with the task
64
64
  - [Tasks plans](tasks-plans.md): The five verbs that read or write a task's plan, being where it sits and who cites it, whether it waits on the operator, the branch it derives, what a branch reached against what it declared, and writing its Plan line
65
- - [Tasks validate](tasks-validate.md): Validating the board against the tree, the seven checks, the untested, claims, and unplaced arrays, the exit codes, and the refusal reasons
65
+ - [Tasks validate](tasks-validate.md): Validating the board against the tree, the seven checks, the untested, claims, wide, and unplaced arrays, the exit codes, and the refusal reasons
66
66
  - [Tasks](tasks.md): Claiming the next phase label, recording a pull request number and closing an outcome, listing each task's readiness, and where archiving, the plan verbs, and board validation are described
67
67
  - [Teach authoring](teach-authoring.md): Resolving what the next lesson needs before it is written, rendering a lesson body's blocks to HTML, and rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions
68
68
  - [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, viewing one with its chrome refreshed, where the lesson authoring verbs are described, the refusal reasons, and why every write here runs through a verb
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Tasks validate
3
- description: Validating the board against the tree, the seven checks, the untested, claims, and unplaced arrays, the exit codes, and the refusal reasons
3
+ description: Validating the board against the tree, the seven checks, the untested, claims, wide, and unplaced arrays, the exit codes, and the refusal reasons
4
4
  ---
5
5
 
6
6
  # Tasks validate
@@ -103,7 +103,9 @@ That claim collides with every row a later session writes under the folder, and
103
103
 
104
104
  The scan reaches `## Run now` and stops, where the collision check stops. A cell in another group describes work nobody has planned, written as a sentence and rewritten once a plan exists, so a claim read off one reports on prose rather than on a file set. A parked folder claim surfaces when its row is promoted, which is when the cell becomes something a dispatcher can act on.
105
105
 
106
- A task file neither surface names lands in a fourth array, on the same reasoning:
106
+ A code span in any cell that the preview cannot break lands in a fourth array, `wide`, with the same `group`, `subject`, and `message` fields. The scan measures a span's longest whitespace-free run and flags one past 60 characters, or a brace-glob at any width. A 57-character span fit the pane and a 78-character one did not, so the limit is a heuristic and the array moves no exit code. A link target is skipped, since only its label renders, and the scan reaches every group, since the overflow it exists for sat under `## Up next`.
107
+
108
+ A task file neither surface names lands in a fifth array, on the same reasoning:
107
109
 
108
110
  ```json
109
111
  {
@@ -118,6 +118,6 @@ canon tasks list --json | jq -r '.tasks[] | select(.readiness == "backlog") | .s
118
118
 
119
119
  ## Validate
120
120
 
121
- `canon tasks validate` reports what each row of `priority.md` and each line of `backlog.md` claims against what the tree holds. Its seven checks, the three arrays that move no exit code, and the refusals are in `tasks-validate.md`.
121
+ `canon tasks validate` reports what each row of `priority.md` and each line of `backlog.md` claims against what the tree holds. Its seven checks, the four arrays that move no exit code, and the refusals are in `tasks-validate.md`.
122
122
 
123
123
  For the board format, see `standards/board.md`. For the `Pull request:` line and the archive rules, see `standards/tasks.md`.
@@ -71,7 +71,7 @@ Keep the `## Scripts` table in `canon/context/development.md` current as scripts
71
71
 
72
72
  Scaffold installs tooling and seeds. It does not fill the planning docs or the design system. Complete those before the first feature session:
73
73
 
74
- 1. Fill `canon/REQUIREMENTS.md` and `canon/ARCHITECTURE.md`. The seed provides the files, the scope and decisions are yours to write.
74
+ 1. Fill `canon/REQUIREMENTS.md` and `canon/ARCHITECTURE.md`. The seed provides the files, the scope and decisions are yours to write. Each seed states its own caps, 12 decisions of at most 150 words and 6 risk bullets for the architecture record and 600 words for the requirements, and `canon context audit` gates on them. Edit a number in the record to loosen it.
75
75
  2. For a UI project, invoke `canon:design-extract` to draft `canon/DESIGN.md`. With no UI code yet it takes the greenfield path and proposes tokens from the requirements and a `## Personality` section. Skip for non-UI projects.
76
76
  3. Optionally invoke `canon:draft-diagram` to draft entries under `.canon/diagrams/` from the architecture and the requirements. One file per diagram kind, so a later refresh of one kind leaves the others untouched. It renders each diagram it writes to verify the layout, which downloads the Mermaid CLI on first use and takes about 15 seconds.
77
77
  4. Start the feature loop. See [AI workflow](../workflow/ai-workflow.md) for the per-feature sequence.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.22.0",
4
+ "version": "5.23.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": {
@@ -42,6 +42,7 @@ import {
42
42
  import {
43
43
  type Finding,
44
44
  type FolderClaim,
45
+ type WideToken,
45
46
  type Unplaced,
46
47
  type Untested,
47
48
  type ValidateOutcome,
@@ -1411,6 +1412,16 @@ function reportValidation(
1411
1412
  for (const claim of outcome.claims) logWarn(describeClaim(claim))
1412
1413
  }
1413
1414
 
1415
+ // The width limit is a heuristic over one preview pane, so a wide token
1416
+ // reports and moves no exit code, and a measure failing on a cell that
1417
+ // fits would teach a reader to skip it.
1418
+ logStep('Wide tokens')
1419
+ if (outcome.wide.length === 0) {
1420
+ logInfo('every code span fits the preview pane')
1421
+ } else {
1422
+ for (const token of outcome.wide) logWarn(describeWide(token))
1423
+ }
1424
+
1414
1425
  // Unplaced is the normal state between a session filing a task and an
1415
1426
  // orchestrator placing its row, so it moves no exit code. It still reports,
1416
1427
  // since it is the only local detector for a row a hand-edit dropped or a
@@ -1436,6 +1447,7 @@ function reportValidation(
1436
1447
  findings: outcome.findings,
1437
1448
  untested: outcome.untested,
1438
1449
  claims: outcome.claims,
1450
+ wide: outcome.wide,
1439
1451
  unplaced: outcome.unplaced,
1440
1452
  })}\n`,
1441
1453
  )
@@ -1457,6 +1469,10 @@ function describeClaim(claim: FolderClaim): string {
1457
1469
  return `${claim.group}: ${claim.subject} ${claim.message}`
1458
1470
  }
1459
1471
 
1472
+ function describeWide(token: WideToken): string {
1473
+ return `${token.group}: ${token.subject} ${token.message}`
1474
+ }
1475
+
1460
1476
  function describeUnplaced(task: Unplaced): string {
1461
1477
  return `${task.subject} ${task.message}`
1462
1478
  }
@@ -89,6 +89,20 @@ export interface FolderClaim {
89
89
  readonly message: string
90
90
  }
91
91
 
92
+ /**
93
+ * A code span in a board cell that the markdown preview cannot break, so it
94
+ * pushes the table past the pane and makes it scroll sideways. Either the
95
+ * longest whitespace-free run is wider than `WIDE_LIMIT`, or the span is a
96
+ * brace-glob, which is written out as separate paths. The limit is a heuristic
97
+ * over one pane, which is why it reports beside the findings and moves no exit
98
+ * code.
99
+ */
100
+ export interface WideToken {
101
+ readonly group: BoardGroup
102
+ readonly subject: string
103
+ readonly message: string
104
+ }
105
+
92
106
  /**
93
107
  * A task file neither surface names. That is the normal state between a
94
108
  * session filing it and a live orchestrator placing it, so it reports beside
@@ -124,6 +138,8 @@ export interface BoardRow {
124
138
  readonly ordinal: OrdinalWord | 'last' | undefined
125
139
  /** Whether a `Waiting on` cell ranks its row against a sibling row or a class of rows, false when the group fixes no such column. */
126
140
  readonly ranked: boolean
141
+ /** Every cell of the row as `splitCells` read it, which the wide-token scan walks. */
142
+ readonly cells: readonly string[]
127
143
  }
128
144
 
129
145
  export interface ValidateReport {
@@ -135,6 +151,7 @@ export interface ValidateReport {
135
151
  readonly findings: readonly Finding[]
136
152
  readonly untested: readonly Untested[]
137
153
  readonly claims: readonly FolderClaim[]
154
+ readonly wide: readonly WideToken[]
138
155
  readonly unplaced: readonly Unplaced[]
139
156
  }
140
157
 
@@ -461,6 +478,7 @@ export function readBoard(text: string): {
461
478
  waiting,
462
479
  ordinal: waiting ? readOrdinal(waiting) : undefined,
463
480
  ranked: waiting ? readRank(waiting) : false,
481
+ cells,
464
482
  })
465
483
  }
466
484
 
@@ -992,6 +1010,52 @@ function checkFolderClaims(
992
1010
  return claims
993
1011
  }
994
1012
 
1013
+ /**
1014
+ * The widest the longest unbroken run in a code span may be before a table
1015
+ * scrolls in the preview pane. A 57-character span fit and a 78-character one
1016
+ * did not, so the limit sits at the nearest round number above the first.
1017
+ */
1018
+ const WIDE_LIMIT = 60
1019
+
1020
+ /**
1021
+ * Reports a code span a table cell cannot wrap. A span breaks only at
1022
+ * whitespace, so the longest whitespace-free run is what is measured, and a
1023
+ * link target is stripped first because only its label renders. Every group is
1024
+ * scanned, since the overflow this exists for happened under `## Up next`,
1025
+ * where the folder-claim scan would not have looked. Prose breaks at spaces
1026
+ * and bare words at hyphens, so only a span is read.
1027
+ */
1028
+ function checkWideTokens(rows: readonly BoardRow[]): WideToken[] {
1029
+ const wide: WideToken[] = []
1030
+
1031
+ for (const row of rows) {
1032
+ for (const cell of row.cells) {
1033
+ const spans = cell.replace(/\]\([^)]*\)/g, ']').match(/`[^`]+`/g) ?? []
1034
+
1035
+ for (const span of spans) {
1036
+ const text = span.slice(1, -1)
1037
+ const run = Math.max(...text.split(/\s+/).map((part) => part.length))
1038
+
1039
+ if (/\{[^}]*,[^}]*\}/.test(text)) {
1040
+ wide.push({
1041
+ group: row.group,
1042
+ subject: subjectOf(row),
1043
+ message: `holds the brace-glob ${span}, which renders as one unbroken token. Write it out as separate paths.`,
1044
+ })
1045
+ } else if (run > WIDE_LIMIT) {
1046
+ wide.push({
1047
+ group: row.group,
1048
+ subject: subjectOf(row),
1049
+ message: `holds a ${run}-character token, ${span}, past the ${WIDE_LIMIT} a preview pane fits, so its table scrolls sideways.`,
1050
+ })
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ return wide
1057
+ }
1058
+
995
1059
  /**
996
1060
  * Reports a task file neither the board nor the backlog names. Under the
997
1061
  * roster-checked hand-off, filing a task and placing its row are two acts a
@@ -1327,6 +1391,7 @@ export async function validateBoard(
1327
1391
  findings,
1328
1392
  untested: parked.untested,
1329
1393
  claims: checkFolderClaims(rows, root),
1394
+ wide: checkWideTokens(rows),
1330
1395
  unplaced: checkUnplaced(rows, backlog, stems),
1331
1396
  }
1332
1397
  }
@@ -88,7 +88,7 @@ Every session pays for this file before any work starts, so a heavy read is a re
88
88
  - `This record holds at most <n> decisions.`
89
89
  - `at most <n> words a decision`, counting a decision's prose below its heading, fenced lines left out
90
90
  - `at most <n> risk bullets`, counting every list item under `## Risks / open questions`, nested ones included
91
- - 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.
91
+ - 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. The template and the seed a new project installs state all three, and a project loosens one by editing the number in its own record.
92
92
  - 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.
93
93
  - 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.
94
94
  - 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.
@@ -106,7 +106,7 @@ The revisit sentence closes the reasoning of every decision. The anchor sentence
106
106
 
107
107
  ## Overview
108
108
 
109
- This record holds at most 12 decisions. Every decision closes with a revisit sentence.
109
+ This record holds at most 12 decisions, at most 150 words a decision, and at most 6 risk bullets. Every decision closes with a revisit sentence.
110
110
 
111
111
  ## Key technical decisions
112
112
 
@@ -57,6 +57,8 @@ Each group fixes its own columns, which follow from the test above it rather tha
57
57
 
58
58
  A `Touches` cell is copied from its plan's own list, so it states what the branch sets out to write rather than a bound on it, and a branch outgrows it while the row still reads as it did at dispatch. Correct the cell from the branch rather than from the plan once one is running, since the plan is the prediction that already went stale and only the diff says what was written. `canon tasks plan-reach <plan>` reads that diff against every live plan and every cell in this group, and the row's owner is the one who writes the correction: a worker never edits this board.
59
59
 
60
+ A cell holds no code span whose longest unbroken run passes 60 characters, since a span has no break point and a wider one makes its table scroll sideways in the preview. Write a brace-glob out as separate paths for the same reason. `canon tasks validate` reports both in its `wide` array and moves no exit code.
61
+
60
62
  ### Row order
61
63
 
62
64
  Row position inside `## Needs a plan` is the order those tasks get planned in, top first. The three tests answer whether a task can start, which is mechanical, and none of them answers which task is worth starting, which is a judgment no column holds. Position is where that judgment is recorded, so the top row is the answer to what to plan next and a reader needs no other surface to get it. The other two groups take the same reading, and it costs them little, since a group holding what is already planned is short by construction.
@@ -40,7 +40,7 @@ Use `## Problem`, `## Goals`, `## Non-goals`, `## MVP features`, and `## Constra
40
40
 
41
41
  ## Length
42
42
 
43
- 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.
43
+ 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. The template and the seed a new project installs state it at 600, and a project loosens it by editing the number in its own record. At the cap, cut a goal or a constraint that no longer changes a decision before adding one.
44
44
 
45
45
  ## Lifecycle
46
46
 
@@ -59,6 +59,8 @@ State each entry as an outcome the consumer reaches, never as the mechanism that
59
59
  ```markdown
60
60
  # Requirements
61
61
 
62
+ This record holds at most 600 words.
63
+
62
64
  ## Problem
63
65
 
64
66
  ## Goals
@@ -12,8 +12,8 @@
12
12
  # its surface, not from this hook or the output rule.
13
13
  #
14
14
  # The worktree branch comes from the path rather than from `git rev-parse`,
15
- # the way `tasks-index.sh` and `memory-index.sh` already derive their main
16
- # root from a path suffix instead of the session. A git call would answer for
15
+ # the way `records-index.sh` already derives its main root from a path suffix
16
+ # instead of the session. A git call would answer for
17
17
  # whatever directory the hook's own process happens to run in, which is not
18
18
  # necessarily the worktree the write came from.
19
19
 
@@ -39,8 +39,8 @@ url=$(printf '%s' "$stdout" | grep -Eo 'https://github\.com/[^[:space:]]+/pull/[
39
39
  # CLAUDE_PROJECT_DIR is the session's own worktree rather than the main root,
40
40
  # so a worker building in a linked worktree would log into a folder that dies
41
41
  # with the worktree. The log is a denominator across a wave rather than a
42
- # per-session record, so strip back to the main root the way tasks-index.sh
43
- # and memory-index.sh already derive theirs, off a path suffix.
42
+ # per-session record, so strip back to the main root the way
43
+ # records-index.sh already derives its own, off a path suffix.
44
44
  root="${CLAUDE_PROJECT_DIR:-.}"
45
45
  case "$root" in
46
46
  */.claude/worktrees/*) root="${root%/.claude/worktrees/*}" ;;
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env bash
2
2
 
3
- # Regenerates .claude/tasks/index.md after a task file changes.
3
+ # Regenerates the index.md of a gitignored record folder, `tasks` or `memory`,
4
+ # after a file in it changes.
4
5
  #
5
- # The task folder is gitignored, so the whole-repo walk in `bun run check`
6
- # drops it and never regenerates this index. Naming the file as a positional
6
+ # These folders are gitignored, so the whole-repo walk in `bun run check` drops
7
+ # them and never regenerates their index. Naming the file as a positional
7
8
  # argument to `canon indexes regen` below bypasses that filter, which makes this
8
- # hook the only trigger that reaches the folder.
9
+ # hook the only trigger that reaches either folder.
9
10
 
10
11
  # Claude Code sends a payload and closes stdin. A bare read with nothing feeding
11
12
  # it blocks forever and holds the session open, so the read is bounded. `read`
@@ -25,45 +26,51 @@ esac
25
26
  file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
26
27
  [ -n "$file_path" ] || exit 0
27
28
 
28
- # Both record roots, since a project the move has reached keeps its board under
29
+ # Both record roots, since a project the move has reached keeps its records under
29
30
  # `.canon/` and a guard fixed at the old spelling stops matching with nothing
30
31
  # said. The index then goes stale while every save reports success.
31
- case "$file_path" in
32
- */.claude/tasks/*.md | */.canon/tasks/*.md) ;;
33
- *) exit 0 ;;
34
- esac
35
-
36
- # The board index covers the live folder alone. A shell pattern's wildcard
37
- # crosses a separator, so the guard above matches an archived or declined task
38
- # as well, and a regen fired on one would rebuild the index that task was
39
- # taken out of.
40
- case "$file_path" in
41
- */.claude/tasks/index.md | */.claude/tasks/archive/* | */.claude/tasks/declined/*) exit 0 ;;
42
- */.canon/tasks/index.md | */.canon/tasks/archive/* | */.canon/tasks/declined/*) exit 0 ;;
43
- esac
44
-
32
+ #
33
+ # A shell `case` `*` crosses `/`, so each arm also matches a file in a subfolder
34
+ # the index does not render: an archived or declined task, a memory receipt, a
35
+ # retired memory entry, or the index itself. Those exit here, before the regen
36
+ # call. The exclusions are per folder, since a task under `review/` is indexed
37
+ # and a memory `declined/` is not special. The arms are ordered tasks first,
38
+ # and no path carries both segments today.
39
+ #
45
40
  # The walk-up boundary has to come from the path, not from the session. Shared
46
41
  # scratch resolves at the main worktree root, so a session inside a linked
47
42
  # worktree passes a path that sits outside its own project directory and the
48
- # default boundary would reject it.
49
- #
50
- # The index this would have rebuilt is read out of the same branch, so both
51
- # messages below name the file that actually went stale rather than one root's
52
- # spelling of it.
43
+ # default boundary would reject it. The messages below name the index read out
44
+ # of the same match, so they name the file that actually went stale.
53
45
  case "$file_path" in
54
- */.canon/tasks/*)
55
- root="${file_path%/.canon/tasks/*}"
56
- index=".canon/tasks/index.md"
46
+ */.canon/tasks/index.md | */.canon/tasks/archive/* | */.canon/tasks/declined/*) exit 0 ;;
47
+ */.claude/tasks/index.md | */.claude/tasks/archive/* | */.claude/tasks/declined/*) exit 0 ;;
48
+ */.canon/memory/index.md | */.canon/memory/review/* | */.canon/memory/archive/*) exit 0 ;;
49
+ */.claude/memory/index.md | */.claude/memory/review/* | */.claude/memory/archive/*) exit 0 ;;
50
+ */.canon/tasks/*.md)
51
+ folder=tasks
52
+ base=.canon
53
+ ;;
54
+ */.claude/tasks/*.md)
55
+ folder=tasks
56
+ base=.claude
57
57
  ;;
58
- *)
59
- root="${file_path%/.claude/tasks/*}"
60
- index=".claude/tasks/index.md"
58
+ */.canon/memory/*.md)
59
+ folder=memory
60
+ base=.canon
61
61
  ;;
62
+ */.claude/memory/*.md)
63
+ folder=memory
64
+ base=.claude
65
+ ;;
66
+ *) exit 0 ;;
62
67
  esac
68
+ root="${file_path%/"$base/$folder"/*}"
69
+ index="$base/$folder/index.md"
63
70
  [ -n "$root" ] || exit 0
64
71
 
65
72
  # Report a missing CLI rather than exiting quietly. The path guard above already
66
- # scopes this to a task-file edit, so the message only fires where the stale
73
+ # scopes this to a record-file edit, so the message only fires where the stale
67
74
  # index it warns about is the actual outcome.
68
75
  if ! command -v canon >/dev/null 2>&1; then
69
76
  msg="canon is not on PATH, so $index was not regenerated and is now stale. Install the toolkit CLI or run canon indexes regen by hand."
@@ -73,18 +80,18 @@ if ! command -v canon >/dev/null 2>&1; then
73
80
  fi
74
81
 
75
82
  # `--no-stage` because a hook has no business touching the index. On a project
76
- # whose board is not gitignored, the default auto-stage would silently add task
77
- # files to whatever commit is being assembled.
83
+ # whose folder is not gitignored, the default auto-stage would silently add
84
+ # record files to whatever commit is being assembled.
78
85
  output=$(canon indexes regen --no-stage --root "$root" "$file_path" 2>&1) && exit 0
79
86
 
80
- # Regen failed, which on this folder means a task file is missing `title` or
87
+ # Regen failed, which on these folders means a file is missing `title` or
81
88
  # `description`. Report it. Nothing else can: the folder is gitignored, so the
82
89
  # whole-repo walk never reaches it and no gate stage will ever fail on a stale
83
90
  # index. Staying quiet here is what makes the drift permanent.
84
91
  errors=$(printf '%s\n' "$output" | grep '^ERROR: ' | head -5)
85
92
  [ -n "$errors" ] || errors="$output"
86
93
 
87
- msg="Task index regen failed, so $index is now stale. Fix the frontmatter and save again. $errors"
94
+ msg="The $folder index regen failed, so $index is now stale. Fix the frontmatter and save again. $errors"
88
95
  jq -nc --arg msg "$msg" \
89
96
  '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
90
97
  exit 0
@@ -39,11 +39,7 @@
39
39
  },
40
40
  {
41
41
  "type": "command",
42
- "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tasks-index.sh"
43
- },
44
- {
45
- "type": "command",
46
- "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/memory-index.sh"
42
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/records-index.sh"
47
43
  },
48
44
  {
49
45
  "type": "command",
@@ -4,7 +4,7 @@ Authoring guidance: the architecture standard.
4
4
 
5
5
  ## Overview
6
6
 
7
- This record holds at most 12 decisions.
7
+ This record holds at most 12 decisions, at most 150 words a decision, and at most 6 risk bullets.
8
8
 
9
9
  ## Key technical decisions
10
10
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  Authoring guidance: the requirements standard.
4
4
 
5
+ This record holds at most 600 words.
6
+
5
7
  ## Problem
6
8
 
7
9
  ## Goals
@@ -1,95 +0,0 @@
1
- #!/usr/bin/env bash
2
-
3
- # Regenerates .claude/memory/index.md after a memory file changes.
4
- #
5
- # The memory folder is gitignored, so the whole-repo walk in `bun run check`
6
- # drops it and never regenerates this index. Naming the file as a positional
7
- # argument to `canon indexes regen` below bypasses that filter, which makes this
8
- # hook the only trigger that reaches the folder.
9
-
10
- # Claude Code sends a payload and closes stdin. A bare read with nothing feeding
11
- # it blocks forever and holds the session open, so the read is bounded. `read`
12
- # rather than `timeout cat`, which macOS does not ship.
13
- IFS= read -r -d '' -t 2 input
14
- [ -n "$input" ] || {
15
- printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
16
- exit 1
17
- }
18
-
19
- tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
20
- case "$tool" in
21
- Write | Edit | MultiEdit) ;;
22
- *) exit 0 ;;
23
- esac
24
-
25
- file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
26
- [ -n "$file_path" ] || exit 0
27
-
28
- # Both record roots, since a project the move has reached keeps its memory pen
29
- # under `.canon/` and a guard fixed at the old spelling stops matching with
30
- # nothing said. The index then goes stale while every save reports success.
31
- case "$file_path" in
32
- */.claude/memory/*.md | */.canon/memory/*.md) ;;
33
- *) exit 0 ;;
34
- esac
35
-
36
- # A shell `case` `*` crosses `/`, so the match above also catches a receipt
37
- # under memory/review/ or a retired entry under memory/archive/. Neither is
38
- # a pen entry the index renders, so both exit here before the regen call.
39
- case "$file_path" in
40
- */.claude/memory/review/* | */.canon/memory/review/* | \
41
- */.claude/memory/archive/* | */.canon/memory/archive/*)
42
- exit 0
43
- ;;
44
- esac
45
-
46
- case "$file_path" in
47
- */.claude/memory/index.md | */.canon/memory/index.md) exit 0 ;;
48
- esac
49
-
50
- # The walk-up boundary has to come from the path, not from the session. Shared
51
- # scratch resolves at the main worktree root, so a session inside a linked
52
- # worktree passes a path that sits outside its own project directory and the
53
- # default boundary would reject it.
54
- #
55
- # The index this would have rebuilt is read out of the same branch, so both
56
- # messages below name the file that actually went stale rather than one root's
57
- # spelling of it.
58
- case "$file_path" in
59
- */.canon/memory/*)
60
- root="${file_path%/.canon/memory/*}"
61
- index=".canon/memory/index.md"
62
- ;;
63
- *)
64
- root="${file_path%/.claude/memory/*}"
65
- index=".claude/memory/index.md"
66
- ;;
67
- esac
68
- [ -n "$root" ] || exit 0
69
-
70
- # Report a missing CLI rather than exiting quietly. The path guard above already
71
- # scopes this to a memory-file edit, so the message only fires where the stale
72
- # index it warns about is the actual outcome.
73
- if ! command -v canon >/dev/null 2>&1; then
74
- msg="canon is not on PATH, so $index was not regenerated and is now stale. Install the toolkit CLI or run canon indexes regen by hand."
75
- jq -nc --arg msg "$msg" \
76
- '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
77
- exit 0
78
- fi
79
-
80
- # `--no-stage` because a hook has no business touching the index. On a project
81
- # whose memory folder is not gitignored, the default auto-stage would silently
82
- # add memory files to whatever commit is being assembled.
83
- output=$(canon indexes regen --no-stage --root "$root" "$file_path" 2>&1) && exit 0
84
-
85
- # Regen failed, which on this folder means a memory file is missing `title` or
86
- # `description`. Report it. Nothing else can: the folder is gitignored, so the
87
- # whole-repo walk never reaches it and no gate stage will ever fail on a stale
88
- # index. Staying quiet here is what makes the drift permanent.
89
- errors=$(printf '%s\n' "$output" | grep '^ERROR: ' | head -5)
90
- [ -n "$errors" ] || errors="$output"
91
-
92
- msg="Memory index regen failed, so $index is now stale. Fix the frontmatter and save again. $errors"
93
- jq -nc --arg msg "$msg" \
94
- '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
95
- exit 0