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,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,25 @@
1
+ # Sourced by scripts/check-decisions.sh, scripts/fail-without-change.sh, the Stop hooks
2
+ # .claude/hooks/stop-check-docs.sh and .claude/hooks/stop-check-fast.sh, and
3
+ # .claude/hooks/session-start.sh.
4
+ # resolve_base [ref] prints the commit that the current change is compared with:
5
+ # - with a ref: the merge base of that ref and HEAD; an unknown ref is an error (status 2);
6
+ # - without: the merge base with the first of origin/HEAD, origin/main, and main that exists (the
7
+ # branch a pull request goes into, never the current branch's own upstream); else HEAD (only
8
+ # uncommitted changes count); else, in a repository without commits, the empty tree.
9
+ resolve_base() {
10
+ local ref
11
+ if [ -n "${1:-}" ]; then
12
+ git rev-parse --verify -q "$1^{commit}" >/dev/null || { echo "unknown base ref '$1'" >&2; return 2; }
13
+ git merge-base "$1" HEAD
14
+ return
15
+ fi
16
+ for ref in origin/HEAD origin/main main; do
17
+ git rev-parse --verify -q "$ref^{commit}" >/dev/null 2>&1 || continue
18
+ git merge-base "$ref" HEAD 2>/dev/null && return 0
19
+ done
20
+ if git rev-parse --verify -q HEAD >/dev/null; then
21
+ git rev-parse HEAD
22
+ else
23
+ git hash-object -t tree /dev/null
24
+ fi
25
+ }
@@ -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,101 @@
1
+ #!/usr/bin/env bash
2
+ # Protects the default branch on GitHub (AGENTS.md, section 4) and applies the repository settings
3
+ # the rules assume (docs/contributing.md, sections 1 and 3), with the GitHub CLI; run it once as a
4
+ # repository admin, after the first push, and again with --require-base when that applies. A change
5
+ # of repository settings is an owner action: an agent runs it only on the owner's explicit
6
+ # instruction. In this order:
7
+ # 1. Merge commits only, without squash and rebase merging, and "Automatically delete head
8
+ # branches": these work on every plan.
9
+ # 2. Secret scanning with push protection: public repositories have them; a private one may need
10
+ # GitHub Advanced Security, so a refusal is reported and the script goes on.
11
+ # 3. The repository ruleset "protect default branch": changes reach the default branch only through
12
+ # pull requests merged with a merge commit whose `check` (`docs` workflow) and `integrity`
13
+ # (`decisions` workflow) jobs pass, with --require-base also `base`, and only these: `readiness`
14
+ # signals and AI review is a sensor; it cannot be force-pushed or deleted. Updating a ruleset
15
+ # that requires `base` keeps it without --require-base while the fail-without-change workflow
16
+ # exists, drops it otherwise, and says which; a ruleset it cannot read stops the run unchanged.
17
+ # Rulesets need a public repository, or a GitHub plan that offers them for private ones;
18
+ # without one, the rule rests on the owner and review.
19
+ #
20
+ # Usage: scripts/protect-branch.sh [--dry-run] [--repo owner/name] [--require-base]
21
+ # --dry-run print the settings and the ruleset as one JSON object and change nothing
22
+ # --repo the repository; default: the one of the current directory
23
+ # --require-base also require the `base` job of the fail-without-change workflow: use it once
24
+ # that job has passed in CI on a pull request that changes a test, not as a SKIP;
25
+ # the label `tests-pass-on-base` still lets a test that passes on the base through
26
+ set -euo pipefail
27
+ cd "$(dirname "$0")/.."
28
+
29
+ dry= repo= base=
30
+ while [ $# -gt 0 ]; do
31
+ case "$1" in
32
+ --dry-run) dry=1; shift ;;
33
+ --repo) repo="${2:?--repo needs owner/name}"; shift 2 ;;
34
+ --require-base) base=1; shift ;;
35
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
36
+ esac
37
+ done
38
+ if [ -n "$base" ] && [ ! -f .github/workflows/fail-without-change.yml ]; then
39
+ echo "protect-branch: --require-base needs the fail-without-change workflow, which this project does not have" >&2
40
+ exit 2
41
+ fi
42
+
43
+ name="protect default branch"
44
+ plan() { node -e '
45
+ const [name, base] = process.argv.slice(1);
46
+ const checks = ["check", "integrity", ...(base ? ["base"] : [])].map((context) => ({ context }));
47
+ console.log(JSON.stringify({
48
+ repository: { allow_merge_commit: true, allow_squash_merge: false, allow_rebase_merge: false, delete_branch_on_merge: true },
49
+ security: { security_and_analysis: { secret_scanning: { status: "enabled" }, secret_scanning_push_protection: { status: "enabled" } } },
50
+ ruleset: {
51
+ name, target: "branch", enforcement: "active",
52
+ conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
53
+ rules: [
54
+ { type: "deletion" },
55
+ { type: "non_fast_forward" },
56
+ { type: "pull_request", parameters: { required_approving_review_count: 0, dismiss_stale_reviews_on_push: false,
57
+ require_code_owner_review: false, require_last_push_approval: false, required_review_thread_resolution: false,
58
+ allowed_merge_methods: ["merge"] } },
59
+ { type: "required_status_checks", parameters: { strict_required_status_checks_policy: false,
60
+ required_status_checks: checks } },
61
+ ],
62
+ },
63
+ }, null, 2));' "$name" "$base"; }
64
+
65
+ if [ -n "$dry" ]; then
66
+ plan
67
+ exit 0
68
+ fi
69
+ part() { plan | node -e 'console.log(JSON.stringify(JSON.parse(require("fs").readFileSync(0, "utf8"))[process.argv[1]]))' "$1"; }
70
+ command -v gh > /dev/null || { echo "protect-branch: the GitHub CLI gh is required" >&2; exit 2; }
71
+ [ -n "$repo" ] || repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
72
+
73
+ part repository | gh api --method PATCH "repos/$repo" --input - > /dev/null
74
+ echo "== $repo: merge commits only, head branches deleted after the merge"
75
+ if out=$(part security | gh api --method PATCH "repos/$repo" --input - 2>&1 > /dev/null); then
76
+ echo "== $repo: secret scanning and push protection on"
77
+ else
78
+ printf '== %s: secret scanning and push protection not set; a private repository may need GitHub Advanced Security:\n%s\n' "$repo" "$out" >&2
79
+ fi
80
+ id=$(gh api "repos/$repo/rulesets" --jq ".[] | select(.name == \"$name\") | .id" | head -n 1)
81
+ if [ -n "$id" ] && [ -z "$base" ]; then
82
+ contexts=$(gh api "repos/$repo/rulesets/$id" --jq '.rules[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context') || {
83
+ echo "protect-branch: cannot read the ruleset \"$name\" (id $id) of $repo; it is left unchanged" >&2
84
+ exit 1
85
+ }
86
+ if printf '%s\n' "$contexts" | grep -qx base; then
87
+ if [ -f .github/workflows/fail-without-change.yml ]; then
88
+ base=1
89
+ echo "== the ruleset \"$name\" requires \`base\`: kept (run with --require-base to say so)"
90
+ else
91
+ echo "== the ruleset \"$name\" requires \`base\`, which no workflow of this project reports: dropped" >&2
92
+ fi
93
+ fi
94
+ fi
95
+ if [ -n "$id" ]; then
96
+ part ruleset | gh api --method PUT "repos/$repo/rulesets/$id" --input - > /dev/null
97
+ echo "== updated the ruleset \"$name\" of $repo"
98
+ else
99
+ part ruleset | gh api --method POST "repos/$repo/rulesets" --input - > /dev/null
100
+ echo "== created the ruleset \"$name\" of $repo"
101
+ fi