@erclx/canon 5.22.0 → 5.24.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 (44) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-operator/REQUIREMENT.md +1 -0
  3. package/claude/skills/canon-operator/SKILL.md +5 -1
  4. package/claude/skills/draft-doc/REQUIREMENT.md +1 -0
  5. package/claude/skills/draft-doc/references/readme/agent-facing.md +59 -0
  6. package/claude/skills/draft-doc/references/readme/application.md +66 -0
  7. package/claude/skills/draft-doc/references/readme/cli.md +64 -0
  8. package/claude/skills/draft-doc/references/readme/library.md +57 -0
  9. package/claude/skills/draft-doc/references/readme/plugin.md +66 -0
  10. package/claude/skills/draft-doc/references/readme.md +33 -4
  11. package/claude/skills/target-check/SKILL.md +9 -9
  12. package/claude/skills/target-check/references/domains.md +11 -5
  13. package/docs/agents/architecture-record.md +2 -2
  14. package/docs/agents/canvas.md +5 -1
  15. package/docs/agents/index.md +1 -1
  16. package/docs/agents/tasks-validate.md +4 -2
  17. package/docs/agents/tasks.md +1 -1
  18. package/docs/target/projects.md +1 -1
  19. package/docs/workflow/visual-design-workflow.md +1 -1
  20. package/package.json +1 -1
  21. package/src/canvas/client/inspector.tsx +46 -4
  22. package/src/canvas/client/shell.css +7 -0
  23. package/src/canvas/tokens.ts +9 -1
  24. package/src/commands/canvas.ts +29 -0
  25. package/src/commands/tasks.ts +16 -0
  26. package/src/design/budget.ts +149 -0
  27. package/src/design/document.ts +22 -10
  28. package/src/design/render.ts +11 -1
  29. package/src/design/tokens.ts +40 -61
  30. package/src/gate/measures.ts +49 -0
  31. package/src/gate/stages.ts +5 -1
  32. package/src/tasks/validate.ts +65 -0
  33. package/standards/architecture.md +2 -2
  34. package/standards/board.md +2 -0
  35. package/standards/design.md +13 -3
  36. package/standards/requirements.md +3 -1
  37. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -2
  38. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
  39. package/tooling/claude/seeds/.claude/hooks/{tasks-index.sh → records-index.sh} +42 -35
  40. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
  41. package/tooling/claude/seeds/.claude/settings.json +1 -5
  42. package/tooling/claude/seeds/canon/ARCHITECTURE.md +1 -1
  43. package/tooling/claude/seeds/canon/REQUIREMENTS.md +2 -0
  44. package/tooling/claude/seeds/.claude/hooks/memory-index.sh +0 -95
@@ -10,6 +10,7 @@ import {
10
10
  auditSet,
11
11
  captureStamps,
12
12
  clientCommandCitations,
13
+ designProse,
13
14
  documentCeiling,
14
15
  folderEchoedNames,
15
16
  markdownBans,
@@ -234,7 +235,9 @@ export const STAGES: readonly Stage[] = [
234
235
  // from `src/design/tokens.ts`, the base stylesheet from
235
236
  // `src/design/neutral.ts`, and none is edited by hand. Four artifacts
236
237
  // from two token sources is the cost of the token move, and a render step that has to run is only safe while something fails
237
- // when it did not, which is this.
238
+ // when it did not, which is this. The record and the worked example are
239
+ // then counted against the prose budget, which the drift asserts cannot
240
+ // see since a regenerated paragraph agrees with its source.
238
241
  id: 'design',
239
242
  label: 'Design',
240
243
  checks: [
@@ -277,6 +280,7 @@ export const STAGES: readonly Stage[] = [
277
280
  failure:
278
281
  'The tab icon drifted from the token source. Run bun run check and commit web/public/favicon.svg.',
279
282
  },
283
+ { kind: 'measure', measure: designProse },
280
284
  ],
281
285
  success: 'Design source clean',
282
286
  },
@@ -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.
@@ -33,10 +33,20 @@ Does not govern:
33
33
 
34
34
  ## Format
35
35
 
36
- - Use tables for token systems, one row per token. Use short bullets for component rules, one decision per line.
36
+ - Use tables for token systems, one row per token. Write every other rule as a short bullet, one decision per line, within `## Prose budget`.
37
37
  - Plain English over technical notation. If a section could be removed and the developer would still build correctly from wireframes and code alone, remove it.
38
38
  - Keep table headers and role names intact so the render tooling can parse the token tables.
39
39
 
40
+ ## Prose budget
41
+
42
+ The record is a lookup, so its prose stays short enough to scan beside the tables. Why a value was chosen belongs in the project's context entry for design, never here.
43
+
44
+ - Personality: one paragraph of at most 60 words
45
+ - Any other section: at most 3 rules beside its table, each at most 25 words. A bullet is one rule, and so is a paragraph.
46
+ - The lead before the first section: at most 40 words
47
+
48
+ Headings and table rows are never counted, and a section holding a table and no rules is within budget. A bullet wrapping over several lines counts as one rule, and a nested bullet counts as its own.
49
+
40
50
  ## The uncertainty tag
41
51
 
42
52
  A cell no source anchors ends in ` ? verify`, written inside the cell rather than as a trailing column, since a trailing marker breaks the table parse. A cell wrapping itself in a code span carries the tag inside the span, as in `` `#ffffff ? verify` ``. Both spellings parse.
@@ -93,9 +103,9 @@ The column headers are the strings the renderer parses, read by exact key, so th
93
103
 
94
104
  ## Motion
95
105
 
96
- <whether motion is used at all, and if so the default duration and easing>
106
+ - <whether motion is used at all, and if so the default duration and easing>
97
107
 
98
108
  ## Iconography
99
109
 
100
- <style, source library, and whether custom icons are allowed>
110
+ - <style, source library, and whether custom icons are allowed>
101
111
  ```
@@ -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
@@ -57,5 +57,5 @@ marker="$marker_dir/$key"
57
57
  mkdir -p "$marker_dir"
58
58
  : >"$marker"
59
59
 
60
- msg='Temporary file write outside the project scratch folder. Write temp files to .canon/tmp/<slug>/ in the project root, or .claude/.tmp/<slug>/ where the project carries no .canon/ root, not system temp. See the Scratch rule (604-scratch, under canon/ in your installed governance rules).'
60
+ msg='Temporary file write outside the project scratch folder. Write temp files to .canon/tmp/<slug>/ in the project root, not system temp. See the Scratch rule (604-scratch, under canon/ in your installed governance rules).'
61
61
  jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'
@@ -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