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,21 @@
1
+ #!/usr/bin/env bash
2
+ # All checks: the single local entry point (AGENTS.md, section 6). Runs every part even when an
3
+ # earlier one fails, then reports which parts failed.
4
+ set -uo pipefail
5
+ cd "$(dirname "$0")/.."
6
+
7
+ failed=()
8
+ run() {
9
+ echo "#### $1"
10
+ "$@" || failed+=("$1")
11
+ }
12
+ run scripts/check-docs.sh
13
+ run scripts/check-decisions.sh
14
+ run scripts/check-template.sh
15
+ run scripts/check-project.sh
16
+
17
+ if [ "${#failed[@]}" -gt 0 ]; then
18
+ echo "#### failed: ${failed[*]}"
19
+ exit 1
20
+ fi
21
+ echo "#### all checks passed"
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # The environment contract (docs/README.md, "Environment commands"; docs/contributing.md,
3
+ # section 1): one entry point for the commands an agent needs to set up, check, test end to end,
4
+ # and reset the project. `check` is scripts/check.sh; the others are the project's commands in
5
+ # .claude/harness.conf (SETUP, CHECK_FAST, E2E, RESET), run from the repository root with no
6
+ # stdin. A command that is not set prints SKIP and exits 0; otherwise the exit code is the
7
+ # command's, so a failure is machine-readable.
8
+ #
9
+ # Usage: scripts/env.sh setup|check|fast|e2e|reset
10
+ set -uo pipefail
11
+ cd "$(dirname "$0")/.."
12
+ # shellcheck source=scripts/lib/harness-conf.sh
13
+ . scripts/lib/harness-conf.sh
14
+
15
+ case "${1:-}" in
16
+ setup) key=SETUP ;;
17
+ check) echo "== env check: scripts/check.sh"; exec scripts/check.sh < /dev/null ;;
18
+ fast) key=CHECK_FAST ;;
19
+ e2e) key=E2E ;;
20
+ reset) key=RESET ;;
21
+ *) echo "usage: scripts/env.sh setup|check|fast|e2e|reset" >&2; exit 2 ;;
22
+ esac
23
+ cmd=$(conf_get "$key")
24
+ if [ -z "$cmd" ]; then
25
+ echo "SKIP: $key is not set in .claude/harness.conf"
26
+ exit 0
27
+ fi
28
+ echo "== env $1: $cmd"
29
+ bash -c "$cmd" < /dev/null
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # Prints the decision index from the front matter of the records (docs/documentation-rules.md,
3
+ # section 2).
4
+ # Usage: scripts/gen-decisions-index.sh > docs/decisions/README.md
5
+ # scripts/check-decisions.sh fails when the committed index differs from this output.
6
+ set -euo pipefail
7
+ cd "$(dirname "$0")/.."
8
+
9
+ # Prints front-matter field $1 of the record read from stdin, without surrounding quotes.
10
+ field() {
11
+ tr -d '\r' | awk -v k="$1" 'NR==1 && $0!="---" {exit} NR>1 && $0=="---" {exit}
12
+ NR>1 && index($0, k ":")==1 {
13
+ v = substr($0, length(k) + 2); sub(/^[[:space:]]+/, "", v); sub(/[[:space:]]+$/, "", v)
14
+ if (v ~ /^".*"$/) v = substr(v, 2, length(v) - 2)
15
+ print v; exit}'
16
+ }
17
+
18
+ # Prints the first level-one heading after the front matter.
19
+ title() {
20
+ awk 'NR==1 && $0=="---" {fm=1; next} fm && $0=="---" {fm=0; next}
21
+ !fm && /^# / {sub(/^# /, ""); print; exit}'
22
+ }
23
+
24
+ # Prints $1 as a table cell: "–" when empty, pipes escaped.
25
+ cell() {
26
+ local v="${1:-}"
27
+ [ -n "$v" ] || v="–"
28
+ printf '%s' "${v//|/\\|}"
29
+ }
30
+
31
+ shopt -s nullglob
32
+ records=(docs/decisions/[0-9][0-9][0-9][0-9]-*.md)
33
+
34
+ cat <<'EOF'
35
+ # Decision records
36
+
37
+ Generated by `scripts/gen-decisions-index.sh` from the front matter of the records; do not edit
38
+ by hand. Read this index first and open a record only when the task needs it. Which decisions get
39
+ a record, and where the other owner decisions live:
40
+ [`documentation-rules.md`](../documentation-rules.md), sections 3 and 5.
41
+
42
+ EOF
43
+ if [ "${#records[@]}" -eq 0 ]; then
44
+ echo "No records yet."
45
+ exit 0
46
+ fi
47
+ cat <<'EOF'
48
+ | ADR | Status | Scope | Decision | Summary | Supersedes |
49
+ | --- | --- | --- | --- | --- | --- |
50
+ EOF
51
+ for f in "${records[@]}"; do
52
+ name=$(basename "$f")
53
+ printf '| [%s](%s) | %s | %s | %s | %s | %s |\n' "${name:0:4}" "$name" \
54
+ "$(cell "$(field status < "$f")")" "$(cell "$(field scope < "$f")")" \
55
+ "$(cell "$(title < "$f")")" "$(cell "$(field summary < "$f")")" \
56
+ "$(cell "$(field supersedes < "$f" | tr -d '[]')")"
57
+ done
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # Generates .claude/settings.json (docs/documentation-rules.md, section 9) from the template's
3
+ # .claude/settings.template.json and the project's .claude/settings.project.json: objects merge
4
+ # key by key, arrays are joined without duplicates (the template's entries first), and for any
5
+ # other value the project's wins. The project's top-level "remove" holds entries to take out of
6
+ # the template's arrays, such as {"remove": {"permissions": {"deny": ["Read(**/.env.*)"]}}}.
7
+ # Claude Code reads only the generated file.
8
+ #
9
+ # Usage: scripts/gen-settings.sh [--check]
10
+ # --check write nothing; fail when .claude/settings.json differs from the merge.
11
+ set -euo pipefail
12
+ cd "$(dirname "$0")/.."
13
+ command -v node >/dev/null || { echo "gen-settings: Node.js is required" >&2; exit 2; }
14
+ exec node scripts/lib/json.mjs settings ${1:+"$1"}
@@ -0,0 +1,89 @@
1
+ # Sourced by scripts/check-decisions.sh. The path classes of docs/documentation-rules.md,
2
+ # section 8: approval, high, gate, normal, low; a path takes the highest class that applies.
3
+ # - approval, built in: AGENTS.md, the template-owned files of .template/owned.txt, the generated
4
+ # .template/owned.sha256, the answers file (.afe-answers.json, or the legacy
5
+ # .copier-answers.yml), and .claude/settings.json, the map
6
+ # docs/README.md, .claude/path-classes, .claude/settings.project.json, .mcp.json, the glob
7
+ # lines of a legacy .claude/protected-paths, any decision record that exists on the base, and
8
+ # any decision record whose status is not `proposed`;
9
+ # - then every `<class> <glob>` line of .claude/path-classes that matches;
10
+ # - a path that nothing matches is low when it is Markdown or generated, otherwise normal.
11
+
12
+ classes_file=.claude/path-classes
13
+
14
+ class_rank() {
15
+ case "$1" in approval) echo 5 ;; high) echo 4 ;; gate) echo 3 ;; normal) echo 2 ;; low) echo 1 ;; *) echo 0 ;; esac
16
+ }
17
+
18
+ # True when $1 matches a glob line of the list file $2; '#' starts a comment.
19
+ glob_listed() {
20
+ local pat
21
+ [ -f "$2" ] || return 1
22
+ while IFS= read -r pat || [ -n "$pat" ]; do
23
+ case "$pat" in ''|'#'*) continue ;; esac
24
+ # shellcheck disable=SC2254
25
+ case "$1" in $pat) return 0 ;; esac
26
+ done < "$2"
27
+ return 1
28
+ }
29
+
30
+ # True when path $1 is an approval path by a built-in rule; $2 is the base commit.
31
+ builtin_approval() {
32
+ case "$1" in
33
+ AGENTS.md|.template/owned.sha256|.afe-answers.json|.copier-answers.yml|.claude/settings.json|docs/README.md|\
34
+ .claude/path-classes|.claude/settings.project.json|.mcp.json) return 0 ;;
35
+ docs/decisions/README.md) return 1 ;;
36
+ docs/decisions/[0-9][0-9][0-9][0-9]-*.md)
37
+ git cat-file -e "$2:$1" 2>/dev/null && return 0
38
+ [ -f "$1" ] && ! head -n 8 "$1" | grep -q -E '^status:[[:space:]]*proposed' && return 0 ;;
39
+ esac
40
+ glob_listed "$1" .template/owned.txt || glob_listed "$1" .claude/protected-paths
41
+ }
42
+
43
+ # class_of <path> <base commit> [content]: prints the class of the path. With "content", approval
44
+ # is left out: the class of what the path contains, which decides its review.
45
+ class_of() {
46
+ local best=0 name= cls pat r
47
+ if [ "${3:-}" != content ] && builtin_approval "$1" "$2"; then echo approval; return; fi
48
+ if [ -f "$classes_file" ]; then
49
+ while read -r cls pat || [ -n "$cls" ]; do
50
+ case "$cls" in ''|'#'*) continue ;; esac
51
+ [ -n "$pat" ] || continue
52
+ [ "${3:-}" = content ] && [ "$cls" = approval ] && continue
53
+ # shellcheck disable=SC2254
54
+ case "$1" in $pat) ;; *) continue ;; esac
55
+ r=$(class_rank "$cls")
56
+ [ "$r" -gt "$best" ] && best=$r name=$cls
57
+ done < "$classes_file"
58
+ fi
59
+ if [ -z "$name" ]; then
60
+ case "$1" in
61
+ *.md|.template/owned.sha256|.afe-answers.json|.copier-answers.yml|.claude/settings.json) name=low ;;
62
+ *) name=normal ;;
63
+ esac
64
+ fi
65
+ echo "$name"
66
+ }
67
+
68
+ # review_level <base commit> <path>...: the review before the pull request that the paths need,
69
+ # by their highest content class: santa (high), one (gate, normal), or none (low). A template
70
+ # update needs none, because the template repository reviewed it: a change of
71
+ # the answers file whose other paths are all template-owned, generated, or seeded.
72
+ review_level() {
73
+ local mb="$1" p c best=1 update=1 answers=0
74
+ shift
75
+ for p in "$@"; do
76
+ case "$p" in
77
+ .afe-answers.json|.copier-answers.yml) answers=1 ;;
78
+ AGENTS.md|.template/owned.sha256|.claude/settings.json|docs/decisions/README.md) ;;
79
+ README.md|CLAUDE.md|.gitignore|docs/README.md|.claude/path-classes|.claude/harness.conf|\
80
+ .claude/settings.project.json|.claude/protected-paths) ;;
81
+ *) glob_listed "$p" .template/owned.txt || update=0 ;;
82
+ esac
83
+ c=$(class_rank "$(class_of "$p" "$mb" content)")
84
+ [ "$c" -gt "$best" ] && best=$c
85
+ done
86
+ if [ "$#" -eq 0 ] || { [ "$update" = 1 ] && [ "$answers" = 1 ]; } || [ "$best" -le 1 ]; then echo none
87
+ elif [ "$best" -ge 4 ]; then echo santa
88
+ else echo one; fi
89
+ }
@@ -0,0 +1,23 @@
1
+ # Sourced by scripts/check-decisions.sh and .claude/hooks/stop-check-docs.sh.
2
+ # resolve_base [ref] prints the commit that the current change is compared with:
3
+ # - with a ref: the merge base of that ref and HEAD; an unknown ref is an error (status 2);
4
+ # - without: the merge base with the first of origin/HEAD, origin/main, and main that exists (the
5
+ # branch a pull request goes into, never the current branch's own upstream); else HEAD (only
6
+ # uncommitted changes count); else, in a repository without commits, the empty tree.
7
+ resolve_base() {
8
+ local ref
9
+ if [ -n "${1:-}" ]; then
10
+ git rev-parse --verify -q "$1^{commit}" >/dev/null || { echo "unknown base ref '$1'" >&2; return 2; }
11
+ git merge-base "$1" HEAD
12
+ return
13
+ fi
14
+ for ref in origin/HEAD origin/main main; do
15
+ git rev-parse --verify -q "$ref^{commit}" >/dev/null 2>&1 || continue
16
+ git merge-base "$ref" HEAD 2>/dev/null && return 0
17
+ done
18
+ if git rev-parse --verify -q HEAD >/dev/null; then
19
+ git rev-parse HEAD
20
+ else
21
+ git hash-object -t tree /dev/null
22
+ fi
23
+ }
@@ -0,0 +1,48 @@
1
+ # Sourced by the scripts that read .claude/harness.conf (docs/contributing.md, section 3).
2
+ # conf_get KEY [default]: prints the value of the last `KEY=value` line of the file, without
3
+ # surrounding quotes, or the default when the key is missing or empty. The file is read, never run.
4
+ conf_get() {
5
+ local v=
6
+ [ -f .claude/harness.conf ] &&
7
+ v=$(tr -d '\r' < .claude/harness.conf | awk -v k="$1" 'index($0, k "=") == 1 {v = substr($0, length(k) + 2)} END {print v}')
8
+ case "$v" in \"*\") v=${v#\"}; v=${v%\"} ;; \'*\') v=${v#\'}; v=${v%\'} ;; esac
9
+ printf '%s' "${v:-${2:-}}"
10
+ }
11
+
12
+ # matching_files <globs>: prints the tracked and untracked files that match one of the
13
+ # space-separated shell globs, in which * also matches /.
14
+ matching_files() {
15
+ local f g
16
+ { git ls-files; git ls-files --others --exclude-standard; } | sort -u | while IFS= read -r f; do
17
+ set -f
18
+ for g in $1; do
19
+ # shellcheck disable=SC2254
20
+ case "$f" in $g) printf '%s\n' "$f"; break ;; esac
21
+ done
22
+ set +f
23
+ done
24
+ }
25
+
26
+ # The Ralph loop's cap in the Stop hooks (docs/contributing.md, section 3). ralph_block <name>
27
+ # <message>: below RALPH_MAX_ITERATIONS failing stops of hook <name>, prints the message and
28
+ # exits 2, which sends the agent back to fix it; at the cap, prints the message and that the cap
29
+ # is reached, resets the count, and exits 0, so the agent stops and reports the failure.
30
+ # ralph_pass <name>: resets the count when the hook's check passes. The counts live in the git
31
+ # directory, one file per hook and Claude Code session (RALPH_SESSION, the hook input's
32
+ # session_id), so an interrupted loop does not shorten the next one.
33
+ ralph_count_file() { printf '%s/ralph-stops-%s-%s' "$(git rev-parse --git-dir 2>/dev/null || echo /tmp)" "${RALPH_SESSION:-none}" "$1"; }
34
+ ralph_block() {
35
+ local f n cap
36
+ f=$(ralph_count_file "$1")
37
+ n=$(( $(cat "$f" 2>/dev/null || echo 0) + 1 ))
38
+ cap=$(conf_get RALPH_MAX_ITERATIONS 5)
39
+ printf '%s\n' "$2" >&2
40
+ if [ "$n" -ge "$cap" ]; then
41
+ rm -f "$f"
42
+ printf 'Ralph loop cap of %s reached: stop now and report the failing command and its output (docs/contributing.md, section 3).\n' "$cap" >&2
43
+ exit 0
44
+ fi
45
+ echo "$n" > "$f"
46
+ exit 2
47
+ }
48
+ ralph_pass() { rm -f "$(ralph_count_file "$1")"; }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // JSON for the hooks and scripts (docs/documentation-rules.md, section 9), with Node.js only.
3
+ //
4
+ // Usage: node scripts/lib/json.mjs <command> [arguments]
5
+ // fields <path>... read JSON from stdin; print the value at each dotted path, each
6
+ // followed by a NUL byte, an empty string when missing; the path
7
+ // @secret prints the kind of the first known token format in the text
8
+ // a PreToolUse input writes (content, new_string, edits, command)
9
+ // get <path> [default] read JSON from stdin; print the value at the path, or the default
10
+ // ask <reason> print the PreToolUse decision that asks the owner, with the reason
11
+ // deny <reason> print the PreToolUse decision that denies the action, with the reason
12
+ // settings [--check] merge .claude/settings.template.json and .claude/settings.project.json
13
+ // into .claude/settings.json (scripts/gen-settings.sh); with --check,
14
+ // write nothing and exit 1 when the file is missing or stale
15
+ // hooks print the command of every hook in .claude/settings.json, one per line
16
+ // prs <since> <separator> read `gh pr list --json number,createdAt,mergedAt,body` from stdin;
17
+ // print the count listed and the count merged before <since> on the
18
+ // first line, then for each pull request merged on or after <since> a
19
+ // line "<separator>PR <number> <hours to merge>" and its body
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
+
22
+ const stdin = () => readFileSync(0, 'utf8');
23
+ const at = (obj, path) => path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
24
+ const text = (v) => (v == null ? '' : typeof v === 'string' ? v : JSON.stringify(v));
25
+
26
+ function parse(input) {
27
+ try {
28
+ return JSON.parse(input || 'null');
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ // High-confidence token formats; a match asks the owner, it does not prove a live secret.
35
+ const SECRETS = [
36
+ ['AWS access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
37
+ ['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{60,}/],
38
+ ['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,}/],
39
+ ['Slack token', /\bxox[abposr]-[A-Za-z0-9-]{10,}/],
40
+ ['Stripe live key', /\b[rs]k_live_[A-Za-z0-9]{20,}/],
41
+ ['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
42
+ ['Anthropic API key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
43
+ ['OpenAI API key', /\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}/],
44
+ ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
45
+ ['private key', /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----/],
46
+ ];
47
+
48
+ function secret(input) {
49
+ const t = input?.tool_input ?? {};
50
+ const texts = [t.content, t.new_string, t.command, ...(Array.isArray(t.edits) ? t.edits.map((e) => e?.new_string) : [])];
51
+ const text = texts.filter((x) => typeof x === 'string').join('\n');
52
+ return SECRETS.find(([, re]) => re.test(text))?.[0] ?? '';
53
+ }
54
+
55
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
56
+
57
+ function equal(a, b) {
58
+ if (a === b) return true;
59
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, i) => equal(x, b[i]));
60
+ if (isObject(a) && isObject(b)) {
61
+ const ka = Object.keys(a);
62
+ return ka.length === Object.keys(b).length && ka.every((k) => k in b && equal(a[k], b[k]));
63
+ }
64
+ return false;
65
+ }
66
+
67
+ // Objects merge by key, arrays are a union in order without duplicates, anything else: b wins.
68
+ function merge(a, b) {
69
+ if (isObject(a) && isObject(b)) {
70
+ const out = {};
71
+ for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
72
+ out[k] = k in a && k in b ? merge(a[k], b[k]) : k in b ? b[k] : a[k];
73
+ }
74
+ return out;
75
+ }
76
+ if (Array.isArray(a) && Array.isArray(b)) {
77
+ const out = [];
78
+ for (const x of [...a, ...b]) if (!out.some((y) => equal(x, y))) out.push(x);
79
+ return out;
80
+ }
81
+ return b;
82
+ }
83
+
84
+ // Takes out of a what r names: keys recurse, array elements equal to one of r are removed.
85
+ function drop(a, r) {
86
+ if (isObject(a) && isObject(r)) {
87
+ const out = { ...a };
88
+ for (const k of Object.keys(r)) if (k in out) out[k] = drop(out[k], r[k]);
89
+ return out;
90
+ }
91
+ if (Array.isArray(a) && Array.isArray(r)) return a.filter((x) => !r.some((y) => equal(x, y)));
92
+ return a;
93
+ }
94
+
95
+ function settings(check) {
96
+ const read = (f) => JSON.parse(readFileSync(f, 'utf8'));
97
+ const template = read('.claude/settings.template.json');
98
+ const project = existsSync('.claude/settings.project.json') ? read('.claude/settings.project.json') ?? {} : {};
99
+ const { remove = {}, ...rest } = project;
100
+ const merged = drop(merge(template, rest), remove);
101
+ const file = '.claude/settings.json';
102
+ if (check) {
103
+ if (!existsSync(file) || !equal(parse(readFileSync(file, 'utf8')), merged)) {
104
+ console.log('FAIL: .claude/settings.json is missing or stale; run scripts/gen-settings.sh');
105
+ process.exit(1);
106
+ }
107
+ return;
108
+ }
109
+ writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`);
110
+ }
111
+
112
+ function prs(since, sep) {
113
+ const list = parse(stdin()) ?? [];
114
+ const out = [`${list.length} ${list.filter((p) => p.mergedAt < since).length}`];
115
+ for (const p of list) {
116
+ if (!(p.mergedAt >= since)) continue;
117
+ const hours = (Date.parse(p.mergedAt) - Date.parse(p.createdAt ?? p.mergedAt)) / 3600000;
118
+ out.push(`${sep}PR ${p.number} ${hours}`, (p.body ?? '').replace(/\r/g, ''));
119
+ }
120
+ process.stdout.write(`${out.join('\n')}\n`);
121
+ }
122
+
123
+ const [command, ...args] = process.argv.slice(2);
124
+ switch (command) {
125
+ case 'fields': {
126
+ const data = parse(stdin());
127
+ process.stdout.write(args.map((p) => `${p === '@secret' ? secret(data) : text(at(data, p))}\0`).join(''));
128
+ break;
129
+ }
130
+ case 'get': {
131
+ const v = at(parse(stdin()), args[0]);
132
+ process.stdout.write(v == null || v === '' ? (args[1] ?? '') : text(v));
133
+ break;
134
+ }
135
+ case 'ask':
136
+ case 'deny':
137
+ process.stdout.write(
138
+ `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: command, permissionDecisionReason: args[0] } })}\n`,
139
+ );
140
+ break;
141
+ case 'settings':
142
+ settings(args[0] === '--check');
143
+ break;
144
+ case 'hooks': {
145
+ const s = parse(existsSync('.claude/settings.json') ? readFileSync('.claude/settings.json', 'utf8') : '');
146
+ const commands = Object.values(s?.hooks ?? {})
147
+ .flat()
148
+ .flatMap((m) => m?.hooks ?? [])
149
+ .map((h) => h?.command)
150
+ .filter((c) => typeof c === 'string');
151
+ process.stdout.write(commands.map((c) => `${c.replace(/\n/g, ' ')}\n`).join(''));
152
+ break;
153
+ }
154
+ case 'prs':
155
+ prs(args[0] ?? '', args[1] ?? '\x1f');
156
+ break;
157
+ default:
158
+ console.error(`json.mjs: unknown command ${command ?? '(none)'}`);
159
+ process.exit(2);
160
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # Aggregates the "Metrics" tables of merged pull requests (docs/contributing.md, section 3): the
3
+ # findings of each sensor by class and how they were answered; the first-pass result of
4
+ # scripts/check.sh; Ralph iterations, Santa rounds, owner interventions, and files outside the
5
+ # scope, each with its median and maximum, since a distribution says more than a sum; the
6
+ # regression rate (pull requests that repair another's behaviour); and the time from opening to
7
+ # merge, which gh reports for every pull request. It measures which rules and checks earn their
8
+ # keep, so that one that never fires can be proposed for removal. Only table rows inside
9
+ # "## Metrics" are read; a pull request without that section counts for the time to merge only.
10
+ # Needs the GitHub CLI (`gh`, signed in) and Node.js.
11
+ #
12
+ # Usage: scripts/pr-metrics.sh [--since YYYY-MM-DD] [--limit N] [--repo owner/name]
13
+ # --since only pull requests merged on or after this date; default: all that gh lists
14
+ # --limit how many merged pull requests gh lists, newest first; default 500. When all of them
15
+ # are in the period, older ones may be missing, and the script says so.
16
+ # --repo the repository; default: the one of the current directory
17
+ set -uo pipefail
18
+
19
+ since= limit=500 repo=()
20
+ while [ $# -gt 0 ]; do
21
+ case "$1" in
22
+ --since) since="${2:?--since needs a date}"; shift 2 ;;
23
+ --limit) limit="${2:?--limit needs a number}"; shift 2 ;;
24
+ --repo) repo=(--repo "${2:?--repo needs owner/name}"); shift 2 ;;
25
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
26
+ esac
27
+ done
28
+ command -v gh >/dev/null || { echo "pr-metrics: the GitHub CLI gh is required" >&2; exit 2; }
29
+ command -v node >/dev/null || { echo "pr-metrics: Node.js is required" >&2; exit 2; }
30
+
31
+ json=$(gh pr list --state merged --limit "$limit" --json number,createdAt,mergedAt,body ${repo[@]+"${repo[@]}"}) ||
32
+ { echo "pr-metrics: gh pr list failed" >&2; exit 2; }
33
+ sep=$(printf '\037')
34
+ feed=$(printf '%s' "$json" | node "$(dirname "$0")/lib/json.mjs" prs "$since" "$sep") || { echo "pr-metrics: cannot read the gh output" >&2; exit 2; }
35
+ read -r listed outside <<< "${feed%%$'\n'*}"
36
+ body=; case "$feed" in *$'\n'*) body=${feed#*$'\n'} ;; esac
37
+ printf '%s\n' "$body" |
38
+ awk -F'|' -v sep="$sep" -v since="${since:-the first listed}" '
39
+ function add(name, v) { cnt[name]++; val[name, cnt[name]] = v + 0; sum[name] += v }
40
+ function stats(name, i, j, t, n, a) {
41
+ n = cnt[name]; if (!n) return "none"
42
+ for (i = 1; i <= n; i++) a[i] = val[name, i]
43
+ for (i = 2; i <= n; i++) { t = a[i]; for (j = i - 1; j >= 1 && a[j] > t; j--) a[j + 1] = a[j]; a[j + 1] = t }
44
+ return sprintf("total %g, median %g, max %g, in %d pull requests", sum[name], (n % 2 ? a[(n + 1) / 2] : (a[n / 2] + a[n / 2 + 1]) / 2), a[n], n)
45
+ }
46
+ index($0, sep "PR ") == 1 { prs++; insec = 0; split(substr($0, length(sep) + 4), p, " "); add("hours to merge", sprintf("%.1f", p[2])); next }
47
+ /^## / { l = $0; sub(/[ \t]+$/, "", l); insec = (l == "## Metrics"); if (insec && !(prs in withm)) { withm[prs] = 1; nwith++ }; next }
48
+ !insec || !/^[ \t]*\|/ { next }
49
+ {
50
+ n = 0
51
+ for (i = 2; i < NF; i++) { c = $i; gsub(/^[ \t]+|[ \t]+$/, "", c); cell[++n] = c }
52
+ if (cell[1] == "Sensor" || cell[1] == "Measure" || cell[1] ~ /^:?-/) next
53
+ if (n == 6 && cell[2] ~ /^(blocker|suggestion)$/) {
54
+ k = cell[1] SUBSEP cell[2]
55
+ if (!(k in P)) order[++nk] = k
56
+ F[k] += cell[3]; X[k] += cell[4]; L[k] += cell[5]; D[k] += cell[6]; P[k]++
57
+ S[cell[1]] += cell[3]; if (!(cell[1] in seen)) { seen[cell[1]] = 1; sensors[++ns] = cell[1] }
58
+ } else if (n == 2) {
59
+ if (cell[1] == "First check.sh") { if (cell[2] ~ /^pass/) first_pass++; first_n++ }
60
+ else if (cell[1] == "Regression of") { reg_n++; if (cell[2] !~ /^none/) reg++ }
61
+ else if (cell[1] ~ /^(Ralph iterations|Santa rounds|Owner interventions|Files outside the scope)$/) add(tolower(cell[1]), cell[2])
62
+ }
63
+ }
64
+ END {
65
+ printf "== merged pull requests since %s: %d, with a Metrics section: %d\n", since, prs, nwith
66
+ printf "%-24s %-10s %6s %6s %6s %8s %4s\n", "sensor", "class", "found", "fixed", "limit", "declined", "PRs"
67
+ for (i = 1; i <= nk; i++) {
68
+ split(order[i], kk, SUBSEP)
69
+ printf "%-24s %-10s %6d %6d %6d %8d %4d\n", kk[1], kk[2], F[order[i]], X[order[i]], L[order[i]], D[order[i]], P[order[i]]
70
+ }
71
+ printf "first check.sh passed: %d of %d pull requests\n", first_pass, first_n
72
+ printf "regression rate: %d of %d pull requests repair another one\n", reg, reg_n
73
+ split("ralph iterations|santa rounds|owner interventions|files outside the scope|hours to merge", names, "|")
74
+ for (i = 1; i <= 5; i++) printf "%s: %s\n", names[i], stats(names[i])
75
+ for (i = 1; i <= ns; i++) if (S[sensors[i]] == 0) quiet = quiet (quiet == "" ? "" : ", ") sensors[i]
76
+ printf "== sensors without a finding in the period: %s\n", (quiet == "" ? "none" : quiet)
77
+ }'
78
+ if [ "$listed" -ge "$limit" ] && [ "$outside" -eq 0 ]; then
79
+ echo "WARNING: gh listed $listed pull requests, the --limit, all in the period; older ones in the period may be missing. Raise --limit."
80
+ fi
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # Protects the default branch on GitHub (AGENTS.md, section 4): changes reach it only through pull
3
+ # requests whose `docs` and `decisions` integrity checks pass, and it cannot be force-pushed or
4
+ # deleted. It creates or updates the repository ruleset "protect default branch" with the GitHub
5
+ # CLI; run it once as a repository admin, after the first push. A change of repository settings
6
+ # is an owner action: an agent runs it only on the owner's explicit instruction.
7
+ #
8
+ # Usage: scripts/protect-branch.sh [--dry-run] [--repo owner/name]
9
+ # --dry-run print the ruleset and change nothing
10
+ # --repo the repository; default: the one of the current directory
11
+ # Rulesets need a public repository, or a GitHub plan that offers them for private ones.
12
+ set -euo pipefail
13
+ cd "$(dirname "$0")/.."
14
+
15
+ dry= repo=
16
+ while [ $# -gt 0 ]; do
17
+ case "$1" in
18
+ --dry-run) dry=1; shift ;;
19
+ --repo) repo="${2:?--repo needs owner/name}"; shift 2 ;;
20
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
21
+ esac
22
+ done
23
+
24
+ name="protect default branch"
25
+ ruleset=$(node -e '
26
+ const [name] = process.argv.slice(1);
27
+ console.log(JSON.stringify({
28
+ name, target: "branch", enforcement: "active",
29
+ conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
30
+ rules: [
31
+ { type: "deletion" },
32
+ { type: "non_fast_forward" },
33
+ { type: "pull_request", parameters: { required_approving_review_count: 0, dismiss_stale_reviews_on_push: false,
34
+ require_code_owner_review: false, require_last_push_approval: false, required_review_thread_resolution: false } },
35
+ { type: "required_status_checks", parameters: { strict_required_status_checks_policy: false,
36
+ required_status_checks: [{ context: "check" }, { context: "integrity" }] } },
37
+ ],
38
+ }, null, 2));' "$name")
39
+
40
+ if [ -n "$dry" ]; then
41
+ printf '%s\n' "$ruleset"
42
+ exit 0
43
+ fi
44
+ command -v gh > /dev/null || { echo "protect-branch: the GitHub CLI gh is required" >&2; exit 2; }
45
+ [ -n "$repo" ] || repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
46
+ id=$(gh api "repos/$repo/rulesets" --jq ".[] | select(.name == \"$name\") | .id" | head -n 1)
47
+ if [ -n "$id" ]; then
48
+ printf '%s' "$ruleset" | gh api --method PUT "repos/$repo/rulesets/$id" --input - > /dev/null
49
+ echo "== updated the ruleset \"$name\" of $repo"
50
+ else
51
+ printf '%s' "$ruleset" | gh api --method POST "repos/$repo/rulesets" --input - > /dev/null
52
+ echo "== created the ruleset \"$name\" of $repo"
53
+ fi
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # Acceptance check, the `acceptance_check` module (docs/contributing.md, section 3): every
3
+ # identifier that the section "Acceptance and test plan" of a pull request description names, by
4
+ # ID_PATTERN of .claude/harness.conf, appears as a whole word in at least one test file of
5
+ # TEST_GLOBS. It does not judge whether that test proves the identifier; the review does.
6
+ # scripts/check-decisions.sh --ready runs it. It skips when TEST_GLOBS is empty or the section
7
+ # names no identifier. A project may replace it with a stricter check in scripts/checks.d/.
8
+ #
9
+ # Usage: scripts/check-acceptance.sh --pr-body <file>
10
+ set -uo pipefail
11
+ cd "$(dirname "$0")/.."
12
+ # shellcheck source=scripts/lib/harness-conf.sh
13
+ . scripts/lib/harness-conf.sh
14
+
15
+ body=
16
+ while [ $# -gt 0 ]; do
17
+ case "$1" in
18
+ --pr-body) body="${2:?--pr-body needs a file}"; shift 2 ;;
19
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
20
+ esac
21
+ done
22
+ [ -f "$body" ] || { echo "check-acceptance: --pr-body <file> is required" >&2; exit 2; }
23
+
24
+ echo "== acceptance: every identifier of the description is carried by a test file"
25
+ pattern=$(conf_get ID_PATTERN '(REQ|AC|INV)-[0-9]+')
26
+ globs=$(conf_get TEST_GLOBS)
27
+ [ -n "$globs" ] || { echo "SKIP: TEST_GLOBS is empty in .claude/harness.conf"; exit 0; }
28
+ ids=$(tr -d '\r' < "$body" |
29
+ awk '/^## / {l = $0; sub(/[[:space:]]+$/, "", l); s = (l == "## Acceptance and test plan"); next} s' |
30
+ grep -o -E -- "$pattern" | sort -u)
31
+ [ -n "$ids" ] || { echo "SKIP: the Acceptance section names no identifier"; exit 0; }
32
+ files=$(matching_files "$globs")
33
+
34
+ failures=0
35
+ while IFS= read -r id; do
36
+ hit=$(printf '%s\n' "$files" | while IFS= read -r f; do
37
+ [ -f "$f" ] && grep -q -F -w -- "$id" "$f" 2>/dev/null && { printf '%s\n' "$f"; break; }
38
+ done)
39
+ if [ -n "$hit" ]; then
40
+ echo "ok: $id in $hit"
41
+ else
42
+ echo "FAIL: $id: no test file of TEST_GLOBS carries it (docs/testing-rules.md, section 1)"
43
+ failures=$((failures + 1))
44
+ fi
45
+ done <<< "$ids"
46
+ [ "$failures" -eq 0 ] || { echo "== acceptance check failed: $failures"; exit 1; }
47
+ echo "== acceptance check passed"