@erclx/canon 5.74.0 → 5.75.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 (42) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-rule/SKILL.md +5 -1
  4. package/claude/skills/create-rule/references/ledger.md +73 -0
  5. package/claude/skills/create-rule/references/rule.md +1 -1
  6. package/claude/skills/create-skill/references/skill-practice.md +1 -1
  7. package/claude/skills/pr-evidence/references/reel.md +9 -9
  8. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  9. package/claude/skills/ui-film/SKILL.md +8 -7
  10. package/docs/agents/audits.md +24 -24
  11. package/docs/agents/migrate.md +10 -10
  12. package/docs/migrations/by-verb.md +2 -0
  13. package/governance/detect.toml +4 -0
  14. package/governance/ledger/claude/rule.md +16 -0
  15. package/governance/rules/claude/rule.md +13 -0
  16. package/package.json +1 -1
  17. package/scripts/ci/refresh-frames-pr.sh +11 -12
  18. package/src/audits/catalog.ts +7 -2
  19. package/src/capture/stamp.ts +3 -5
  20. package/src/claude/skills-audit.ts +65 -0
  21. package/src/cli/help.ts +1 -1
  22. package/src/commands/claude.ts +6 -2
  23. package/src/commands/gov.ts +12 -7
  24. package/src/commands/migrate.ts +6 -2
  25. package/src/commands/take.ts +36 -7
  26. package/src/counts/catalogs.ts +3 -5
  27. package/src/gate/measures.ts +1 -144
  28. package/src/gate/stages.ts +0 -23
  29. package/src/gov/audit.ts +59 -13
  30. package/src/markdown/scope.ts +2 -0
  31. package/src/migrate/session-defaults.ts +114 -4
  32. package/src/take/encode.ts +9 -1
  33. package/src/take/film.ts +17 -2
  34. package/src/take/fixtures/busy.html +107 -0
  35. package/src/take/perform.ts +99 -25
  36. package/src/take/screencast.ts +4 -3
  37. package/src/take/stepper.ts +33 -20
  38. package/tooling/claude/reference.md +4 -2
  39. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  40. package/tooling/claude/seeds/.claude/settings.json +8 -0
  41. package/scripts/core/regen-hero.ts +0 -113
  42. package/src/capture/frame-values.ts +0 -326
@@ -68,14 +68,16 @@ A project installed before the board became a folder still holds `.claude/TASKS.
68
68
 
69
69
  ## settings.json
70
70
 
71
- - Pair the seed's three PreToolUse hooks with `index-reminder.sh`, `search-reminder.sh`, and `scratch-guard.sh`, and its PostToolUse hook with `standards-audit.sh`, all under `.claude/hooks/`
71
+ - Pair the seed's PreToolUse hooks with `index-reminder.sh`, `search-reminder.sh`, `scratch-guard.sh`, `review-post-scan.sh`, and `plan-guard.sh`, and its PostToolUse hook with `standards-audit.sh`
72
72
  - Keep only what is project-specific in the project file, since project settings layer over user settings
73
73
  - Expect `permissions.deny` to hold `Agent` and `Task`, so no session spawns a subagent and parallel work runs as separate background sessions
74
74
  - Expect `autoMemoryEnabled` to be `false`. A person turns it back on for themselves in `settings.local.json`.
75
- - Run `canon migrate session-defaults` on a target seeded before those two defaults or before the merge entries below, since sync never rewrites an existing settings file
75
+ - Run `canon migrate session-defaults` on a target seeded before those defaults, the merge entries, or the plan guard below, since sync never rewrites an existing settings file
76
76
 
77
77
  The seed's deny list also holds `Bash(canon pr merge:*)` and `Bash(gh pr merge:*)`, so the operator alone merges. An existing target gets both from `canon migrate session-defaults --write`, and `canon claude sync` warns while either is absent. Neither covers more than the form an agent writes by default.
78
78
 
79
+ `plan-guard.sh` blocks a new plan under `.canon/plans/` until `plan-feature` is invoked for it, since a heredoc never loads the plan rule. It passes when the transcript holds no assistant tool call it can parse, though a format change confined to the Skill call still blocks. It logs each decision under `.canon/tmp/hooks/plan-guard/`.
80
+
79
81
  ## User settings
80
82
 
81
83
  - `canon claude setup` installs `tooling/claude/user/settings.template.json` once per machine
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # A path-scoped rule loads on a Read, Write, or Edit of a matching file, and a
4
+ # heredoc onto `.canon/plans/` is none of them, so the plan rule never reaches
5
+ # the session writing most plans. This guard is the check behind that rule: a
6
+ # new plan passes only when the transcript shows a `canon:plan-feature`
7
+ # invocation this session has not already spent on an earlier new plan, or an
8
+ # `auto-ship` invocation naming that plan's path. It
9
+ # backs the rule up rather than replacing it, since a hook fails open on a
10
+ # timeout and on anything below it cannot read.
11
+ IFS= read -r -d '' -t 2 input
12
+ [ -n "$input" ] || {
13
+ printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
14
+ exit 1
15
+ }
16
+
17
+ # Bash is the highest-frequency tool, so the one field a write names is lifted
18
+ # out of the raw payload and tested with a shell pattern before jq starts.
19
+ field_re='"(command|file_path)":"(([^"\\]|\\.)*)"'
20
+ [[ $input =~ $field_re ]] || exit 0
21
+ case "${BASH_REMATCH[2]}" in
22
+ *.canon/plans/*) ;;
23
+ *) exit 0 ;;
24
+ esac
25
+
26
+ target=$(printf '%s' "$input" | jq -r '
27
+ if .tool_name == "Write" then "F" + .tool_input.file_path
28
+ elif .tool_name == "Bash" then "B" + .tool_input.command
29
+ else empty end // empty')
30
+ [ -n "$target" ] || exit 0
31
+ kind=${target:0:1}
32
+ target=${target:1}
33
+
34
+ # A command that only mentions the folder is not a write to it, as with
35
+ # `canon tasks plan-link` or a heredoc body quoting a plan path. The awk pass
36
+ # reads the command as words, drops heredoc bodies, and prints only the words a
37
+ # write lands on: the word after a `>` or `>>`, and every argument of `tee` or
38
+ # `cp`, whose source is an existing file and so never reads as a new plan. A
39
+ # quoted span is one word, so a redirect inside quotes is text. This is a
40
+ # scanner and not a shell parser, so a path built in a variable or written by
41
+ # `printf` into an interpreter passes unseen.
42
+ if [ "$kind" = B ]; then
43
+ IFS= read -r -d '' scanner <<'AWK'
44
+ { s = s (NR > 1 ? "\n" : "") $0 }
45
+ function flush() {
46
+ if (!inword) return
47
+ if (redirect) print word
48
+ else if (tag) { nt++; tags[nt] = word; dashes[nt] = dash }
49
+ else if (cmd == "") {
50
+ w = word; sub(/.*\//, "", w)
51
+ if (w !~ /^[A-Za-z_][A-Za-z0-9_]*=/ && w !~ /^(sudo|env|command|exec|time|nice|xargs)$/) cmd = w
52
+ }
53
+ else if ((cmd == "tee" || cmd == "cp") && word !~ /^-/) print word
54
+ word = ""; inword = 0; redirect = 0; tag = 0
55
+ }
56
+ END {
57
+ n = length(s); i = 1; q = ""; cmd = ""; nt = 0
58
+ while (i <= n) {
59
+ c = substr(s, i, 1)
60
+ if (q != "") {
61
+ if (c == q) q = ""
62
+ else if (q == "\"" && c == "\\") { word = word substr(s, i + 1, 1); i += 2; continue }
63
+ else word = word c
64
+ i++; continue
65
+ }
66
+ if (c == "'" || c == "\"") { q = c; inword = 1; i++; continue }
67
+ if (c == "\\") { word = word substr(s, i + 1, 1); inword = 1; i += 2; continue }
68
+ if (c == " " || c == "\t") { flush(); i++; continue }
69
+ if (c == ">") {
70
+ flush(); i++
71
+ if (substr(s, i, 1) == ">" || substr(s, i, 1) == "|") i++
72
+ if (substr(s, i, 1) == "&") { i++; continue }
73
+ redirect = 1; continue
74
+ }
75
+ if (c == "<" && substr(s, i, 2) == "<<" && substr(s, i, 3) != "<<<") {
76
+ flush(); i += 2; dash = 0
77
+ if (substr(s, i, 1) == "-") { dash = 1; i++ }
78
+ tag = 1; continue
79
+ }
80
+ if (c ~ /[;|&()<`]/) { flush(); if (c != "<") cmd = ""; i++; continue }
81
+ if (c == "\n") {
82
+ flush(); cmd = ""; i++
83
+ for (t = 1; t <= nt; t++) {
84
+ while (i <= n) {
85
+ e = index(substr(s, i), "\n")
86
+ if (e == 0) { line = substr(s, i); i = n + 1 } else { line = substr(s, i, e - 1); i += e }
87
+ if (dashes[t]) sub(/^\t+/, "", line)
88
+ if (line == tags[t]) break
89
+ }
90
+ }
91
+ nt = 0; continue
92
+ }
93
+ word = word c; inword = 1; i++
94
+ }
95
+ flush()
96
+ }
97
+ AWK
98
+ targets=$(printf '%s' "$target" | awk "$scanner")
99
+ else
100
+ targets=$target
101
+ fi
102
+ [ -n "$targets" ] || exit 0
103
+
104
+ payload_cwd=$(printf '%s' "$input" | jq -r '.cwd // empty')
105
+ cwd=${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}
106
+ # Plans live at the main worktree root, which a linked worktree's cwd is not.
107
+ root=$(git -C "$cwd" worktree list --porcelain 2>/dev/null | awk '/^worktree /{sub(/^worktree /, ""); print; exit}')
108
+ root=${root:-$cwd}
109
+
110
+ # A write onto an existing plan is an amendment, such as a worker rewriting a
111
+ # `- Suggested:` line, and an archive path holds a shipped plan rather than a
112
+ # new one, so only a path naming no file yet asks for the skill.
113
+ new=""
114
+ while IFS= read -r path; do
115
+ case "$path" in
116
+ *.canon/plans/*) ;;
117
+ *) continue ;;
118
+ esac
119
+ rest=${path##*.canon/plans/}
120
+ case "$rest" in
121
+ '' | archive/* | *'$'* | *'*'*) continue ;;
122
+ *.md) ;;
123
+ *) continue ;;
124
+ esac
125
+ [ -e "$root/.canon/plans/$rest" ] && continue
126
+ case "$path" in
127
+ /*) [ -e "$path" ] && continue ;;
128
+ esac
129
+ new=".canon/plans/$rest"
130
+ break
131
+ done <<EOF
132
+ $targets
133
+ EOF
134
+ [ -n "$new" ] || exit 0
135
+
136
+ # Without a session id or a readable transcript there is nothing to read the
137
+ # invocation from, and blocking on that would stop every plan, so pass.
138
+ meta=$(printf '%s' "$input" | jq -r '[.session_id // "", .transcript_path // ""] | @tsv')
139
+ session=${meta%%$'\t'*}
140
+ transcript=${meta#*$'\t'}
141
+ [ -n "$session" ] && [ -n "$transcript" ] && [ -r "$transcript" ] || exit 0
142
+
143
+ # An invocation is a Skill tool call naming the skill, or the operator typing
144
+ # its slash command, whose user line carries the command-name tag. Each one
145
+ # prints as `P <line>`, the line ordering it against the invocation the last
146
+ # new plan spent. An `auto-ship` invocation prints as `A <its arguments>`,
147
+ # since a cloud worker writes the approved plan that launch names verbatim and
148
+ # never plans it, so a write onto the path those arguments name passes.
149
+ invocations=$(grep -n -e 'plan-feature' -e 'auto-ship' "$transcript" 2>/dev/null | jq -Rr '
150
+ def text: if type == "string" then . else (map(select(.type == "text") | .text) | join("\n")) end;
151
+ def skill($name): .type == "tool_use" and .name == "Skill"
152
+ and ((.input.skill // "") | test("^(canon:)?" + $name + "$"));
153
+ def typed($name): test("<command-name>/?(canon:)?" + $name + "</command-name>");
154
+ capture("^(?<n>[0-9]+):(?<j>.*)$") as $l
155
+ | ($l.j | fromjson? // empty) as $o
156
+ | if $o.type == "assistant" then
157
+ ($o.message.content[]? | select(type == "object")
158
+ | if skill("plan-feature") then "P \($l.n)"
159
+ elif skill("auto-ship") then "A \(.input.args // "" | tostring)"
160
+ else empty end)
161
+ elif $o.type == "user" then
162
+ (($o.message.content // "") | text)
163
+ | if typed("plan-feature") then "P \($l.n)"
164
+ elif typed("auto-ship") then "A \(gsub("\n"; " "))"
165
+ else empty end
166
+ else empty end' 2>/dev/null)
167
+
168
+ rest=${new#.canon/plans/}
169
+ key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9' '_')
170
+ marker_dir="$root/.canon/tmp/hooks/plan-guard"
171
+
172
+ # One line per new plan judged, so a wrong block in any project shows up beside
173
+ # the passes and the unread ones. A failed write never changes the decision.
174
+ log_decision() {
175
+ # The group takes the redirect's own error, which a `2>/dev/null` after the
176
+ # `>>` would miss because the shell opens the file first.
177
+ mkdir -p "$marker_dir" 2>/dev/null && {
178
+ printf '%s %s %s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$key" "$rest" \
179
+ >>"$marker_dir/decisions.log"
180
+ } 2>/dev/null
181
+ return 0
182
+ }
183
+
184
+ while IFS= read -r line; do
185
+ case "$line" in
186
+ "A "*".canon/plans/$rest"*)
187
+ log_decision pass
188
+ exit 0
189
+ ;;
190
+ esac
191
+ done <<EOF
192
+ $invocations
193
+ EOF
194
+ latest=$(printf '%s\n' "$invocations" | awk '/^P /{n = $2} END {if (n != "") print n}')
195
+
196
+ # The marker holds the line of the spent invocation and the plan it licensed.
197
+ # A PreToolUse pass is not a landed write, so a retry after a refused heredoc,
198
+ # or the `cp` that follows a worktree `Write`, lands on the same plan and
199
+ # passes on the invocation it already spent.
200
+ marker="$marker_dir/$key"
201
+ spent=0
202
+ spent_plan=""
203
+ if [ -f "$marker" ]; then
204
+ IFS=' ' read -r spent spent_plan <"$marker" 2>/dev/null
205
+ fi
206
+ case "$spent" in '' | *[!0-9]*) spent=0 ;; esac
207
+
208
+ if [ "$spent" -gt 0 ] && [ "$spent_plan" = "$rest" ]; then
209
+ log_decision pass
210
+ exit 0
211
+ fi
212
+ if [ -n "$latest" ] && [ "$latest" -gt "$spent" ]; then
213
+ mkdir -p "$marker_dir" 2>/dev/null && printf '%s %s\n' "$latest" "$rest" >"$marker" 2>/dev/null
214
+ log_decision pass
215
+ exit 0
216
+ fi
217
+
218
+ # A transcript holding no assistant `tool_use` line means Claude Code changed
219
+ # its message format rather than that the session skipped the skill, and
220
+ # blocking on that would stop every plan, so pass and log it apart. A change
221
+ # confined to the Skill call's input or the typed command tag leaves other
222
+ # tool calls readable, so this does not catch it and the guard still blocks.
223
+ calls=$(grep -F '"tool_use"' "$transcript" 2>/dev/null | jq -Rrn '
224
+ first(inputs | fromjson? | select(type == "object" and .type == "assistant")
225
+ | .message.content[]? | select(type == "object" and .type == "tool_use"
226
+ and (.name | type) == "string") | "read") // empty' 2>/dev/null)
227
+ if [ -z "$calls" ]; then
228
+ log_decision unread
229
+ exit 0
230
+ fi
231
+
232
+ if [ "$spent" -gt 0 ]; then
233
+ why='The last canon:plan-feature invocation in this session was spent on an earlier new plan'
234
+ else
235
+ why='This session has not invoked canon:plan-feature'
236
+ fi
237
+ log_decision block
238
+ printf '%s, so %s would be written outside the skill that owns its shape. Invoke the canon:plan-feature skill for this plan, then resend the write.\n' "$why" "$new" >&2
239
+ exit 2
@@ -29,6 +29,10 @@
29
29
  {
30
30
  "type": "command",
31
31
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scratch-guard.sh"
32
+ },
33
+ {
34
+ "type": "command",
35
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-guard.sh"
32
36
  }
33
37
  ]
34
38
  },
@@ -38,6 +42,10 @@
38
42
  {
39
43
  "type": "command",
40
44
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/review-post-scan.sh"
45
+ },
46
+ {
47
+ "type": "command",
48
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-guard.sh"
41
49
  }
42
50
  ]
43
51
  }
@@ -1,113 +0,0 @@
1
- /**
2
- * Fills every assets/captures/*.html.tmpl from the catalogs and the design
3
- * source, writing the .html beside each one.
4
- *
5
- * The name says hero because the hero was the only template when it was written
6
- * and every citation of it across docs/, canon/context/, and src/ spells that
7
- * name. The loop below covers whatever templates the folder holds.
8
- *
9
- * Each catalog is read through the module its `canon` verb calls, so no child
10
- * process runs and no stderr is discarded. The palette arrives from
11
- * `buildDesignCss` without the component half, which is a scrollbar and a
12
- * status marker a static capture frame renders neither of. The value builder
13
- * and every guard live in `src/capture/frame-values.ts`, where tsc and vitest
14
- * reach them.
15
- *
16
- * `--check` fills every template into a temp folder and discards it, so a
17
- * renamed featured skill, an empty catalog, or an unresolved placeholder still
18
- * fails a branch while no frame file changes. Frames are refreshed after a
19
- * merge rather than committed by each branch.
20
- *
21
- * Only the HTML regenerates here. The PNG beside it is a chromium render whose
22
- * bytes move with the browser version. Rebuild the images with
23
- * `canon capture assets/captures --selector .window --out assets/frames`
24
- * after this script reports a change. That capture writes a .stamp beside each
25
- * PNG, which records the digest of the markup it rendered and is what the Hero
26
- * stage compares, so a frame's three files commit together. The frame carries
27
- * no version, since `package.json` is bumped on main by the release tooling and
28
- * embedding it would drift every open branch on the next release.
29
- *
30
- * Clone-only. This script reads the repository's own catalogs and
31
- * `assets/captures`, which a registry install does not carry.
32
- */
33
- import {
34
- mkdirSync,
35
- mkdtempSync,
36
- readdirSync,
37
- readFileSync,
38
- rmSync,
39
- writeFileSync,
40
- } from 'node:fs'
41
- import { tmpdir } from 'node:os'
42
- import { join } from 'node:path'
43
-
44
- import {
45
- buildFrameValues,
46
- FrameError,
47
- fillTemplate,
48
- } from '../../src/capture/frame-values'
49
- import { listSkills } from '../../src/claude/skills-list'
50
- import { countCommands } from '../../src/counts/catalogs'
51
- import { buildDesignCss } from '../../src/design/css'
52
- import { buildGovCatalog } from '../../src/gov/list'
53
- import { buildStackSummaries } from '../../src/tooling/list'
54
-
55
- const root = new URL('../..', import.meta.url).pathname
56
- const assetDir = join(root, 'assets', 'captures')
57
-
58
- const [flag, ...extra] = process.argv.slice(2)
59
- if (flag !== undefined && flag !== '--check') {
60
- console.error(`regen-hero: unknown argument ${flag}`)
61
- process.exit(1)
62
- }
63
- if (extra.length > 0) {
64
- console.error(`regen-hero: unknown argument ${extra[0]}`)
65
- process.exit(1)
66
- }
67
-
68
- const outDir =
69
- flag === '--check' ? mkdtempSync(join(tmpdir(), 'regen-hero-')) : assetDir
70
-
71
- function run(): void {
72
- const templates = readdirSync(assetDir)
73
- .filter((name) => name.endsWith('.html.tmpl'))
74
- .sort()
75
- if (templates.length === 0) {
76
- throw new FrameError(`no templates under ${assetDir}`)
77
- }
78
-
79
- const tokenCss = buildDesignCss(undefined, { components: false })
80
- if (!tokenCss.trim()) {
81
- throw new FrameError(
82
- 'the design source emitted nothing, refusing to write an unstyled frame',
83
- )
84
- }
85
-
86
- const { values, frames } = buildFrameValues({
87
- skills: listSkills(root).map((entry) => entry.name),
88
- gov: buildGovCatalog(root),
89
- toolingStacks: buildStackSummaries(root),
90
- commandCount: countCommands(root) ?? 0,
91
- tokenCss,
92
- markSvg: readFileSync(join(root, 'assets', 'brand', 'mark.svg'), 'utf8'),
93
- })
94
-
95
- mkdirSync(outDir, { recursive: true })
96
- for (const { template, out, tokens } of frames(templates)) {
97
- const source = readFileSync(join(assetDir, template), 'utf8')
98
- writeFileSync(
99
- join(outDir, out),
100
- fillTemplate(template, source, values, tokens),
101
- )
102
- }
103
- }
104
-
105
- try {
106
- run()
107
- } catch (error) {
108
- if (!(error instanceof FrameError)) throw error
109
- console.error(`regen-hero: ${error.message}`)
110
- process.exitCode = 1
111
- } finally {
112
- if (outDir !== assetDir) rmSync(outDir, { recursive: true, force: true })
113
- }