@erclx/canon 5.74.0 → 5.76.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/CHANGELOG.md +29 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-rule/SKILL.md +5 -1
- package/claude/skills/create-rule/references/ledger.md +73 -0
- package/claude/skills/create-rule/references/rule.md +1 -1
- package/claude/skills/create-skill/references/skill-practice.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +1 -1
- package/claude/skills/design-identity/SKILL.md +13 -4
- package/claude/skills/design-identity/references/card-route.md +11 -4
- package/claude/skills/git-issue/REQUIREMENT.md +4 -2
- package/claude/skills/git-issue/SKILL.md +32 -3
- package/claude/skills/git-issue/references/issue.md +7 -0
- package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
- package/claude/skills/issue-triage/SKILL.md +130 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -9
- package/claude/skills/proposal-critique/references/adopted.md +44 -0
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
- package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
- package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
- package/claude/skills/ui-film/SKILL.md +8 -7
- package/docs/agents/audits.md +24 -24
- package/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +10 -4
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +4 -0
- package/governance/ledger/claude/rule.md +16 -0
- package/governance/rules/claude/rule.md +13 -0
- package/package.json +1 -1
- package/scripts/ci/refresh-frames-pr.sh +11 -12
- package/src/audits/catalog.ts +7 -2
- package/src/capture/render.ts +30 -5
- package/src/capture/stamp.ts +3 -5
- package/src/claude/cases/git.ts +5 -0
- package/src/claude/cases/workflow.ts +4 -0
- package/src/claude/skills-audit.ts +65 -0
- package/src/cli/help.ts +1 -1
- package/src/commands/capture.ts +38 -1
- package/src/commands/claude.ts +10 -2
- package/src/commands/gov.ts +12 -7
- package/src/commands/migrate.ts +6 -2
- package/src/commands/take.ts +36 -7
- package/src/counts/catalogs.ts +3 -5
- package/src/gate/measures.ts +1 -144
- package/src/gate/stages.ts +0 -23
- package/src/gov/audit.ts +59 -13
- package/src/markdown/scope.ts +4 -0
- package/src/migrate/session-defaults.ts +146 -8
- package/src/take/encode.ts +9 -1
- package/src/take/film.ts +17 -2
- package/src/take/fixtures/busy.html +107 -0
- package/src/take/perform.ts +99 -25
- package/src/take/screencast.ts +4 -3
- package/src/take/stepper.ts +33 -20
- package/tooling/claude/reference.md +4 -2
- package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
- package/tooling/claude/seeds/.claude/settings.json +12 -0
- package/scripts/core/regen-hero.ts +0 -113
- package/src/capture/frame-values.ts +0 -326
package/src/take/stepper.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type KeyInput,
|
|
6
6
|
type PointerInput,
|
|
7
7
|
type Stage,
|
|
8
|
-
|
|
8
|
+
TargetRefused,
|
|
9
9
|
performSteps,
|
|
10
10
|
} from '@/take/perform'
|
|
11
11
|
import type { Beat } from '@/take/plan'
|
|
@@ -25,7 +25,10 @@ import { type ShellPage, openShell } from '@/take/shell'
|
|
|
25
25
|
* - Frame time continues Chromium's monotonic clock, since a frame stamped on
|
|
26
26
|
* any other base leaves every CSS animation at its first frame
|
|
27
27
|
* - No frame is begun before the load event, since one sent mid-navigation can
|
|
28
|
-
* go unanswered
|
|
28
|
+
* go unanswered
|
|
29
|
+
* - A frame is begun only once the one before it settles, since Chromium
|
|
30
|
+
* refuses a second while one is pending, and a frame left pending past the
|
|
31
|
+
* stall limit falls the beat back rather than hanging it
|
|
29
32
|
*/
|
|
30
33
|
|
|
31
34
|
const FRAME_MS = 1000 / FPS
|
|
@@ -41,7 +44,6 @@ export const HOLD_MS = 600
|
|
|
41
44
|
|
|
42
45
|
const LOAD_CAP_MS = 15_000
|
|
43
46
|
const SETTLE_MS = 2500
|
|
44
|
-
const LOADING_FRAME_CAP_MS = 250
|
|
45
47
|
|
|
46
48
|
/** How long a refusal waits on the frame it shows, since the page may be wedged. */
|
|
47
49
|
const STILL_CAP_MS = 2000
|
|
@@ -81,19 +83,25 @@ export async function filmStepped(
|
|
|
81
83
|
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
82
84
|
try {
|
|
83
85
|
let ticks = await load(page, request.url)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
let outstanding: Promise<unknown> = Promise.resolve()
|
|
87
|
+
const beginFrame = (screenshot: boolean) => {
|
|
88
|
+
const reply = outstanding.then(() =>
|
|
89
|
+
page.send<{ screenshotData?: string }>(
|
|
90
|
+
'HeadlessExperimental.beginFrame',
|
|
91
|
+
{
|
|
92
|
+
frameTimeTicks: (ticks += FRAME_MS),
|
|
93
|
+
interval: FRAME_MS,
|
|
94
|
+
...(screenshot
|
|
95
|
+
? { screenshot: { format: 'jpeg', quality: 95 } }
|
|
96
|
+
: {}),
|
|
97
|
+
},
|
|
98
|
+
),
|
|
94
99
|
)
|
|
100
|
+
outstanding = reply.catch(() => {})
|
|
101
|
+
return reply
|
|
102
|
+
}
|
|
95
103
|
still = () => frameStill(beginFrame)
|
|
96
|
-
await pump(beginFrame, SETTLE_MS)
|
|
104
|
+
await pump(beginFrame, SETTLE_MS, stallMs)
|
|
97
105
|
|
|
98
106
|
const live = liveStage(page, beginFrame, stallMs)
|
|
99
107
|
await performSteps(live, request.beat.setup, { pointer: START })
|
|
@@ -163,7 +171,7 @@ export async function filmStepped(
|
|
|
163
171
|
return { frames, events, still: last }
|
|
164
172
|
} catch (error) {
|
|
165
173
|
encoder.abort()
|
|
166
|
-
if (error instanceof
|
|
174
|
+
if (error instanceof TargetRefused && still) error.still = await still()
|
|
167
175
|
throw error
|
|
168
176
|
} finally {
|
|
169
177
|
page.close()
|
|
@@ -215,17 +223,22 @@ async function load(page: ShellPage, url: string): Promise<number> {
|
|
|
215
223
|
return timestamp
|
|
216
224
|
}
|
|
217
225
|
|
|
218
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* Begins frames in real time so loading, fonts, and layout settle. Each frame
|
|
228
|
+
* is awaited, so a slow page stretches the settle rather than overlapping it.
|
|
229
|
+
*/
|
|
219
230
|
async function pump(
|
|
220
231
|
beginFrame: (screenshot: boolean) => Promise<unknown>,
|
|
221
232
|
ms: number,
|
|
233
|
+
stallMs: number,
|
|
222
234
|
): Promise<void> {
|
|
223
235
|
const end = Date.now() + ms
|
|
224
236
|
while (Date.now() < end) {
|
|
225
|
-
await
|
|
237
|
+
await withinStall(
|
|
226
238
|
beginFrame(false).catch(() => {}),
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
0,
|
|
240
|
+
stallMs,
|
|
241
|
+
)
|
|
229
242
|
await sleep(12)
|
|
230
243
|
}
|
|
231
244
|
}
|
|
@@ -242,7 +255,7 @@ function liveStage(
|
|
|
242
255
|
const started = Date.now()
|
|
243
256
|
return {
|
|
244
257
|
now: () => Date.now() - started,
|
|
245
|
-
hold: (ms) => pump(beginFrame, ms),
|
|
258
|
+
hold: (ms) => pump(beginFrame, ms, stallMs),
|
|
246
259
|
pointer: (input) => {
|
|
247
260
|
const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
|
|
248
261
|
return withinStall(
|
|
@@ -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
|
|
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
|
|
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
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"autoMemoryEnabled": false,
|
|
3
3
|
"permissions": {
|
|
4
|
+
"allow": [
|
|
5
|
+
"Bash(git push --force-with-lease)",
|
|
6
|
+
"Bash(git push --force-with-lease origin HEAD)"
|
|
7
|
+
],
|
|
4
8
|
"deny": ["Agent", "Task", "Bash(canon pr merge:*)", "Bash(gh pr merge:*)"]
|
|
5
9
|
},
|
|
6
10
|
"hooks": {
|
|
@@ -29,6 +33,10 @@
|
|
|
29
33
|
{
|
|
30
34
|
"type": "command",
|
|
31
35
|
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scratch-guard.sh"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"type": "command",
|
|
39
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-guard.sh"
|
|
32
40
|
}
|
|
33
41
|
]
|
|
34
42
|
},
|
|
@@ -38,6 +46,10 @@
|
|
|
38
46
|
{
|
|
39
47
|
"type": "command",
|
|
40
48
|
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/review-post-scan.sh"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"type": "command",
|
|
52
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-guard.sh"
|
|
41
53
|
}
|
|
42
54
|
]
|
|
43
55
|
}
|
|
@@ -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
|
-
}
|