create-afe-project 0.0.0-stage → 1.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 (59) hide show
  1. package/CHANGELOG.md +351 -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 +152 -0
  8. package/src/project.js +217 -0
  9. package/src/render.js +51 -0
  10. package/template/.claude/harness.conf +47 -0
  11. package/template/.claude/hooks/post-edit-check.sh +38 -0
  12. package/template/.claude/hooks/protect-docs.sh +315 -0
  13. package/template/.claude/hooks/stop-check-decisions.sh +25 -0
  14. package/template/.claude/hooks/stop-check-docs.sh +29 -0
  15. package/template/.claude/hooks/stop-check-template.sh +23 -0
  16. package/template/.claude/hooks/stop-run-project-hooks.sh +27 -0
  17. package/template/.claude/path-classes +61 -0
  18. package/template/.claude/settings.project.json +5 -0
  19. package/template/.claude/settings.template.json +76 -0
  20. package/template/.claude/skills/afe-init/SKILL.md +104 -0
  21. package/template/.claude/skills/create-pr/SKILL.md.jinja +107 -0
  22. package/template/.claude/skills/update-template/SKILL.md +40 -0
  23. package/template/.claude/skills/{% if code_review %}review-code{% endif %}/SKILL.md.jinja +46 -0
  24. package/template/.claude/skills/{% if docs_review %}docs-review{% endif %}/SKILL.md +56 -0
  25. package/template/.claude/skills/{% if dual_review %}dual-review{% endif %}/SKILL.md +58 -0
  26. package/template/.github/workflows/decisions.yml.jinja +47 -0
  27. package/template/.github/workflows/docs.yml.jinja +40 -0
  28. package/template/.github/workflows/{% if code_review %}code-review.yml{% endif %}.jinja +51 -0
  29. package/template/.github/workflows/{% if docs_review %}docs-review.yml{% endif %}.jinja +49 -0
  30. package/template/.github/workflows/{% if fail_without_change %}fail-without-change.yml{% endif %}.jinja +40 -0
  31. package/template/.github/workflows/{% if secret_scan %}secret-scan.yml{% endif %}.jinja +47 -0
  32. package/template/.markdownlint-cli2.jsonc +27 -0
  33. package/template/.template/owned.txt.jinja +63 -0
  34. package/template/AGENTS.md.jinja +91 -0
  35. package/template/CLAUDE.md +3 -0
  36. package/template/README.md.jinja +41 -0
  37. package/template/_gitignore +14 -0
  38. package/template/docs/README.md.jinja +44 -0
  39. package/template/docs/contributing.md.jinja +206 -0
  40. package/template/docs/decisions/adr-template.md +38 -0
  41. package/template/docs/documentation-rules.md.jinja +200 -0
  42. package/template/docs/testing-rules.md +80 -0
  43. package/template/scripts/check-decisions.sh +382 -0
  44. package/template/scripts/check-docs.sh +20 -0
  45. package/template/scripts/check-links.mjs +142 -0
  46. package/template/scripts/check-project.sh +27 -0
  47. package/template/scripts/check-template.sh +88 -0
  48. package/template/scripts/check.sh +21 -0
  49. package/template/scripts/env.sh +29 -0
  50. package/template/scripts/gen-decisions-index.sh +57 -0
  51. package/template/scripts/gen-settings.sh +14 -0
  52. package/template/scripts/lib/classes.sh +89 -0
  53. package/template/scripts/lib/git-base.sh +23 -0
  54. package/template/scripts/lib/harness-conf.sh +48 -0
  55. package/template/scripts/lib/json.mjs +160 -0
  56. package/template/scripts/pr-metrics.sh +80 -0
  57. package/template/scripts/protect-branch.sh +53 -0
  58. package/template/scripts/{% if acceptance_check %}check-acceptance.sh{% endif %} +47 -0
  59. 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,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,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,61 @@
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, harness settings, dependency manifests
31
+ # and lock files, and the configuration of linters, test runners, coverage, and mutation testing.
32
+ gate .github/workflows/*
33
+ gate scripts/checks.d/*
34
+ gate .claude/hooks/project/*
35
+ gate .claude/harness.conf
36
+ gate package.json
37
+ gate */package.json
38
+ gate package-lock.json
39
+ gate pnpm-lock.yaml
40
+ gate yarn.lock
41
+ gate pyproject.toml
42
+ gate uv.lock
43
+ gate poetry.lock
44
+ gate requirements*.txt
45
+ gate go.mod
46
+ gate go.sum
47
+ gate Cargo.toml
48
+ gate Cargo.lock
49
+ gate .eslintrc*
50
+ gate eslint.config.*
51
+ gate .prettierrc*
52
+ gate ruff.toml
53
+ gate .ruff.toml
54
+ gate pytest.ini
55
+ gate tox.ini
56
+ gate .coveragerc
57
+ gate jest.config.*
58
+ gate vitest.config.*
59
+ gate tsconfig*.json
60
+ gate stryker.conf.*
61
+ gate .golangci.yml
@@ -0,0 +1,5 @@
1
+ {
2
+ "permissions": {
3
+ "deny": []
4
+ }
5
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "permissions": {
3
+ "deny": [
4
+ "Read(**/.env)",
5
+ "Read(**/.env.*)",
6
+ "Read(**/*.pem)",
7
+ "Read(**/*.key)",
8
+ "Bash(git push --force*)",
9
+ "Bash(git push * --force*)",
10
+ "Bash(git push -f*)",
11
+ "Bash(git push * -f*)",
12
+ "Bash(git push -uf*)",
13
+ "Bash(git push -fu*)",
14
+ "Bash(git push * -uf*)",
15
+ "Bash(git push * -fu*)",
16
+ "Bash(git push --delete*)",
17
+ "Bash(git push * --delete*)",
18
+ "Bash(git push -d *)",
19
+ "Bash(git push * -d *)",
20
+ "Bash(git push --mirror*)",
21
+ "Bash(git push * --mirror*)",
22
+ "Bash(git push * +*)"
23
+ ]
24
+ },
25
+ "hooks": {
26
+ "PreToolUse": [
27
+ {
28
+ "matcher": "Edit|Write|MultiEdit|Bash",
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-docs.sh"
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "PostToolUse": [
38
+ {
39
+ "matcher": "Edit|Write|MultiEdit",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-check.sh",
44
+ "timeout": 120
45
+ }
46
+ ]
47
+ }
48
+ ],
49
+ "Stop": [
50
+ {
51
+ "hooks": [
52
+ {
53
+ "type": "command",
54
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-check-docs.sh",
55
+ "timeout": 120
56
+ },
57
+ {
58
+ "type": "command",
59
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-check-decisions.sh",
60
+ "timeout": 60
61
+ },
62
+ {
63
+ "type": "command",
64
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-check-template.sh",
65
+ "timeout": 30
66
+ },
67
+ {
68
+ "type": "command",
69
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-run-project-hooks.sh",
70
+ "timeout": 600
71
+ }
72
+ ]
73
+ }
74
+ ]
75
+ }
76
+ }