create-afe-project 0.0.0-stage → 2.0.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 (61) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -2
  4. package/bin/create-afe-project.js +123 -0
  5. package/package.json +43 -4
  6. package/src/answers.js +147 -0
  7. package/src/config.js +151 -0
  8. package/src/project.js +217 -0
  9. package/src/render.js +51 -0
  10. package/template/.claude/harness.conf +50 -0
  11. package/template/.claude/hooks/post-edit-check.sh +41 -0
  12. package/template/.claude/hooks/protect-docs.sh +315 -0
  13. package/template/.claude/hooks/session-start.sh +81 -0
  14. package/template/.claude/hooks/stop-check-decisions.sh +25 -0
  15. package/template/.claude/hooks/stop-check-docs.sh +29 -0
  16. package/template/.claude/hooks/stop-check-fast.sh +33 -0
  17. package/template/.claude/hooks/stop-check-template.sh +23 -0
  18. package/template/.claude/hooks/stop-run-project-hooks.sh +27 -0
  19. package/template/.claude/path-classes +102 -0
  20. package/template/.claude/settings.project.json +5 -0
  21. package/template/.claude/settings.template.json +92 -0
  22. package/template/.claude/skills/afe-init/SKILL.md +104 -0
  23. package/template/.claude/skills/create-pr/SKILL.md.jinja +110 -0
  24. package/template/.claude/skills/update-template/SKILL.md +40 -0
  25. package/template/.claude/skills/{% if code_review %}review-code{% endif %}/SKILL.md.jinja +50 -0
  26. package/template/.claude/skills/{% if docs_review %}docs-review{% endif %}/SKILL.md +60 -0
  27. package/template/.claude/skills/{% if dual_review %}dual-review{% endif %}/SKILL.md +58 -0
  28. package/template/.github/workflows/decisions.yml.jinja +57 -0
  29. package/template/.github/workflows/docs.yml.jinja +40 -0
  30. package/template/.github/workflows/{% if code_review %}code-review.yml{% endif %}.jinja +54 -0
  31. package/template/.github/workflows/{% if docs_review %}docs-review.yml{% endif %}.jinja +52 -0
  32. package/template/.github/workflows/{% if fail_without_change %}fail-without-change.yml{% endif %}.jinja +42 -0
  33. package/template/.github/workflows/{% if secret_scan %}secret-scan.yml{% endif %}.jinja +47 -0
  34. package/template/.markdownlint-cli2.jsonc +27 -0
  35. package/template/.template/owned.txt.jinja +65 -0
  36. package/template/AGENTS.md.jinja +91 -0
  37. package/template/CLAUDE.md +3 -0
  38. package/template/README.md.jinja +53 -0
  39. package/template/_gitignore +14 -0
  40. package/template/docs/README.md.jinja +44 -0
  41. package/template/docs/contributing.md.jinja +206 -0
  42. package/template/docs/decisions/adr-template.md +38 -0
  43. package/template/docs/documentation-rules.md.jinja +200 -0
  44. package/template/docs/testing-rules.md +80 -0
  45. package/template/scripts/check-decisions.sh +382 -0
  46. package/template/scripts/check-docs.sh +20 -0
  47. package/template/scripts/check-links.mjs +142 -0
  48. package/template/scripts/check-project.sh +27 -0
  49. package/template/scripts/check-template.sh +88 -0
  50. package/template/scripts/check.sh +21 -0
  51. package/template/scripts/env.sh +29 -0
  52. package/template/scripts/gen-decisions-index.sh +57 -0
  53. package/template/scripts/gen-settings.sh +14 -0
  54. package/template/scripts/lib/classes.sh +89 -0
  55. package/template/scripts/lib/git-base.sh +25 -0
  56. package/template/scripts/lib/harness-conf.sh +48 -0
  57. package/template/scripts/lib/json.mjs +160 -0
  58. package/template/scripts/pr-metrics.sh +80 -0
  59. package/template/scripts/protect-branch.sh +101 -0
  60. package/template/scripts/{% if acceptance_check %}check-acceptance.sh{% endif %} +47 -0
  61. package/template/scripts/{% if fail_without_change %}fail-without-change.sh{% endif %} +88 -0
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook (Edit, Write, MultiEdit, Bash): a change to a path of class approval, and an
3
+ # irreversible action, need an explicit owner instruction (AGENTS.md, section 4;
4
+ # docs/documentation-rules.md, sections 8 and 9). Class approval are the template-owned files of
5
+ # .template/owned.txt, the generated ownership manifest, answers file, and .claude/settings.json,
6
+ # the documentation map, .claude/path-classes and its `approval <glob>` lines,
7
+ # .claude/settings.project.json, .mcp.json, the glob lines of a legacy .claude/protected-paths,
8
+ # ADRs that exist on main, and ADRs that are no longer `proposed`. The irreversible actions are the
9
+ # commands that irreversible() below names. A target path is judged by the git repository that holds it when that
10
+ # repository has .template/owned.txt, so a sibling worktree or another generated project is
11
+ # guarded by its own paths; otherwise by the session's project (CLAUDE_PROJECT_DIR) when the path
12
+ # is inside it; any other path is not guarded. A relative path of a shell command resolves
13
+ # against the hook input's `cwd`. The hook turns such a change into a
14
+ # permission prompt for the owner (`permissionDecision: ask`); in headless runs Claude Code denies
15
+ # it. DOCS_GUARD=off in the Claude Code environment disables the guard for scripted owner-driven
16
+ # runs, such as a template update. This is a guard against unreviewed changes, not a security
17
+ # boundary: a command that hides the target path (for example inside a script file, behind a
18
+ # variable, or in a directory changed by a script) is not detected.
19
+ set -uo pipefail
20
+ [ "${DOCS_GUARD:-on}" = "off" ] && exit 0
21
+
22
+ # Without Node.js the hook cannot read the tool call; block it rather than allow everything.
23
+ command -v node >/dev/null || { echo "protect-docs: Node.js is required (README.md, Checks)" >&2; exit 2; }
24
+ json="$(cd "$(dirname "$0")/../.." && pwd)/scripts/lib/json.mjs"
25
+ { IFS= read -r -d '' tool; IFS= read -r -d '' cwd; IFS= read -r -d '' file_path; IFS= read -r -d '' cmd
26
+ IFS= read -r -d '' secret; } < <(node "$json" fields tool_name cwd tool_input.file_path tool_input.command @secret)
27
+ [ -n "$cwd" ] && [ -d "$cwd" ] || cwd=$PWD
28
+ project="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
29
+ [ -n "$project" ] && project=$(cd "$project" 2>/dev/null && pwd -P) || project=
30
+
31
+ # True when $1 matches a line of the list file $2 (shell glob patterns; '#' starts a comment).
32
+ listed() {
33
+ local pat
34
+ [ -f "$2" ] || return 1
35
+ while IFS= read -r pat; do
36
+ case "$pat" in ''|'#'*) continue ;; esac
37
+ # shellcheck disable=SC2254
38
+ case "$1" in $pat) return 0 ;; esac
39
+ done < "$2"
40
+ return 1
41
+ }
42
+
43
+ # Prints why $1 is protected, or nothing when it is not.
44
+ protected_reason() {
45
+ local p="$1" abs rel dir rest seg norm climbed=1 root
46
+ case "$p" in /*) abs="$p" ;; *) abs="$cwd/$p" ;; esac
47
+ # Resolve "..", "//", and symbolic links in the part of the path that exists. In the part that
48
+ # does not exist yet, fold "." and "x/.."; a ".." left over climbs from the existing directory,
49
+ # and then the folded path is resolved once more, as it may enter another existing directory.
50
+ while [ -n "$climbed" ]; do
51
+ dir=$(dirname "$abs") rest=$(basename "$abs") norm= climbed=
52
+ while [ ! -d "$dir" ]; do rest="$(basename "$dir")/$rest"; dir=$(dirname "$dir"); done
53
+ dir=$(cd "$dir" && pwd -P) || return 0
54
+ while [ -n "$rest" ]; do
55
+ seg=${rest%%/*}
56
+ if [ "$seg" = "$rest" ]; then rest=; else rest=${rest#*/}; fi
57
+ case "$seg" in
58
+ ''|.) ;;
59
+ ..) if [ -z "$norm" ]; then dir=$(dirname "$dir") climbed=1
60
+ elif [ "${norm%/*}" = "$norm" ]; then norm=
61
+ else norm=${norm%/*}; fi ;;
62
+ *) norm=${norm:+$norm/}$seg ;;
63
+ esac
64
+ done
65
+ abs="$dir/$norm"
66
+ done
67
+ # The generated project that holds the path: its git repository when that has
68
+ # .template/owned.txt (a sibling worktree, another generated project), otherwise the session's
69
+ # project when the path is inside it (no git yet, a nested repository, a missing manifest).
70
+ root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) && root=$(cd "$root" && pwd -P) &&
71
+ [ -f "$root/.template/owned.txt" ] || root=$project
72
+ [ -n "$root" ] || return 0
73
+ rel="${abs#"$root"/}"
74
+ [ "$rel" != "$abs" ] || return 0
75
+ if [ "$rel" = AGENTS.md ]; then
76
+ echo "template-owned outside its project block; only the project block may change here"
77
+ return
78
+ fi
79
+ if approval_listed "$rel" "$root/.claude/path-classes"; then
80
+ echo "class approval in .claude/path-classes"
81
+ return
82
+ fi
83
+ if listed "$rel" "$root/.template/owned.txt"; then
84
+ echo "template-owned; change it in the template repository and update (docs/contributing.md, section 5)"
85
+ return
86
+ fi
87
+ case "$rel" in
88
+ .template/owned.sha256|.afe-answers.json|.copier-answers.yml)
89
+ echo "generated by a template update only" ;;
90
+ .claude/settings.json)
91
+ echo "generated by scripts/gen-settings.sh; change .claude/settings.project.json instead, with the owner" ;;
92
+ .claude/path-classes|.claude/protected-paths)
93
+ echo "the classes of paths, class approval" ;;
94
+ .claude/settings.project.json|.mcp.json)
95
+ echo "permissions and MCP servers, class approval" ;;
96
+ docs/README.md)
97
+ echo "documentation map; structure changes need the owner" ;;
98
+ docs/decisions/README.md)
99
+ ;; # generated by scripts/gen-decisions-index.sh; scripts/check-decisions.sh checks it
100
+ docs/decisions/*.md)
101
+ if git -C "$root" cat-file -e "origin/main:$rel" 2>/dev/null ||
102
+ { ! git -C "$root" rev-parse -q --verify origin/main >/dev/null && git -C "$root" cat-file -e "main:$rel" 2>/dev/null; }; then
103
+ echo "ADR that exists on main; only its status line changes (documentation-rules.md, section 5), otherwise supersede it with a new ADR"
104
+ elif [ -f "$abs" ] && ! head -n 8 "$abs" | grep -q -E '^status:[[:space:]]*proposed'; then
105
+ echo "ADR that is no longer proposed; immutable, supersede it with a new ADR"
106
+ fi ;;
107
+ *)
108
+ listed "$rel" "$root/.claude/protected-paths" && echo "listed in .claude/protected-paths" ;;
109
+ esac
110
+ }
111
+
112
+ # True when $1 matches an `approval <glob>` line of .claude/path-classes, file $2.
113
+ approval_listed() {
114
+ local cls pat
115
+ [ -f "$2" ] || return 1
116
+ while read -r cls pat || [ -n "$cls" ]; do
117
+ [ "$cls" = approval ] && [ -n "$pat" ] || continue
118
+ # shellcheck disable=SC2254
119
+ case "$1" in $pat) return 0 ;; esac
120
+ done < "$2"
121
+ return 1
122
+ }
123
+
124
+ # Prints the paths a shell command can write, one per line: redirect targets in every segment, the
125
+ # destination of cp, mv, install, and ln, the files of tee, rm, truncate, and touch, dd's of=, the
126
+ # files of sed -i, perl -i, and git checkout, restore, rm, mv, and apply, and file-like tokens of an
127
+ # interpreter (python, node, ruby, perl, bash, sh), also in the heredoc a python, node, ruby, or
128
+ # perl reads; a heredoc on a line where a shell runs (`bash <<EOF`, `cat <<EOF | sh`,
129
+ # `source /dev/stdin <<EOF`) is parsed as commands. A relative target is joined to
130
+ # the directory of an earlier `cd DIR` with a literal DIR, and a git path argument to a literal
131
+ # `git -C DIR`. The body of any other heredoc is text, not commands; `<<<`, `$((1 << n))`, and
132
+ # `<<` inside quotes start no heredoc. Input files (<) and sources are not targets.
133
+ write_targets() {
134
+ printf '%s\n' "$1" | awk '
135
+ function out(t) { if (t == "") return; if (t !~ /^\// && pre != "") t = pre "/" t; print t }
136
+ function emit_all(from, x, na, A) { na = split(args, A, " "); for (x = from; x <= na; x++) out(A[x]) }
137
+ function files(text, tmp) {
138
+ tmp = text
139
+ while (match(tmp, /[A-Za-z0-9_.\/-]*(\.(md|json|jsonc|toml|yml|yaml|sh|txt|sha256|jinja|conf|pem|key)|\.claude\/(protected-paths|path-classes)|\.env[A-Za-z0-9_.]*)/)) {
140
+ out(substr(tmp, RSTART, RLENGTH)); tmp = substr(tmp, RSTART + RLENGTH)
141
+ }
142
+ }
143
+ BEGIN { dir = "" }
144
+ hd != "" { l = $0; gsub(/^[ \t]+|[ \t]+$/, "", l); if (l == hd) { hd = ""; next }; if (hdint) { pre = dir; files($0) }; next }
145
+ {
146
+ line = $0; delim = ""
147
+ if (match(line, /<<-?[ \t]*["\047]?[A-Za-z_][A-Za-z0-9_]*/)) {
148
+ before = substr(line, 1, RSTART - 1); cand = substr(line, RSTART, RLENGTH)
149
+ dq = gsub(/"/, "\"", before); sq = gsub(/\047/, "\047", before)
150
+ if (before !~ /<$/ && before !~ /\$\(\([^)]*$/ && dq % 2 == 0 && sq % 2 == 0) {
151
+ delim = cand; sub(/^<<-?[ \t]*["\047]?/, "", delim)
152
+ }
153
+ }
154
+ n = split(line, segs, /[|;&]+/)
155
+ for (i = 1; i <= n; i++) {
156
+ s = segs[i]; tmp = s; pre = dir
157
+ m = split(s, w, /[ \t]+/); k = 1
158
+ while (k <= m && (w[k] == "" || w[k] ~ /^[A-Za-z_][A-Za-z0-9_]*=/ || w[k] == "sudo" || w[k] == "command")) k++
159
+ cmd = w[k]; sub(/.*\//, "", cmd)
160
+ if (cmd == "cd") {
161
+ d = w[k + 1]; gsub(/^["\047]+|["\047]+$/, "", d)
162
+ if (d ~ /[$`~*?]/ || d == "" || d == "-") { }
163
+ else if (d ~ /^\//) dir = d; else dir = (dir == "" ? d : dir "/" d)
164
+ continue
165
+ }
166
+ while (match(tmp, />>?[ \t]*["\047]?[A-Za-z0-9_.\/-]+/)) {
167
+ t = substr(tmp, RSTART, RLENGTH); sub(/^>>?[ \t]*["\047]?/, "", t); out(t)
168
+ tmp = substr(tmp, RSTART + RLENGTH)
169
+ }
170
+ if (cmd == "git" && w[k + 1] == "-C") {
171
+ d = w[k + 2]; gsub(/^["\047]+|["\047]+$/, "", d)
172
+ if (d !~ /[$`~*?]/) pre = (d ~ /^\// || dir == "") ? d : dir "/" d
173
+ for (x = k + 1; x + 2 <= m; x++) w[x] = w[x + 2]; m -= 2
174
+ }
175
+ gsub(/[0-9]*>>?[ \t]*["\047]?[^ \t]*/, "", s); gsub(/<<?-?[ \t]*[^ \t]*/, "", s)
176
+ if (cmd != "git") { m = split(s, w, /[ \t]+/); k = 1
177
+ while (k <= m && (w[k] == "" || w[k] ~ /^[A-Za-z_][A-Za-z0-9_]*=/ || w[k] == "sudo" || w[k] == "command")) k++ }
178
+ args = ""
179
+ for (j = k + 1; j <= m; j++) { a = w[j]; gsub(/^["\047]+|["\047]+$/, "", a); if (a !~ /^-/) args = args " " a }
180
+ if (cmd ~ /^(cp|mv|install|ln)$/) { na = split(args, A, " "); if (na) out(A[na]) }
181
+ else if (cmd ~ /^(tee|rm|truncate|touch)$/) emit_all(1)
182
+ else if (cmd == "dd") { na = split(args, A, " "); for (x = 1; x <= na; x++) if (sub(/^of=/, "", A[x])) out(A[x]) }
183
+ else if (cmd ~ /^(sed|perl)$/ && s ~ /[ \t]-[a-zA-Z]*i/) emit_all(1)
184
+ else if (cmd == "git" && w[k + 1] ~ /^(checkout|restore|rm|mv|apply)$/) emit_all(2)
185
+ if (cmd ~ /^(python3?|node|ruby|perl|bash|sh|zsh)$/) files(segs[i])
186
+ if (delim != "" && segs[i] ~ /<</) hdint = (cmd ~ /^(python3?|node|ruby|perl)$/)
187
+ if (cmd ~ /^(bash|sh|zsh|source|\.)$/) hdsh = 1
188
+ }
189
+ if (delim != "" && !hdsh) hd = delim
190
+ hdsh = 0
191
+ }'
192
+ }
193
+
194
+ # Prints the first irreversible action in a shell command, or nothing: in any segment, `git push`
195
+ # (after git's global options such as -C DIR) with --force, --force-with-lease,
196
+ # --force-if-includes, --delete, --mirror, --prune, or --tags, a short option that contains f or
197
+ # d (-f, -uf, -d), a refspec that starts with + or :, or a tag (refs/tags/..., or v followed by a
198
+ # digit, the form of a release); `gh pr merge`; `gh release create` or `gh release delete`;
199
+ # `gh repo delete`; `gh workflow run`; `gh api` with the method DELETE; and publishing a package
200
+ # or an image: `npm`, `pnpm`, `yarn`, `bun`, `cargo`, `poetry`, or `uv publish`, `twine upload`,
201
+ # `gem push`, `dotnet nuget push`, `docker` or `podman push`.
202
+ # With "push" as $2 it prints instead the words of the first `git push` that are not options:
203
+ # the remote and the refspecs, one per line, or "-" alone when there are none.
204
+ irreversible() {
205
+ printf '%s\n' "$1" | awk -v mode="${2:-}" '{
206
+ n = split($0, segs, /[|;&]+/)
207
+ for (i = 1; i <= n; i++) {
208
+ m = split(segs[i], w, /[ \t]+/); k = 1
209
+ while (k <= m && (w[k] == "" || w[k] ~ /^[A-Za-z_][A-Za-z0-9_]*=/ || w[k] == "sudo" || w[k] == "command")) k++
210
+ cmd = w[k]; sub(/.*\//, "", cmd)
211
+ s = k + 1
212
+ if (cmd == "git") while (s <= m && w[s] ~ /^-/) s += (w[s] == "-C" || w[s] == "-c") ? 2 : 1
213
+ sub1 = w[s]; sub2 = w[s + 1]
214
+ if (cmd == "git" && sub1 == "push") {
215
+ if (mode == "push") {
216
+ out = ""
217
+ for (j = s + 1; j <= m; j++) { a = w[j]; gsub(/^["\047]+|["\047]+$/, "", a); if (a != "" && a !~ /^-/) out = out a "\n" }
218
+ printf "%s", (out == "" ? "-\n" : out); exit
219
+ }
220
+ for (j = s + 1; j <= m; j++) {
221
+ a = w[j]; gsub(/^["\047]+|["\047]+$/, "", a)
222
+ if (a ~ /^--(force|force-with-lease|force-if-includes|delete|mirror|prune|tags)(=.*)?$/ || a ~ /^-[A-Za-z]*[fd][A-Za-z]*$/ || a ~ /^[+:]/ || a ~ /^(refs\/tags\/|v[0-9])/) {
223
+ print "git push " a; exit
224
+ }
225
+ }
226
+ }
227
+ if (mode == "push") continue
228
+ if (cmd == "gh" && ((sub1 == "pr" && sub2 == "merge") || (sub1 == "release" && sub2 ~ /^(create|delete)$/) || (sub1 == "repo" && sub2 == "delete") || (sub1 == "workflow" && sub2 == "run"))) {
229
+ print "gh " sub1 " " sub2; exit
230
+ }
231
+ if (cmd == "gh" && sub1 == "api") {
232
+ for (j = s + 1; j <= m; j++) if ((w[j] ~ /^(-X|--method)$/ && toupper(w[j + 1]) == "DELETE") || toupper(w[j]) ~ /^(-XDELETE|--METHOD=DELETE)$/) { print "gh api DELETE"; exit }
233
+ }
234
+ if ((cmd ~ /^(npm|pnpm|yarn|bun|cargo|poetry|uv)$/ && sub1 == "publish") || (cmd == "twine" && sub1 == "upload") || (cmd == "gem" && sub1 == "push") ||
235
+ (cmd ~ /^(docker|podman)$/ && sub1 == "push") || (cmd == "dotnet" && sub1 == "nuget" && sub2 == "push")) {
236
+ print cmd " " sub1 (cmd == "dotnet" ? " " sub2 : ""); exit
237
+ }
238
+ }
239
+ }'
240
+ }
241
+
242
+ # Prints the default branch when the shell command pushes to it, or nothing. Without a refspec,
243
+ # `git push` pushes the current branch. A default branch that the remote does not have yet (the
244
+ # first push of a new repository) is left to the owner's prompt, not denied.
245
+ pushes_default() {
246
+ local words remote refs r dst def cur
247
+ words=$(irreversible "$1" push)
248
+ [ -n "$words" ] || return 0
249
+ def=$(git -C "$cwd" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) && def=${def#origin/} || def=main
250
+ if [ "$words" = - ]; then refs=; remote=origin; else remote=$(printf '%s\n' "$words" | head -n 1); refs=$(printf '%s\n' "$words" | tail -n +2); fi
251
+ if [ -z "$refs" ]; then
252
+ cur=$(git -C "$cwd" symbolic-ref --quiet --short HEAD 2>/dev/null) || return 0
253
+ refs=$cur
254
+ fi
255
+ while IFS= read -r r; do
256
+ dst=${r#+}; dst=${dst##*:}; dst=${dst#refs/heads/}
257
+ [ "$dst" = HEAD ] && dst=$(git -C "$cwd" symbolic-ref --quiet --short HEAD 2>/dev/null)
258
+ if [ "$dst" = "$def" ]; then
259
+ git -C "$cwd" ls-remote --exit-code --heads "$remote" "$def" > /dev/null 2>&1 && echo "$def"
260
+ return 0
261
+ fi
262
+ done <<< "$refs"
263
+ }
264
+
265
+ ask_owner() {
266
+ node "$json" ask "Protected file: $1 ($2). Changing it needs an explicit owner instruction (AGENTS.md, section 4). Approve only if you asked for this change; otherwise the agent must report it as an open question."
267
+ exit 0
268
+ }
269
+
270
+ deny_push() {
271
+ node "$json" deny "Pushing to the default branch $1 is not allowed: changes reach it only through a pull request (AGENTS.md, section 4; docs/contributing.md, section 1). Push a branch and open a pull request instead."
272
+ exit 0
273
+ }
274
+
275
+ ask_action() {
276
+ node "$json" ask "Irreversible action: $1. It needs an explicit owner instruction (AGENTS.md, section 4; docs/documentation-rules.md, section 8). Approve only if you asked for it."
277
+ exit 0
278
+ }
279
+
280
+ # A push to the default branch is denied before anything asks: an approved prompt must never let
281
+ # it through.
282
+ if [ "$tool" = Bash ] && [ -n "$cmd" ]; then
283
+ branch=$(pushes_default "$cmd")
284
+ [ -n "$branch" ] && deny_push "$branch"
285
+ fi
286
+
287
+ # A known token format in what the call writes: secrets never go into the repository.
288
+ if [ -n "$secret" ] && [[ "$tool" =~ ^(Edit|Write|MultiEdit|Bash)$ ]]; then
289
+ node "$json" ask "Possible secret ($secret) in this change. Secrets never go into the repository or a command line; use an environment variable or the CI secret store. Approve only if it is a placeholder or a test fixture."
290
+ exit 0
291
+ fi
292
+
293
+ case "$tool" in
294
+ Edit|Write|MultiEdit)
295
+ path=$file_path
296
+ [ -n "$path" ] || exit 0
297
+ reason=$(protected_reason "$path")
298
+ [ -n "$reason" ] && ask_owner "$path" "$reason"
299
+ ;;
300
+ Bash)
301
+ [ -n "$cmd" ] || exit 0
302
+ # Rewriting the manifest is how a local edit of a template-owned file would be hidden.
303
+ if printf '%s' "$cmd" | grep -q -E 'check-template\.sh[^|;&]*--write'; then
304
+ ask_owner ".template/owned.sha256" "generated by a template update only, via Bash"
305
+ fi
306
+ action=$(irreversible "$cmd")
307
+ [ -n "$action" ] && ask_action "$action"
308
+ targets=$(write_targets "$cmd")
309
+ for tok in $targets; do
310
+ reason=$(protected_reason "$tok")
311
+ [ -n "$reason" ] && ask_owner "$tok" "$reason, via Bash"
312
+ done
313
+ ;;
314
+ esac
315
+ exit 0
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # SessionStart hook: prints the local state of the work in progress, so that a new, resumed, or
3
+ # compacted session starts from facts (docs/documentation-rules.md, section 7): the branch, the
4
+ # base of the change (scripts/lib/git-base.sh) and the commits ahead of it, the count of
5
+ # uncommitted and untracked files, unresolved merge conflicts, and each plan in docs/plans/ with
6
+ # its review-by date, flagged when the date is past (section 5), past ones first. At most 10 lines
7
+ # and local facts only: no network. Outside a git repository only the plans are shown. What it
8
+ # cannot determine it leaves out; it always exits 0.
9
+ set -uo pipefail
10
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
11
+ [ -n "$root" ] || exit 0
12
+ cd "$root" 2>/dev/null || exit 0
13
+ lines=()
14
+
15
+ if git rev-parse --git-dir > /dev/null 2>&1; then
16
+ if git rev-parse --verify -q HEAD > /dev/null; then
17
+ line="branch $(git symbolic-ref --short -q HEAD || printf 'none (detached at %s)' "$(git rev-parse --short HEAD)")"
18
+ # shellcheck source=scripts/lib/git-base.sh
19
+ if . scripts/lib/git-base.sh 2> /dev/null && base=$(resolve_base 2> /dev/null); then
20
+ name=
21
+ for ref in origin/HEAD origin/main main; do
22
+ git rev-parse --verify -q "$ref^{commit}" > /dev/null 2>&1 || continue
23
+ [ "$(git merge-base "$ref" HEAD 2> /dev/null)" = "$base" ] || continue
24
+ name=$(git rev-parse --abbrev-ref "$ref" 2> /dev/null || echo "$ref")
25
+ break
26
+ done
27
+ ahead=$(git rev-list --count "$base..HEAD" 2> /dev/null)
28
+ if [ -n "$name" ] && [ -n "$ahead" ]; then
29
+ line="$line; base $name at $(git rev-parse --short "$base"), $ahead commits ahead"
30
+ elif [ -z "$name" ]; then
31
+ line="$line; no base branch (origin/HEAD, origin/main, main): only uncommitted changes count"
32
+ fi
33
+ fi
34
+ lines+=("$line")
35
+ fi
36
+ if st=$(git status --porcelain --untracked-files=all 2> /dev/null); then
37
+ untracked=$(printf '%s\n' "$st" | grep -c '^??')
38
+ changed=$(printf '%s\n' "$st" | grep -v '^??' | grep -c .)
39
+ lines+=("uncommitted files: $changed; untracked files: $untracked")
40
+ fi
41
+ conflicts=$(git ls-files -u 2> /dev/null | cut -f2 | sort -u | grep -c .)
42
+ [ "${conflicts:-0}" -gt 0 ] &&
43
+ lines+=("unresolved merge conflicts in $conflicts files: resolve them first (docs/contributing.md, section 5)")
44
+ fi
45
+
46
+ shopt -s nullglob
47
+ plans=()
48
+ for p in docs/plans/*.md; do
49
+ [ "$(basename "$p")" = README.md ] || plans+=("$p")
50
+ done
51
+ # SESSION_START_TODAY (YYYY-MM-DD) stands in for the clock, for the tests.
52
+ today=${SESSION_START_TODAY:-$(date +%Y-%m-%d)}
53
+ # Plans past their review-by come first, then those without one, then the rest, so the five shown
54
+ # never hide a past one; the line for the rest counts the past ones among them.
55
+ past=() undated=() ahead=()
56
+ for p in ${plans[@]+"${plans[@]}"}; do
57
+ rb=$(awk 'NR == 1 && $0 != "---" {exit} NR > 1 && $0 == "---" {exit}
58
+ /^review-by:/ {sub(/^review-by:[[:space:]]*/, ""); gsub(/["'\''[:space:]]/, ""); print; exit}' "$p" 2> /dev/null)
59
+ if ! printf '%s' "$rb" | grep -q -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then
60
+ undated+=("plan $p: no review-by date")
61
+ elif [[ "$rb" < "$today" ]]; then
62
+ past+=("plan $p: review-by $rb is past; update or delete it (docs/documentation-rules.md, section 5)")
63
+ else
64
+ ahead+=("plan $p: review-by $rb")
65
+ fi
66
+ done
67
+ ordered=(${past[@]+"${past[@]}"} ${undated[@]+"${undated[@]}"} ${ahead[@]+"${ahead[@]}"})
68
+ [ "${#ordered[@]}" -eq 0 ] || for i in "${!ordered[@]}"; do
69
+ if [ "$i" -eq 5 ]; then
70
+ rest="and $(( ${#ordered[@]} - 5 )) more plans in docs/plans/"
71
+ [ "${#past[@]}" -le 5 ] || rest="$rest, $(( ${#past[@]} - 5 )) of them past their review-by"
72
+ lines+=("$rest")
73
+ break
74
+ fi
75
+ lines+=("${ordered[$i]}")
76
+ done
77
+
78
+ [ "${#lines[@]}" -gt 0 ] || exit 0
79
+ echo "Local state at session start (.claude/hooks/session-start.sh):"
80
+ printf -- '- %s\n' "${lines[@]}"
81
+ exit 0
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: Claude Code may not finish a turn while the decision-record integrity checks of
3
+ # scripts/check-decisions.sh fail (docs/documentation-rules.md, sections 5 and 7). Merge readiness
4
+ # is not checked here: an agent legitimately stops with a `proposed` record to ask the owner. Exit 2
5
+ # sends the agent back with the check output. DOCS_GUARD=off disables it, as for the protect hook
6
+ # (docs/documentation-rules.md, section 8).
7
+ set -uo pipefail
8
+ [ "${DOCS_GUARD:-on}" = "off" ] && exit 0
9
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
10
+ [ -n "$root" ] || exit 0
11
+ cd "$root" || exit 0
12
+ # During an unresolved merge conflict (a template update) the checks cannot pass yet; let the
13
+ # agent stop and ask instead of looping.
14
+ if [ -n "$(git ls-files -u 2>/dev/null)" ]; then
15
+ echo "Unresolved merge conflicts: checks resume once they are resolved (docs/contributing.md, section 5)." >&2
16
+ exit 0
17
+ fi
18
+ RALPH_SESSION=$(node scripts/lib/json.mjs get session_id none 2>/dev/null || echo none)
19
+ # shellcheck source=scripts/lib/harness-conf.sh
20
+ . scripts/lib/harness-conf.sh 2>/dev/null || { ralph_block() { printf '%s\n' "$2" >&2; exit 2; }; ralph_pass() { :; }; }
21
+ if out=$(scripts/check-decisions.sh 2>&1); then
22
+ ralph_pass decisions
23
+ exit 0
24
+ fi
25
+ ralph_block decisions "$(printf 'Decision record checks failed; fix them before finishing (docs/documentation-rules.md, section 5):\n%s' "$out")"
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: Claude Code may not finish a turn while the documentation checks fail. Runs
3
+ # scripts/check-docs.sh only when a Markdown file differs from the base of the change
4
+ # (scripts/lib/git-base.sh), committed or not, so unrelated turns pay nothing. Exit 2 sends the
5
+ # agent back with the check output; a missing tool is reported without blocking.
6
+ set -uo pipefail
7
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
8
+ [ -n "$root" ] || exit 0
9
+ cd "$root" || exit 0
10
+ # During an unresolved merge conflict (a template update) the checks cannot pass yet; let the
11
+ # agent stop and ask instead of looping.
12
+ if [ -n "$(git ls-files -u 2>/dev/null)" ]; then
13
+ echo "Unresolved merge conflicts: checks resume once they are resolved (docs/contributing.md, section 5)." >&2
14
+ exit 0
15
+ fi
16
+ # shellcheck source=scripts/lib/git-base.sh
17
+ . scripts/lib/git-base.sh
18
+ base=$(resolve_base 2>/dev/null) || exit 0
19
+ changed=$( { git diff --name-only "$base" -- '*.md'; git ls-files --others --exclude-standard -- '*.md'; } 2>/dev/null | head -n 1)
20
+ [ -n "$changed" ] || exit 0
21
+ RALPH_SESSION=$(node scripts/lib/json.mjs get session_id none 2>/dev/null || echo none)
22
+ # shellcheck source=scripts/lib/harness-conf.sh
23
+ . scripts/lib/harness-conf.sh 2>/dev/null || { ralph_block() { printf '%s\n' "$2" >&2; exit 2; }; ralph_pass() { :; }; }
24
+ out=$(scripts/check-docs.sh 2>&1)
25
+ case $? in
26
+ 0) ralph_pass docs; exit 0 ;;
27
+ 3) printf 'Documentation checks could not run; install the missing tool:\n%s\n' "$out" >&2; exit 1 ;;
28
+ esac
29
+ ralph_block docs "$(printf 'Documentation checks failed; fix them before finishing (AGENTS.md, section 6):\n%s' "$out")"
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: Claude Code may not finish a turn while the project's fast check fails
3
+ # (docs/documentation-rules.md, section 7). Runs `scripts/env.sh fast`, the CHECK_FAST command of
4
+ # .claude/harness.conf, only when a file other than Markdown differs from the base of the change
5
+ # (scripts/lib/git-base.sh), committed or not, untracked included. It skips when CHECK_FAST is
6
+ # empty or is the default scripts/check-docs.sh, which stop-check-docs.sh already runs for
7
+ # Markdown. Exit 2 sends the agent back with the command's output, up to the Ralph cap.
8
+ set -uo pipefail
9
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
10
+ [ -n "$root" ] || exit 0
11
+ cd "$root" || exit 0
12
+ # During an unresolved merge conflict (a template update) the checks cannot pass yet; let the
13
+ # agent stop and ask instead of looping.
14
+ if [ -n "$(git ls-files -u 2>/dev/null)" ]; then
15
+ echo "Unresolved merge conflicts: checks resume once they are resolved (docs/contributing.md, section 5)." >&2
16
+ exit 0
17
+ fi
18
+ # shellcheck source=scripts/lib/harness-conf.sh
19
+ . scripts/lib/harness-conf.sh 2>/dev/null || exit 0
20
+ case "$(conf_get CHECK_FAST)" in
21
+ ''|scripts/check-docs.sh) exit 0 ;;
22
+ esac
23
+ # shellcheck source=scripts/lib/git-base.sh
24
+ . scripts/lib/git-base.sh
25
+ base=$(resolve_base 2>/dev/null) || exit 0
26
+ changed=$( { git diff --name-only "$base" -- . ':(exclude)*.md'; git ls-files --others --exclude-standard -- . ':(exclude)*.md'; } 2>/dev/null | head -n 1)
27
+ [ -n "$changed" ] || exit 0
28
+ RALPH_SESSION=$(node scripts/lib/json.mjs get session_id none 2>/dev/null || echo none)
29
+ if out=$(scripts/env.sh fast 2>&1); then
30
+ ralph_pass fast
31
+ exit 0
32
+ fi
33
+ ralph_block fast "$(printf 'The fast check (CHECK_FAST in .claude/harness.conf) failed; fix it before finishing (AGENTS.md, section 6):\n%s' "$(printf '%s\n' "$out" | tail -n 60)")"
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: Claude Code may not finish a turn while a template-owned file differs from the template
3
+ # or AGENTS.md lost its project block (docs/documentation-rules.md, section 9). Exit 2 sends the
4
+ # agent back with the check output. DOCS_GUARD=off disables it, as for the protect hook.
5
+ set -uo pipefail
6
+ [ "${DOCS_GUARD:-on}" = "off" ] && exit 0
7
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
8
+ [ -n "$root" ] || exit 0
9
+ cd "$root" || exit 0
10
+ # During an unresolved merge conflict (a template update) the checks cannot pass yet; let the
11
+ # agent stop and ask instead of looping.
12
+ if [ -n "$(git ls-files -u 2>/dev/null)" ]; then
13
+ echo "Unresolved merge conflicts: checks resume once they are resolved (docs/contributing.md, section 5)." >&2
14
+ exit 0
15
+ fi
16
+ RALPH_SESSION=$(node scripts/lib/json.mjs get session_id none 2>/dev/null || echo none)
17
+ # shellcheck source=scripts/lib/harness-conf.sh
18
+ . scripts/lib/harness-conf.sh 2>/dev/null || { ralph_block() { printf '%s\n' "$2" >&2; exit 2; }; ralph_pass() { :; }; }
19
+ if out=$(scripts/check-template.sh 2>&1); then
20
+ ralph_pass template
21
+ exit 0
22
+ fi
23
+ ralph_block template "$(printf 'Template checks failed; restore the template-owned files before finishing (docs/documentation-rules.md, section 9):\n%s' "$out")"
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: runs the project's own Stop hooks, .claude/hooks/project/stop/*.sh in name order, with
3
+ # the same input (docs/documentation-rules.md, section 9). A project hook blocks the agent by
4
+ # exiting with 2; its output goes to the agent. A hook that is not executable blocks too, because
5
+ # a silent gate is worse than a loud one. Other exit codes are reported without blocking.
6
+ set -uo pipefail
7
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
8
+ [ -n "$root" ] || exit 0
9
+ cd "$root" || exit 0
10
+ shopt -s nullglob
11
+ input=$(cat)
12
+ RALPH_SESSION=$(printf '%s' "$input" | node scripts/lib/json.mjs get session_id none 2>/dev/null || echo none)
13
+ # shellcheck source=scripts/lib/harness-conf.sh
14
+ . scripts/lib/harness-conf.sh 2>/dev/null || { ralph_block() { printf '%s\n' "$2" >&2; exit 2; }; ralph_pass() { :; }; }
15
+ for h in "$root"/.claude/hooks/project/stop/*.sh; do
16
+ if [ ! -x "$h" ]; then
17
+ printf 'Project Stop hook %s is not executable; run chmod +x and commit it.\n' "$h" >&2
18
+ exit 2
19
+ fi
20
+ out=$(printf '%s' "$input" | "$h" 2>&1)
21
+ code=$?
22
+ [ "$code" -eq 2 ] && ralph_block "project-$(basename "$h" .sh)" "$out"
23
+ [ -z "$out" ] || printf '%s\n' "$out" >&2
24
+ ralph_pass "project-$(basename "$h" .sh)"
25
+ [ "$code" -eq 0 ] || printf 'Project Stop hook %s exited with %s\n' "$h" "$code" >&2
26
+ done
27
+ exit 0
@@ -0,0 +1,102 @@
1
+ # Path classes of this project (docs/documentation-rules.md, sections 8 and 9): one
2
+ # "<class> <glob>" per line, a shell glob relative to the repository root, in which * also matches
3
+ # /. A path takes the highest class among the lines that match it and the template's built-in
4
+ # ones (approval for the rules, the map, ADRs on main, and the generated manifest, answers file,
5
+ # and settings). A path that nothing
6
+ # matches is low when it is Markdown, otherwise normal. This file belongs to the project; a
7
+ # change to it is class approval.
8
+ # approval the owner approves the edit first: the protect hook asks, a headless run is denied
9
+ # high with the dual_review module, the Santa loop before the pull request
10
+ # gate listed under "Gate files" in the pull request, reviewed by the owner after the merge
11
+ # normal with the dual_review module, one reviewer before the pull request
12
+ # low no reviewer before the pull request
13
+
14
+ # Secrets and tools with irreversible effects.
15
+ approval .env
16
+ approval .env.*
17
+ approval *.pem
18
+ approval *.key
19
+
20
+ # Security, access, data migrations, money, public contracts. Add the project's own, such as
21
+ # policy or limits.
22
+ high *auth/*
23
+ high *security/*
24
+ high *secrets/*
25
+ high *migrations/*
26
+ high *billing/*
27
+ high *payments/*
28
+ high contracts/*
29
+
30
+ # Gates: workflows, the project's checks and Stop hooks, and the harness settings.
31
+ gate .github/workflows/*
32
+ gate scripts/checks.d/*
33
+ gate .claude/hooks/project/*
34
+ gate .claude/harness.conf
35
+
36
+ # Gates of any stack: dependency updates, pre-commit hooks, task runners, and tool versions. The
37
+ # `*/` lines gate the files of Make, just, and Task in subdirectories too, and `*.mk` every file
38
+ # with that extension; tool versions and mise are gated at the root only.
39
+ gate .github/dependabot.y*ml
40
+ gate renovate.json*
41
+ gate .github/renovate.json*
42
+ gate .renovaterc*
43
+ gate .pre-commit-config.yaml
44
+ gate Makefile
45
+ gate */Makefile
46
+ gate makefile
47
+ gate */makefile
48
+ gate GNUmakefile
49
+ gate */GNUmakefile
50
+ gate *.mk
51
+ gate justfile
52
+ gate */justfile
53
+ gate Justfile
54
+ gate */Justfile
55
+ gate .justfile
56
+ gate */.justfile
57
+ gate Taskfile.y*ml
58
+ gate */Taskfile.y*ml
59
+ gate Taskfile.dist.y*ml
60
+ gate */Taskfile.dist.y*ml
61
+ gate taskfile.y*ml
62
+ gate */taskfile.y*ml
63
+ gate taskfile.dist.y*ml
64
+ gate */taskfile.dist.y*ml
65
+ gate .tool-versions
66
+ gate mise.toml
67
+ gate mise.*.toml
68
+ gate .mise.toml
69
+ gate .mise.*.toml
70
+ gate mise/config.toml
71
+ gate .mise/config.toml
72
+ gate .config/mise.toml
73
+ gate .config/mise/*
74
+
75
+ # Dependency manifests and lock files, and the configuration of linters, test runners, coverage,
76
+ # and mutation testing.
77
+ gate package.json
78
+ gate */package.json
79
+ gate package-lock.json
80
+ gate pnpm-lock.yaml
81
+ gate yarn.lock
82
+ gate pyproject.toml
83
+ gate uv.lock
84
+ gate poetry.lock
85
+ gate requirements*.txt
86
+ gate go.mod
87
+ gate go.sum
88
+ gate Cargo.toml
89
+ gate Cargo.lock
90
+ gate .eslintrc*
91
+ gate eslint.config.*
92
+ gate .prettierrc*
93
+ gate ruff.toml
94
+ gate .ruff.toml
95
+ gate pytest.ini
96
+ gate tox.ini
97
+ gate .coveragerc
98
+ gate jest.config.*
99
+ gate vitest.config.*
100
+ gate tsconfig*.json
101
+ gate stryker.conf.*
102
+ gate .golangci.yml
@@ -0,0 +1,5 @@
1
+ {
2
+ "permissions": {
3
+ "deny": []
4
+ }
5
+ }