@erclx/canon 5.73.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.
- package/CHANGELOG.md +41 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +5 -3
- package/claude/skills/build-in-slices/references/adopted.md +4 -2
- package/claude/skills/code-craft/references/adopted.md +9 -7
- package/claude/skills/codebase-layout/references/adopted.md +8 -6
- 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 +6 -1
- package/claude/skills/deprecation-migration/references/adopted.md +5 -3
- package/claude/skills/design-pick/references/board.md +2 -2
- package/claude/skills/plan-feature/SKILL.md +2 -1
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
- package/claude/skills/pr-evidence/SKILL.md +2 -0
- package/claude/skills/pr-evidence/references/evidence.md +11 -4
- package/claude/skills/pr-evidence/references/reel.md +108 -0
- package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
- package/claude/skills/proposal-critique/SKILL.md +126 -0
- package/claude/skills/proposal-critique/references/adopted.md +44 -0
- package/claude/skills/review-craft/references/adopted.md +4 -4
- package/claude/skills/search-craft/references/adopted.md +9 -7
- package/claude/skills/systematic-debugging/references/adopted.md +4 -2
- package/claude/skills/teach-craft/references/adopted.md +8 -6
- package/claude/skills/test-craft/references/adopted.md +14 -12
- package/claude/skills/test-first/SKILL.md +1 -1
- package/claude/skills/test-first/references/adopted.md +9 -5
- package/claude/skills/ui-film/REQUIREMENT.md +4 -5
- package/claude/skills/ui-film/SKILL.md +21 -19
- package/claude/skills/ui-film/references/compose-craft.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +2 -7
- package/claude/skills/write-human/references/adopted.md +8 -3
- package/docs/agents/audits.md +24 -24
- package/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +2 -0
- package/docs/target/index.md +1 -0
- package/docs/target/outside-tools.md +39 -0
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +4 -0
- package/governance/ledger/claude/rule.md +16 -0
- package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
- package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
- package/governance/ledger/skills/search-craft/SKILL.md +54 -0
- package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
- package/governance/ledger/skills/test-first/SKILL.md +28 -0
- package/governance/ledger/skills/write-human/SKILL.md +62 -0
- package/governance/ledger/skills/write-human/density.md +36 -0
- package/governance/ledger/skills/write-human/machine-tells.md +30 -0
- package/governance/rules/claude/rule.md +13 -0
- package/package.json +1 -1
- package/scripts/ci/docs-only.sh +24 -0
- package/scripts/ci/refresh-frames-pr.sh +11 -12
- package/src/audits/catalog.ts +7 -2
- package/src/capture/stamp.ts +3 -5
- package/src/claude/cases/misc.ts +14 -0
- package/src/claude/skills-audit.ts +154 -8
- package/src/cli/help.ts +1 -1
- package/src/commands/claude.ts +6 -2
- package/src/commands/gov.ts +12 -7
- package/src/commands/migrate.ts +6 -2
- package/src/commands/pr.ts +110 -13
- package/src/commands/take.ts +36 -7
- package/src/counts/catalogs.ts +3 -5
- package/src/gate/measures.ts +2 -145
- package/src/gate/sequencer.ts +7 -1
- package/src/gate/stages.ts +36 -47
- package/src/gov/audit.ts +59 -13
- package/src/markdown/scope.ts +4 -0
- package/src/migrate/session-defaults.ts +114 -4
- package/src/pr/checks.ts +75 -3
- package/src/pr/evidence.ts +62 -1
- package/src/pr/lift.ts +23 -2
- package/src/pr/merge.ts +13 -3
- 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/src/targets/pulls.ts +42 -0
- 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 +8 -0
- package/claude/skills/ui-film/references/pr-clip.md +0 -108
- package/scripts/core/regen-hero.ts +0 -113
- 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
|
|
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
|
|
@@ -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,108 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Pull request clip
|
|
3
|
-
description: Pull request mode of ui-film, run for pr-evidence with no operator present, covering the beat choice from the checklist, the length cap, the still check that stands in for approval, the reel flag, and the push to the frames branch
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Pull request clip
|
|
7
|
-
|
|
8
|
-
Read at the top of `ui-film` when the caller names pull request mode. `pr-evidence` is the only caller, passing the pull request number, the `url` its `canon pr local` read returned, and the checklist text.
|
|
9
|
-
|
|
10
|
-
This mode films a short reel of what the checklist asks a reviewer to try, hosts it on `canon/frames`, and hands back the link or the reason it skipped.
|
|
11
|
-
|
|
12
|
-
## Contents
|
|
13
|
-
|
|
14
|
-
- [What changes from an ordinary run](#what-changes-from-an-ordinary-run)
|
|
15
|
-
- [Choose the beats](#choose-the-beats)
|
|
16
|
-
- [Film the reel](#film-the-reel)
|
|
17
|
-
- [Check each still](#check-each-still)
|
|
18
|
-
- [Push the reel](#push-the-reel)
|
|
19
|
-
- [What goes back to the caller](#what-goes-back-to-the-caller)
|
|
20
|
-
|
|
21
|
-
## What changes from an ordinary run
|
|
22
|
-
|
|
23
|
-
The mode holds only while the caller names it. An ordinary run that finds a pull request in its context still stops at Step 2 for approval.
|
|
24
|
-
|
|
25
|
-
- Skip Step 1's throwaway copy. Film the address the caller passed, joined to the config's `access` value, and start no server.
|
|
26
|
-
- Skip Step 2's approval. The beats come from a checklist a reviewer already reads, and the still check below is the guard against posting a wrong take.
|
|
27
|
-
- Skip Step 5's handoff and its output block, since the reel is the product and HyperFrames composes nothing here
|
|
28
|
-
- Write inside `.canon/tmp/ui-film/pr-<number>/` at the current worktree's root in place of the video workspace, since a reel is thrown away once posted
|
|
29
|
-
- Ask nothing at any step. Every stop below is a skip with a reason, since the ship chain runs this with nobody to answer.
|
|
30
|
-
|
|
31
|
-
## Choose the beats
|
|
32
|
-
|
|
33
|
-
Film where motion is the claim. Filter the boxes first, then spend the three beats so each feature area shows its change once before any area shows two.
|
|
34
|
-
|
|
35
|
-
### Filter the boxes
|
|
36
|
-
|
|
37
|
-
A box is filmable when it asks the reviewer to click, fill, or scroll something and its result is a visible change.
|
|
38
|
-
|
|
39
|
-
- Skip a box ending in `(taste)`, since no driver judges it
|
|
40
|
-
- Skip a box that asks only to look at a state the page opens on, since a screenshot already shows it
|
|
41
|
-
- Skip a negative box, one asserting that something does not happen, such as "no drop line draws", since footage of nothing shows nothing
|
|
42
|
-
|
|
43
|
-
Read the box's text for the negative. A box asserting an unchanged result, such as "the board goes to all projects as before", is not negative, since the action still produces a visible change. Rank it behind a box in the same group that names new behavior.
|
|
44
|
-
|
|
45
|
-
### Order the beats
|
|
46
|
-
|
|
47
|
-
A group is the bold feature-area label above at most five boxes, as `ui-checklist` defines it.
|
|
48
|
-
|
|
49
|
-
1. Take one beat per group in checklist order, from the highest-ranked filmable box of each group, up to three beats.
|
|
50
|
-
2. When beats remain after every group has one, take a second pass in checklist order over the boxes left, highest-ranked first within each group, up to three beats in all.
|
|
51
|
-
|
|
52
|
-
A checklist with one group gives up to three beats from it. A checklist with four groups gives three beats, one from each of the first three.
|
|
53
|
-
|
|
54
|
-
### Plan the beats
|
|
55
|
-
|
|
56
|
-
Write one plan entry for each beat chosen, then skip when none was.
|
|
57
|
-
|
|
58
|
-
- Name each beat after its box's text, cut to the action
|
|
59
|
-
- Write the plan per `${CLAUDE_SKILL_DIR}/references/take-craft.md`, finding each target on the served page rather than guessing it
|
|
60
|
-
- With no interactive box, skip as `no-interactive-box` and film nothing
|
|
61
|
-
- With interactive boxes that the filter removed, whether negative, static, or marked `(taste)`, skip as `no-visible-change` and film nothing
|
|
62
|
-
|
|
63
|
-
The negative test is a judgment over the box's text. A misread box costs a weaker reel and never a wrong pass, since the still check below guards the footage and the UI reviewer drives every box regardless.
|
|
64
|
-
|
|
65
|
-
Keep the whole reel at or under 12 seconds. Each beat opens and closes on a 0.6 s hold, so give each action's `wait` only what its result needs to settle.
|
|
66
|
-
|
|
67
|
-
## Film the reel
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
canon take <url> .canon/tmp/ui-film/pr-<number>/plan.json --out .canon/tmp/ui-film/pr-<number>/takes --reel .canon/tmp/ui-film/pr-<number>/reel.webp --json
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Branch on the record's `reason`, never on the exit code.
|
|
74
|
-
|
|
75
|
-
| Record | Route |
|
|
76
|
-
| --------------- | ------------------------------------------------------------------ |
|
|
77
|
-
| No `reason` | Sum the beats' `seconds` and continue to the stills |
|
|
78
|
-
| `reel-failed` | Skip as `reel-failed` with the record's `message` |
|
|
79
|
-
| Any other value | Skip under that `reason`, such as `target-missing` with its `beat` |
|
|
80
|
-
|
|
81
|
-
When the beats sum past 12 seconds, drop the last beat from the plan and film once more. A second run past the cap skips as `over-cap`.
|
|
82
|
-
|
|
83
|
-
## Check each still
|
|
84
|
-
|
|
85
|
-
Open each beat's `still.jpg`, the closing frame the record names in its `still` field, and confirm it shows the state the beat's box describes. This check replaces the operator's approval, so it runs on every reel.
|
|
86
|
-
|
|
87
|
-
- A still showing the wrong state skips the reel as `wrong-state`, naming the beat and what the still shows
|
|
88
|
-
- A record whose beats carry no `still` field comes from a `canon` predating the still, so skip as `no-still` rather than posting a reel nothing checked
|
|
89
|
-
- Read `fallback` on each beat the way an ordinary run does. A beat filmed by screencast still joins the reel, since the reel shrinks every beat to one width.
|
|
90
|
-
|
|
91
|
-
## Push the reel
|
|
92
|
-
|
|
93
|
-
Stamp the pass in compact UTC and push the file:
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
canon pr frames <number> --reel .canon/tmp/ui-film/pr-<number>/reel.webp --pass "$(date -u +%Y%m%dT%H%M%SZ)" --json
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
- `ok`: hold the record's `link`
|
|
100
|
-
- `reel-too-large`: skip as `reel-too-large`, since GitHub would not render it
|
|
101
|
-
- Any other `reason`: skip under it with the record's `message`
|
|
102
|
-
|
|
103
|
-
## What goes back to the caller
|
|
104
|
-
|
|
105
|
-
Hand `pr-evidence` one of two things and print nothing of your own:
|
|
106
|
-
|
|
107
|
-
- The `link` from the push, which it passes to `canon pr evidence --reel`
|
|
108
|
-
- A skip, as the reason and a one-line message, which its report names
|
|
@@ -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
|
-
}
|