@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-operator/REQUIREMENT.md +1 -0
- package/claude/skills/canon-operator/SKILL.md +5 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +1 -0
- package/claude/skills/draft-doc/references/readme/agent-facing.md +59 -0
- package/claude/skills/draft-doc/references/readme/application.md +66 -0
- package/claude/skills/draft-doc/references/readme/cli.md +64 -0
- package/claude/skills/draft-doc/references/readme/library.md +57 -0
- package/claude/skills/draft-doc/references/readme/plugin.md +66 -0
- package/claude/skills/draft-doc/references/readme.md +33 -4
- package/claude/skills/target-check/SKILL.md +9 -9
- package/claude/skills/target-check/references/domains.md +11 -5
- package/docs/agents/architecture-record.md +2 -2
- package/docs/agents/canvas.md +5 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks-validate.md +4 -2
- package/docs/agents/tasks.md +1 -1
- package/docs/target/projects.md +1 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/package.json +1 -1
- package/src/canvas/client/inspector.tsx +46 -4
- package/src/canvas/client/shell.css +7 -0
- package/src/canvas/tokens.ts +9 -1
- package/src/commands/canvas.ts +29 -0
- package/src/commands/tasks.ts +16 -0
- package/src/design/budget.ts +149 -0
- package/src/design/document.ts +22 -10
- package/src/design/render.ts +11 -1
- package/src/design/tokens.ts +40 -61
- package/src/gate/measures.ts +49 -0
- package/src/gate/stages.ts +5 -1
- package/src/tasks/validate.ts +65 -0
- package/standards/architecture.md +2 -2
- package/standards/board.md +2 -0
- package/standards/design.md +13 -3
- package/standards/requirements.md +3 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/{tasks-index.sh → records-index.sh} +42 -35
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -5
- package/tooling/claude/seeds/canon/ARCHITECTURE.md +1 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +2 -0
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +0 -95
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
},
|
package/src/tasks/validate.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/standards/board.md
CHANGED
|
@@ -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.
|
package/standards/design.md
CHANGED
|
@@ -33,10 +33,20 @@ Does not govern:
|
|
|
33
33
|
|
|
34
34
|
## Format
|
|
35
35
|
|
|
36
|
-
- Use tables for token systems, one row per token.
|
|
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.
|
|
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 `
|
|
16
|
-
#
|
|
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
|
|
43
|
-
#
|
|
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
|
|
3
|
+
# Regenerates the index.md of a gitignored record folder, `tasks` or `memory`,
|
|
4
|
+
# after a file in it changes.
|
|
4
5
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
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,
|
|
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/
|
|
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",
|
|
@@ -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
|