forge-orkes 0.42.0 → 0.46.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 (35) hide show
  1. package/bin/create-forge.js +138 -1
  2. package/package.json +1 -1
  3. package/template/.claude/agents/doc-reviewer.md +115 -0
  4. package/template/.claude/agents/performance-reviewer.md +138 -0
  5. package/template/.claude/agents/security-reviewer.md +163 -0
  6. package/template/.claude/hooks/README.md +39 -0
  7. package/template/.claude/hooks/block-dangerous-commands.sh +158 -0
  8. package/template/.claude/hooks/forge-active-skill-guard.sh +44 -0
  9. package/template/.claude/hooks/forge-reserve.sh +162 -0
  10. package/template/.claude/hooks/format-on-save.sh +95 -0
  11. package/template/.claude/hooks/protect-files.sh +101 -0
  12. package/template/.claude/hooks/scan-secrets.sh +87 -0
  13. package/template/.claude/hooks/tests/README.md +76 -0
  14. package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +376 -0
  15. package/template/.claude/hooks/tests/cases/protect-files.cases.json +222 -0
  16. package/template/.claude/hooks/tests/cases/scan-secrets.cases.json +218 -0
  17. package/template/.claude/hooks/tests/cases/warn-large-files.cases.json +146 -0
  18. package/template/.claude/hooks/tests/forge-reserve.test.sh +121 -0
  19. package/template/.claude/hooks/tests/run.sh +118 -0
  20. package/template/.claude/hooks/warn-large-files.sh +71 -0
  21. package/template/.claude/rules/README.md +63 -0
  22. package/template/.claude/rules/agent-discipline.md +14 -0
  23. package/template/.claude/settings.json +69 -1
  24. package/template/.claude/skills/architecting/SKILL.md +1 -1
  25. package/template/.claude/skills/chief-of-staff/SKILL.md +14 -0
  26. package/template/.claude/skills/executing/SKILL.md +16 -0
  27. package/template/.claude/skills/forge/SKILL.md +12 -1
  28. package/template/.claude/skills/initializing/SKILL.md +4 -0
  29. package/template/.claude/skills/planning/SKILL.md +14 -2
  30. package/template/.claude/skills/reviewing/SKILL.md +2 -0
  31. package/template/.claude/skills/verifying/SKILL.md +19 -8
  32. package/template/.forge/FORGE.md +32 -15
  33. package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
  34. package/template/.forge/migrations/0.44.0-desire-paths.md +57 -0
  35. package/template/.forge/migrations/0.46.0-id-reservation-ledger.md +44 -0
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # Forge safety guardrail — blocks destructive shell commands.
3
+ # PreToolUse hook for Bash. Exit 2 = block. Exit 0 = allow.
4
+ #
5
+ # Deterministic belt-and-braces: this fires regardless of what the model decides,
6
+ # so it catches the cases a model-driven skill (securing) would miss when skipped.
7
+ #
8
+ # Configurable via env:
9
+ # CLAUDE_PROTECTED_BRANCHES comma list of branches push is refused to.
10
+ # Default: main,master + git init.defaultBranch.
11
+ # Set to "" (empty) to DISABLE the protected-branch
12
+ # push check only — force-push, fs, db, and publish
13
+ # guards still apply. Forge's own dev repo does this
14
+ # because framework work legitimately lands on main.
15
+
16
+ set -uo pipefail
17
+
18
+ emit_deny() {
19
+ local reason="${1//\"/\\\"}"
20
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
21
+ exit 2
22
+ }
23
+
24
+ if ! command -v jq >/dev/null 2>&1; then
25
+ emit_deny "jq is required for command protection hooks but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
26
+ fi
27
+
28
+ INPUT=$(cat)
29
+ COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
30
+ [ -z "$COMMAND" ] && exit 0
31
+
32
+ contains_cmd() { printf '%s' "$COMMAND" | grep -qE "$1"; }
33
+ contains_icmd() { printf '%s' "$COMMAND" | grep -qiE "$1"; }
34
+
35
+ # ── Protected branch list ────────────────────────────────────────────────
36
+ # Use ${VAR-default} (no colon) so an explicitly-empty value disables the check
37
+ # while an unset value falls back to the default.
38
+ DEFAULT_BRANCHES="main,master"
39
+ if GIT_DEFAULT=$(git config --get init.defaultBranch 2>/dev/null) && [ -n "$GIT_DEFAULT" ]; then
40
+ DEFAULT_BRANCHES="$DEFAULT_BRANCHES,$GIT_DEFAULT"
41
+ fi
42
+ PROTECTED_BRANCHES="${CLAUDE_PROTECTED_BRANCHES-$DEFAULT_BRANCHES}"
43
+
44
+ # ── Git push protections ────────────────────────────────────────────────
45
+ if contains_cmd '(^|[;&|()]+[[:space:]]*)git[[:space:]]+push'; then
46
+ # Protected-branch checks are skipped when the list is empty.
47
+ if [ -n "$PROTECTED_BRANCHES" ]; then
48
+ BR_REGEX=$(printf '%s' "$PROTECTED_BRANCHES" | tr ',' '\n' | awk 'NF{printf "%s%s",sep,$0; sep="|"}')
49
+ # Explicit refspec to a protected branch (origin main, :main, HEAD:main)
50
+ if contains_cmd "git[[:space:]]+push[[:space:]]+[^[:space:]]+[[:space:]]+([^[:space:]]*:)?($BR_REGEX)(\$|[[:space:]])"; then
51
+ MATCHED_BRANCH=$(printf '%s' "$COMMAND" | grep -oE "($BR_REGEX)(\$|[[:space:]])" | head -1 | tr -d '[:space:]')
52
+ emit_deny "Blocked: push to protected branch '${MATCHED_BRANCH:-main}'. Use a feature branch and open a PR."
53
+ fi
54
+ if contains_cmd "git[[:space:]]+push.*:($BR_REGEX)(\$|[[:space:]])"; then
55
+ MATCHED_BRANCH=$(printf '%s' "$COMMAND" | grep -oE ":($BR_REGEX)(\$|[[:space:]])" | head -1 | tr -d ': [:space:]')
56
+ emit_deny "Blocked: push to protected branch '${MATCHED_BRANCH:-main}' via refspec. Use a feature branch and open a PR."
57
+ fi
58
+ # Bare `git push` while on protected branch
59
+ if contains_cmd 'git[[:space:]]+push[[:space:]]*($|[;&|])'; then
60
+ CURRENT=$(git branch --show-current 2>/dev/null || true)
61
+ if [ -n "$CURRENT" ] && printf '%s' ",$PROTECTED_BRANCHES," | grep -q ",$CURRENT,"; then
62
+ emit_deny "Blocked: you are on '$CURRENT' (a protected branch). Switch to a feature branch."
63
+ fi
64
+ fi
65
+ fi
66
+ # Force push (but allow --force-with-lease). Always enforced — Forge never force-pushes.
67
+ if contains_cmd 'git[[:space:]]+push([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[a-zA-Z]*f[a-zA-Z]*|--force)([[:space:]=]|$)' \
68
+ && ! contains_cmd '\-\-force-with-lease'; then
69
+ emit_deny "Blocked: force push is not allowed. Use --force-with-lease if you must overwrite remote."
70
+ fi
71
+ fi
72
+
73
+ # ── Destructive filesystem operations ───────────────────────────────────
74
+ # rm -rf targeting root, home, $HOME, $VAR (any unresolved expansion), or parent traversal.
75
+ CMD_NOQUOTE=$(printf '%s' "$COMMAND" | tr -d "'\"")
76
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+(/([[:space:]]|\*|$)|~|\$HOME|\$[A-Za-z_][A-Za-z0-9_]*|\.\./\.\.)' ; then
77
+ emit_deny "Blocked: recursive force-delete on /, ~, \$HOME, an unresolved \$VAR, or .../.. path. Specify a concrete safe target."
78
+ fi
79
+ # rm -rf /usr, /etc, /var, /bin, etc.
80
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+/(usr|etc|var|bin|sbin|lib|opt|root|boot)([[:space:]/]|$)'; then
81
+ emit_deny "Blocked: recursive delete targeting a system directory."
82
+ fi
83
+ # rm -rf where the target is produced by $(...) or `...` command substitution.
84
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+[^;&|]*(\$\(|`)'; then
85
+ emit_deny "Blocked: recursive force-delete with a shell-substituted target (\$(...) or \`...\`). Pass a literal path."
86
+ fi
87
+
88
+ # ── Dangerous database operations ───────────────────────────────────────
89
+ if contains_icmd 'DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)[[:space:]]+'; then
90
+ emit_deny "Blocked: DROP TABLE/DATABASE/SCHEMA detected. Run manually if intended."
91
+ fi
92
+ # DELETE FROM without a WHERE on the SAME statement (split on ';').
93
+ if printf '%s\n' "$COMMAND" | awk '
94
+ BEGIN { IGNORECASE=1; RS=";" }
95
+ /DELETE[[:space:]]+FROM[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*/ {
96
+ if ($0 !~ /WHERE/) { print "BAD"; exit }
97
+ }
98
+ ' | grep -q BAD; then
99
+ emit_deny "Blocked: DELETE FROM without a WHERE clause. Add a WHERE or run manually."
100
+ fi
101
+ if contains_icmd 'TRUNCATE[[:space:]]+TABLE'; then
102
+ emit_deny "Blocked: TRUNCATE TABLE detected. Run manually if intended."
103
+ fi
104
+
105
+ # ── Framework-specific destructive migration commands ───────────────────
106
+ # These drop data. Block — require manual invocation against a dev DB.
107
+ if contains_icmd '(php[[:space:]]+artisan[[:space:]]+(migrate:fresh|migrate:reset|migrate:refresh|db:wipe)|(rails|rake)[[:space:]]+db:(drop|reset)|prisma[[:space:]]+migrate[[:space:]]+reset|sequelize[[:space:]]+db:drop)([[:space:]]|$)'; then
108
+ emit_deny "Blocked: destructive migration command detected (drops/recreates the database). Run manually, and only against a dev DB."
109
+ fi
110
+
111
+ # ── Dangerous system commands ───────────────────────────────────────────
112
+ # chmod: any world-writable/universal mode (0?777 or a+rwx)
113
+ if contains_cmd 'chmod([[:space:]]+-[a-zA-Z]+)*[[:space:]]+0?777([[:space:]]|$)' \
114
+ || contains_cmd 'chmod([[:space:]]+-[a-zA-Z]+)*[[:space:]]+a\+rwx([[:space:]]|$)'; then
115
+ emit_deny "Blocked: chmod 777 / a+rwx grants everyone full access. Use restrictive perms."
116
+ fi
117
+
118
+ # curl/wget piped to a shell
119
+ if contains_cmd '(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|ksh|fish|dash|csh)([[:space:]]|$)'; then
120
+ emit_deny "Blocked: piping downloaded content directly to a shell is dangerous."
121
+ fi
122
+
123
+ # Disk / partition. Only REDIRECTIONS to /dev/ are destructive — `2>/dev/null` is not.
124
+ if printf '%s' "$COMMAND" | grep -qE '(^|[^0-9&])>[[:space:]]*/dev/[a-zA-Z][a-zA-Z0-9]*' \
125
+ && ! printf '%s' "$COMMAND" | grep -qE '>[[:space:]]*/dev/(null|stdout|stderr|tty|zero|random|urandom)([[:space:]]|$)' ; then
126
+ emit_deny "Blocked: redirection into a raw device file can destroy data."
127
+ fi
128
+ if contains_cmd '(^|[;&|[:space:]])(mkfs|mkfs\.[a-z0-9]+)([[:space:]]|$)' \
129
+ || contains_cmd '(^|[;&|[:space:]])dd[[:space:]]+[^|]*(if|of)=/dev/[a-zA-Z]' ; then
130
+ emit_deny "Blocked: mkfs/dd against a device node. Irreversible data loss."
131
+ fi
132
+
133
+ # ── Destructive git ─────────────────────────────────────────────────────
134
+ if contains_cmd 'git[[:space:]]+reset[[:space:]]+--hard'; then
135
+ emit_deny "Blocked: git reset --hard discards uncommitted changes permanently. Use git stash or commit first."
136
+ fi
137
+ if contains_cmd 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
138
+ emit_deny "Blocked: git clean -f permanently deletes untracked files."
139
+ fi
140
+
141
+ # ── Accidental package publishing ───────────────────────────────────────
142
+ # Allow --dry-run variants. Covers the common package managers.
143
+ publish_patterns=(
144
+ '(npm|yarn|pnpm|bun)[[:space:]]+publish'
145
+ 'cargo[[:space:]]+publish'
146
+ 'gem[[:space:]]+push'
147
+ 'twine[[:space:]]+upload'
148
+ 'uv[[:space:]]+publish'
149
+ 'poetry[[:space:]]+publish'
150
+ 'composer[[:space:]]+publish'
151
+ )
152
+ for pat in "${publish_patterns[@]}"; do
153
+ if contains_cmd "$pat" && ! contains_cmd '(^|[[:space:]])(--dry-run|-n)([[:space:]=]|$)'; then
154
+ emit_deny "Blocked: publishing packages should run in CI or be done manually, not via the agent."
155
+ fi
156
+ done
157
+
158
+ exit 0
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # Forge PreToolUse(Write|Edit) workflow guard.
3
+ #
4
+ # Blocks code edits when no Forge skill is driving the session, nudging the
5
+ # operator to invoke /forge or /quick-tasking first. A skill IS active when
6
+ # EITHER signal holds:
7
+ #
8
+ # (a) the ephemeral marker .forge/.active-skill exists — written by the
9
+ # PostToolUse(Skill) hook on every Skill invocation; reliable for short
10
+ # skills (quick-tasking) but cleared between turns / at session
11
+ # boundaries, so a long executing run loses it mid-flight; OR
12
+ # (b) a milestone is mid-execution (current.status: executing) — the durable
13
+ # signal that survives across turns because it lives in a committed state
14
+ # file, not an ephemeral marker.
15
+ #
16
+ # (b) is the fix for the failure mode where the marker was cleared 3+ times in
17
+ # one executing session and legitimate edits got blocked (forge#12). When (b)
18
+ # holds we also re-assert the marker so the rest of the turn is cheap.
19
+ #
20
+ # Contract: exit 0 = allow, exit 2 = block with guidance. Advisory workflow
21
+ # gate — the documented bypass is `touch .forge/.active-skill`.
22
+
23
+ set -uo pipefail
24
+
25
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
26
+ [ -n "$ROOT" ] || ROOT="$PWD"
27
+ FORGE="$ROOT/.forge"
28
+
29
+ # (a) marker present → active.
30
+ [ -f "$FORGE/.active-skill" ] && exit 0
31
+
32
+ # (b) a milestone is executing → durable "skill active" signal that outlives the
33
+ # marker. Match the indented `current.status: executing` line; a milestone
34
+ # file is small, so the grep is cheap.
35
+ if ls "$FORGE"/state/milestone-*.yml >/dev/null 2>&1; then
36
+ if grep -qE '^[[:space:]]+status:[[:space:]]*executing([[:space:]]|$)' "$FORGE"/state/milestone-*.yml 2>/dev/null; then
37
+ # Self-heal: re-assert the marker so later checks this turn short-circuit at (a).
38
+ printf 'executing\n' > "$FORGE/.active-skill" 2>/dev/null || true
39
+ exit 0
40
+ fi
41
+ fi
42
+
43
+ echo "[Forge] No active skill. Invoke /forge or /quick-tasking before editing code. To bypass: touch .forge/.active-skill" >&2
44
+ exit 2
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env sh
2
+ # forge-reserve — allocate a collision-safe sequential id (ADR/DEF/FR/NFR).
3
+ #
4
+ # Implements ADR-021: the coordination point is a machine-local ledger in the
5
+ # git COMMON dir (.git/forge/id-reservations), shared instantly across every
6
+ # sibling worktree of the repo on this machine — the input ADR-016's
7
+ # branch-tracked reservations.yml could not see. Same primitive as the
8
+ # integration flag (.git/forge/integration-pending).
9
+ #
10
+ # forge-reserve <kind> --milestone <id> [--summary <text>]
11
+ # kind ∈ {adr|def|fr|nfr}
12
+ #
13
+ # stdout: the allocated id and NOTHING else (e.g. ADR-022). All logs → stderr.
14
+ # exit: 0 success; 2 bad/missing kind or args; 3 lock timeout. On non-zero
15
+ # exit nothing is written.
16
+ #
17
+ # Side effects (both UNcommitted — the caller commits reservations.yml with its
18
+ # work): appends one TSV line to the common-dir ledger and one YAML block to the
19
+ # worktree's .forge/reservations.yml, both under a portable mkdir lock.
20
+ #
21
+ # Pure POSIX sh — no bashisms, no flock (absent on macOS, ADR-021 point 3),
22
+ # no node/python. Only git/mkdir/awk/grep/sed/date/stat.
23
+ set -eu
24
+
25
+ WAIT="${FORGE_RESERVE_WAIT:-5}" # seconds: bounded lock-acquire budget
26
+ STALE="${FORGE_RESERVE_STALE:-15}" # seconds: a lock older than this is broken
27
+ SLEEP="0.1" # retry interval while the lock is held
28
+
29
+ # --- 1. parse args ----------------------------------------------------------
30
+ kind=""
31
+ milestone=""
32
+ summary=""
33
+ while [ $# -gt 0 ]; do
34
+ case "$1" in
35
+ --milestone) shift; milestone="${1:-}"; [ $# -gt 0 ] && shift || true ;;
36
+ --milestone=*) milestone="${1#*=}"; shift ;;
37
+ --summary) shift; summary="${1:-}"; [ $# -gt 0 ] && shift || true ;;
38
+ --summary=*) summary="${1#*=}"; shift ;;
39
+ -*) printf 'forge-reserve: unknown option: %s\n' "$1" >&2; exit 2 ;;
40
+ *)
41
+ if [ -z "$kind" ]; then kind="$1"; shift
42
+ else printf 'forge-reserve: unexpected argument: %s\n' "$1" >&2; exit 2; fi
43
+ ;;
44
+ esac
45
+ done
46
+
47
+ # Validate kind BEFORE touching git, the lock, or any file — a bad kind must
48
+ # exit non-zero having written nothing (must_have truth #3).
49
+ case "$kind" in
50
+ adr) prefix="ADR" ;;
51
+ def) prefix="DEF" ;;
52
+ fr) prefix="FR" ;;
53
+ nfr) prefix="NFR" ;;
54
+ "") printf 'forge-reserve: missing kind (adr|def|fr|nfr)\n' >&2; exit 2 ;;
55
+ *) printf 'forge-reserve: unknown kind: %s (expected adr|def|fr|nfr)\n' "$kind" >&2; exit 2 ;;
56
+ esac
57
+ [ -n "$milestone" ] || { printf 'forge-reserve: --milestone <id> is required\n' >&2; exit 2; }
58
+
59
+ # --- 2. resolve paths -------------------------------------------------------
60
+ common="$(git rev-parse --git-common-dir)"
61
+ top="$(git rev-parse --show-toplevel)"
62
+ mkdir -p "$common/forge"
63
+ ledger="$common/forge/id-reservations"
64
+ lock="$common/forge/id-reservations.lock"
65
+ reservations="$top/.forge/reservations.yml"
66
+
67
+ # Portable mtime → epoch: BSD/macOS `stat -f %m`, GNU `stat -c %Y`. This is the
68
+ # one place a platform branch is warranted (ADR-021 point 3: macOS is a
69
+ # first-class platform and its stat flags differ from GNU's).
70
+ get_mtime() {
71
+ stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || true
72
+ }
73
+
74
+ # --- 3. acquire lock (atomic mkdir, bounded retry, staleness-break) ----------
75
+ # mkdir is POSIX-atomic: it succeeds for exactly one racer and fails if the dir
76
+ # already exists — a lock without flock. A crashed helper's lock is broken after
77
+ # STALE seconds so it can't wedge the namespace forever.
78
+ max_iters="$(awk -v w="$WAIT" -v s="$SLEEP" 'BEGIN{print int(w/s)}')"
79
+ iters=0
80
+ while ! mkdir "$lock" 2>/dev/null; do
81
+ mt="$(get_mtime "$lock")"
82
+ now_epoch="$(date +%s)"
83
+ if [ -n "$mt" ] && [ "$((now_epoch - mt))" -ge "$STALE" ]; then
84
+ printf 'forge-reserve: breaking stale lock (age %ss): %s\n' "$((now_epoch - mt))" "$lock" >&2
85
+ rmdir "$lock" 2>/dev/null || true
86
+ continue
87
+ fi
88
+ iters="$((iters + 1))"
89
+ if [ "$iters" -ge "$max_iters" ]; then
90
+ printf 'forge-reserve: lock timeout after ~%ss: %s\n' "$WAIT" "$lock" >&2
91
+ exit 3
92
+ fi
93
+ sleep "$SLEEP"
94
+ done
95
+ # Only now that the lock is ours do we install the release trap.
96
+ trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
97
+
98
+ # --- 4/5. three-way max(ledger ∪ in-tree ∪ main:reservations.yml) + 1 --------
99
+ # Everything numeric goes through awk (n+0) so leading-zero ids like 021 are read
100
+ # as decimal 21, never octal — no shell $(( )) octal trap.
101
+
102
+ # (a) ledger — same-machine siblings, INCLUDING uncommitted (the ADR-016-blind input)
103
+ ledger_max=0
104
+ if [ -f "$ledger" ]; then
105
+ ledger_max="$(awk -F'\t' -v k="$kind" \
106
+ '$1==k { split($2,a,"-"); n=a[2]+0; if(n>m)m=n } END{print m+0}' "$ledger")"
107
+ fi
108
+
109
+ # (b) in-tree — landed ids in THIS worktree. Exact-prefix match in awk ($1==p) so
110
+ # FR does not swallow NFR (FR is a substring of NFR).
111
+ if [ "$kind" = "adr" ]; then
112
+ intree_max="$(ls "$top/docs/decisions" 2>/dev/null \
113
+ | awk -F- '/^ADR-[0-9]/ { n=$2+0; if(n>m)m=n } END{print m+0}')"
114
+ else
115
+ intree_max="$(grep -rhoE '[A-Z]+-[0-9]+' "$top/.forge/requirements/" 2>/dev/null \
116
+ | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
117
+ fi
118
+
119
+ # (c) main:reservations.yml — cross-machine / cold-start floor (survives a
120
+ # .git/forge wipe; carries another machine's MERGED reservations). Absent → 0.
121
+ main_max="$(git show main:.forge/reservations.yml 2>/dev/null \
122
+ | grep -oE '[A-Z]+-[0-9]+' \
123
+ | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
124
+ [ -n "$main_max" ] || main_max=0
125
+
126
+ next="$(awk -v a="$ledger_max" -v b="$intree_max" -v c="$main_max" \
127
+ 'BEGIN{m=a; if(b>m)m=b; if(c>m)m=c; print m+1}')"
128
+ id="$(printf '%s-%03d' "$prefix" "$next")"
129
+
130
+ # --- 6. dual-write (still under the lock) -----------------------------------
131
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
132
+ printf '%s\t%s\t%s\t%s\n' "$kind" "$id" "$milestone" "$now" >> "$ledger"
133
+
134
+ # reservations.yml missing → seed the 0.42.0 starter HEADER, but with a bare
135
+ # `reservations:` key (not the guide's `reservations: []`) so the block items we
136
+ # append below form a valid sequence — appending `- kind:` under a `[]` flow list
137
+ # is malformed YAML. Existing repo files already use the bare-key block form.
138
+ if [ ! -f "$reservations" ]; then
139
+ mkdir -p "$(dirname "$reservations")"
140
+ cat > "$reservations" <<'YML'
141
+ # Forge ID Reservations — cross-worktree coordination for ADR / DEF / FR / NFR numbers
142
+ # Written by .claude/hooks/forge-reserve.sh (ADR-021). The machine-local ledger
143
+ # (.git/forge/id-reservations) is the same-machine coordination point; this file
144
+ # is the durable committed audit trail + cross-machine floor. Append-only; never
145
+ # edit prior entries. Next number for a kind = max(ledger, in-tree, main) + 1.
146
+ # kind ∈ {adr,def,fr,nfr}. See FORGE.md → ID Reservation Protocol + ADR-021.
147
+ reservations:
148
+ YML
149
+ fi
150
+
151
+ # YAML double-quoted scalar: escape backslash then double-quote.
152
+ esc_summary="$(printf '%s' "$summary" | sed 's/\\/\\\\/g; s/"/\\"/g')"
153
+ {
154
+ printf ' - kind: %s\n' "$kind"
155
+ printf ' id: %s\n' "$id"
156
+ printf ' milestone: %s\n' "$milestone"
157
+ printf ' reserved_at: "%s"\n' "$now"
158
+ printf ' summary: "%s"\n' "$esc_summary"
159
+ } >> "$reservations"
160
+
161
+ # --- 7. emit the id (stdout only); trap releases the lock on exit ------------
162
+ printf '%s\n' "$id"
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # Forge — runs the project's formatter after the agent edits a file.
3
+ # PostToolUse hook for Edit|Write|MultiEdit. Always exits 0 — a formatting
4
+ # failure must never block subsequent work.
5
+ #
6
+ # "Most specific project config wins". A formatter only runs when BOTH the tool
7
+ # and the project's config for it are present, so this never fires against repos
8
+ # that haven't opted in.
9
+
10
+ set -uo pipefail
11
+
12
+ # jq missing → silently skip. Never block work over formatting.
13
+ command -v jq >/dev/null 2>&1 || exit 0
14
+
15
+ INPUT=$(cat)
16
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
17
+ [ -z "$FILE_PATH" ] && exit 0
18
+ [ ! -f "$FILE_PATH" ] && exit 0
19
+
20
+ EXT="${FILE_PATH##*.}"
21
+ EXT_LC=$(printf '%s' "$EXT" | tr '[:upper:]' '[:lower:]')
22
+
23
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
24
+
25
+ has_file() { [ -f "$REPO_ROOT/$1" ]; }
26
+ has_any() { for f in "$@"; do has_file "$f" && return 0; done; return 1; }
27
+ pyproject_has() { has_file "pyproject.toml" && grep -q "^\[$1\]" "$REPO_ROOT/pyproject.toml" 2>/dev/null; }
28
+ pkgjson_has_key() { has_file "package.json" && grep -q "\"$1\"" "$REPO_ROOT/package.json" 2>/dev/null; }
29
+
30
+ run() {
31
+ printf 'format-on-save: %s\n' "$*" >&2
32
+ "$@" >/dev/null 2>&1 || true
33
+ }
34
+
35
+ case "$EXT_LC" in
36
+ # ── Python ────────────────────────────────────────────────────────────
37
+ py)
38
+ if command -v ruff >/dev/null 2>&1 \
39
+ && ( has_any "ruff.toml" ".ruff.toml" || pyproject_has "tool.ruff" ); then
40
+ run ruff format "$FILE_PATH"
41
+ run ruff check --fix "$FILE_PATH"
42
+ elif command -v black >/dev/null 2>&1 && pyproject_has "tool.black"; then
43
+ run black --quiet "$FILE_PATH"
44
+ command -v isort >/dev/null 2>&1 && run isort --quiet "$FILE_PATH"
45
+ fi
46
+ ;;
47
+ # ── PHP / Laravel ─────────────────────────────────────────────────────
48
+ php)
49
+ if [ -x "$REPO_ROOT/vendor/bin/pint" ]; then
50
+ run "$REPO_ROOT/vendor/bin/pint" "$FILE_PATH"
51
+ elif command -v php-cs-fixer >/dev/null 2>&1 && has_any ".php-cs-fixer.php" ".php-cs-fixer.dist.php"; then
52
+ run php-cs-fixer fix "$FILE_PATH"
53
+ fi
54
+ ;;
55
+ # ── JS / TS / Vue / CSS / HTML / JSON / MD / YAML ─────────────────────
56
+ js|jsx|ts|tsx|vue|mjs|cjs|css|scss|sass|less|html|json|jsonc|md|mdx|yml|yaml)
57
+ if has_any "biome.json" "biome.jsonc" && [ -x "$REPO_ROOT/node_modules/.bin/biome" ]; then
58
+ run "$REPO_ROOT/node_modules/.bin/biome" format --write "$FILE_PATH"
59
+ run "$REPO_ROOT/node_modules/.bin/biome" check --write "$FILE_PATH"
60
+ else
61
+ if [ -x "$REPO_ROOT/node_modules/.bin/prettier" ] \
62
+ && ( has_any ".prettierrc" ".prettierrc.json" ".prettierrc.js" ".prettierrc.cjs" ".prettierrc.mjs" ".prettierrc.yaml" ".prettierrc.yml" "prettier.config.js" "prettier.config.cjs" "prettier.config.mjs" \
63
+ || pkgjson_has_key "prettier" ); then
64
+ run "$REPO_ROOT/node_modules/.bin/prettier" --write --log-level=silent "$FILE_PATH"
65
+ fi
66
+ case "$EXT_LC" in
67
+ js|jsx|ts|tsx|vue|mjs|cjs)
68
+ if [ -x "$REPO_ROOT/node_modules/.bin/eslint" ] \
69
+ && ( has_any ".eslintrc" ".eslintrc.js" ".eslintrc.cjs" ".eslintrc.json" ".eslintrc.yml" ".eslintrc.yaml" "eslint.config.js" "eslint.config.mjs" "eslint.config.cjs" "eslint.config.ts" \
70
+ || pkgjson_has_key "eslintConfig" ); then
71
+ run "$REPO_ROOT/node_modules/.bin/eslint" --fix "$FILE_PATH"
72
+ fi
73
+ ;;
74
+ esac
75
+ fi
76
+ ;;
77
+ # ── Rust ──────────────────────────────────────────────────────────────
78
+ rs)
79
+ command -v rustfmt >/dev/null 2>&1 && run rustfmt "$FILE_PATH"
80
+ ;;
81
+ # ── Go ────────────────────────────────────────────────────────────────
82
+ go)
83
+ if command -v goimports >/dev/null 2>&1; then
84
+ run goimports -w "$FILE_PATH"
85
+ elif command -v gofmt >/dev/null 2>&1; then
86
+ run gofmt -w "$FILE_PATH"
87
+ fi
88
+ ;;
89
+ # ── Shell ─────────────────────────────────────────────────────────────
90
+ sh|bash)
91
+ command -v shfmt >/dev/null 2>&1 && run shfmt -w "$FILE_PATH"
92
+ ;;
93
+ esac
94
+
95
+ exit 0
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ # Forge safety guardrail — blocks edits to sensitive or generated files.
3
+ # PreToolUse hook for Edit|Write. Exit 2 = block. Exit 0 = allow.
4
+
5
+ set -uo pipefail
6
+
7
+ emit() {
8
+ # $1 = decision (deny|ask) ; $2 = reason
9
+ local decision="$1"
10
+ local reason="${2//\"/\\\"}"
11
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":"%s"}}\n' "$decision" "$reason"
12
+ exit 2
13
+ }
14
+
15
+ if ! command -v jq >/dev/null 2>&1; then
16
+ emit deny "jq is required for file protection hooks but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
17
+ fi
18
+
19
+ INPUT=$(cat)
20
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
21
+ [ -z "$FILE_PATH" ] && exit 0
22
+
23
+ BASENAME=$(basename -- "$FILE_PATH")
24
+ BASENAME_LC=$(printf '%s' "$BASENAME" | tr '[:upper:]' '[:lower:]')
25
+ PATH_LC=$(printf '%s' "$FILE_PATH" | tr '[:upper:]' '[:lower:]')
26
+
27
+ # Allowlist: template/example env files are meant to be checked in and edited.
28
+ case "$BASENAME_LC" in
29
+ .env.example|.env.sample|.env.dist|.env.template)
30
+ exit 0
31
+ ;;
32
+ esac
33
+
34
+ # Protected basename patterns (matched case-insensitively).
35
+ PROTECTED_PATTERNS=(
36
+ ".env"
37
+ ".env.*"
38
+ "*.pem"
39
+ "*.key"
40
+ "*.crt"
41
+ "*.p12"
42
+ "*.pfx"
43
+ "id_rsa"
44
+ "id_ed25519"
45
+ "credentials.json"
46
+ ".npmrc"
47
+ ".pypirc"
48
+ ".composer-auth.json"
49
+ "auth.json"
50
+ # Lock files — update through the package manager, never by hand.
51
+ "package-lock.json"
52
+ "yarn.lock"
53
+ "pnpm-lock.yaml"
54
+ "composer.lock"
55
+ "uv.lock"
56
+ "poetry.lock"
57
+ "Gemfile.lock"
58
+ "Cargo.lock"
59
+ "go.sum"
60
+ # Generated / minified
61
+ "*.gen.ts"
62
+ "*.generated.*"
63
+ "*.min.js"
64
+ "*.min.css"
65
+ )
66
+
67
+ shopt -s nocasematch 2>/dev/null || true
68
+ for pattern in "${PROTECTED_PATTERNS[@]}"; do
69
+ case "$BASENAME_LC" in
70
+ $pattern)
71
+ emit deny "Protected file: $BASENAME matches pattern '$pattern'. Edit via the package manager or the appropriate secret store."
72
+ ;;
73
+ esac
74
+ done
75
+
76
+ # Sensitive directories (lower-cased path for case-insensitive matches).
77
+ case "$PATH_LC" in
78
+ .git/*|*/.git/*)
79
+ emit deny "Cannot edit files inside .git/" ;;
80
+ secrets/*|*/secrets/*)
81
+ emit deny "Cannot edit files inside secrets/" ;;
82
+ .secrets/*|*/.secrets/*)
83
+ emit deny "Cannot edit files inside .secrets/" ;;
84
+ .env|.env.*|*/.env|*/.env.*)
85
+ emit deny "Cannot edit .env files" ;;
86
+ esac
87
+
88
+ # Framework-authoring files. In a normal project these are re-synced via
89
+ # /upgrading, never hand-edited — so we block hooks and prompt on settings.
90
+ # FORGE_ALLOW_HOOK_EDITS=1 lifts both, set only in the Forge framework repo
91
+ # itself (where authoring hooks and settings IS the work).
92
+ if [ -z "${FORGE_ALLOW_HOOK_EDITS:-}" ]; then
93
+ case "$PATH_LC" in
94
+ .claude/hooks/*|*/.claude/hooks/*)
95
+ emit deny "Cannot edit hook scripts — these enforce safety boundaries. Update them in the framework repo and re-sync (/upgrading)." ;;
96
+ .claude/settings.json|*/.claude/settings.json|.claude/settings.local.json|*/.claude/settings.local.json)
97
+ emit ask "Editing settings.json — this controls permissions and hooks. Confirm this change." ;;
98
+ esac
99
+ fi
100
+
101
+ exit 0
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ # Forge safety guardrail — scans file content for accidental secrets before writing.
3
+ # PreToolUse hook for Edit|Write. Exit 2 = block (decision "ask" so the user can
4
+ # override for genuine test fixtures). Exit 0 = allow.
5
+
6
+ set -uo pipefail
7
+
8
+ emit_deny() {
9
+ local reason="${1//\"/\\\"}"
10
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
11
+ exit 2
12
+ }
13
+
14
+ if ! command -v jq >/dev/null 2>&1; then
15
+ emit_deny "jq is required for secret scanning but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
16
+ fi
17
+
18
+ INPUT=$(cat)
19
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
20
+
21
+ if [ "$TOOL_NAME" = "Write" ]; then
22
+ CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
23
+ elif [ "$TOOL_NAME" = "Edit" ]; then
24
+ CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty')
25
+ else
26
+ exit 0
27
+ fi
28
+
29
+ [ -z "$CONTENT" ] && exit 0
30
+
31
+ MATCHES=""
32
+
33
+ # AWS Access Key IDs
34
+ if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then
35
+ MATCHES="$MATCHES AWS access key (AKIA...);"
36
+ fi
37
+ # AWS Secret Access Keys (40 chars base64 after a key assignment)
38
+ if echo "$CONTENT" | grep -qiE '(aws_secret_access_key|secret_key)[[:space:]]*[=:][[:space:]]*["\x27]?[A-Za-z0-9/+=]{40}'; then
39
+ MATCHES="$MATCHES AWS secret key;"
40
+ fi
41
+ # GitHub tokens (PAT, OAuth, App)
42
+ if echo "$CONTENT" | grep -qE '(ghp_|gho_|ghs_|ghr_|github_pat_)[a-zA-Z0-9_]{20,}'; then
43
+ MATCHES="$MATCHES GitHub token;"
44
+ fi
45
+ # OpenAI / Anthropic / Stripe style keys (sk-...)
46
+ if echo "$CONTENT" | grep -qE 'sk-[a-zA-Z0-9]{20,}'; then
47
+ MATCHES="$MATCHES API key (sk-...);"
48
+ fi
49
+ # Anthropic-specific keys
50
+ if echo "$CONTENT" | grep -qE 'sk-ant-[a-zA-Z0-9_-]{20,}'; then
51
+ MATCHES="$MATCHES Anthropic API key;"
52
+ fi
53
+ # Slack tokens
54
+ if echo "$CONTENT" | grep -qE 'xox[bpras]-[0-9a-zA-Z-]{10,}'; then
55
+ MATCHES="$MATCHES Slack token;"
56
+ fi
57
+ # Google API keys
58
+ if echo "$CONTENT" | grep -qE 'AIza[0-9A-Za-z_-]{35}'; then
59
+ MATCHES="$MATCHES Google API key;"
60
+ fi
61
+ # Private key blocks
62
+ if echo "$CONTENT" | grep -qE -- '-----BEGIN[[:space:]]+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then
63
+ MATCHES="$MATCHES private key block;"
64
+ fi
65
+ # Connection strings with embedded credentials
66
+ if echo "$CONTENT" | grep -qE '(mongodb|postgres|postgresql|mysql|redis|amqp|smtp|mssql)(\+[a-z]+)?://[^:[:space:]]+:[^@[:space:]]+@'; then
67
+ MATCHES="$MATCHES connection string with credentials;"
68
+ fi
69
+ # Laravel APP_KEY with a real base64 value (not a placeholder)
70
+ if echo "$CONTENT" | grep -qE 'APP_KEY[[:space:]]*=[[:space:]]*base64:[A-Za-z0-9+/=]{40,}'; then
71
+ MATCHES="$MATCHES Laravel APP_KEY;"
72
+ fi
73
+ # Generic password/secret/token assignments with literal string values.
74
+ # Excludes env var references (process.env, os.environ, getenv, ${...}, ENV[, env(, config()).
75
+ if echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["\x27][^"\x27]{8,}["\x27]' && \
76
+ ! echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["\x27]?(process\.env|os\.environ|getenv|\$\{|ENV\[|env\(|config\()'; then
77
+ MATCHES="$MATCHES hardcoded credential;"
78
+ fi
79
+
80
+ if [ -n "$MATCHES" ]; then
81
+ # "ask" not "deny" — warn but let the user override (could be a fixture).
82
+ REASON="Possible secret detected in content:$MATCHES Review carefully before allowing."
83
+ echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
84
+ exit 2
85
+ fi
86
+
87
+ exit 0