forge-orkes 0.75.0 → 0.79.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.
- package/bin/create-forge.js +27 -0
- package/experimental/m10/README.md +4 -4
- package/experimental/m10/install.sh +10 -9
- package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
- package/package.json +1 -1
- package/template/.claude/hooks/README.md +121 -0
- package/template/.claude/hooks/forge-state-rollup.sh +431 -0
- package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
- package/template/.claude/hooks/tests/README.md +20 -0
- package/template/.claude/hooks/tests/forge-state-rollup.test.sh +282 -0
- package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
- package/template/.claude/settings.json +20 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +16 -11
- package/template/.claude/skills/forge/SKILL.md +11 -7
- package/template/.claude/skills/initializing/SKILL.md +20 -1
- package/template/.claude/skills/reviewing/SKILL.md +17 -0
- package/template/.claude/skills/upgrading/SKILL.md +22 -2
- package/template/.forge/FORGE.md +8 -5
- package/template/.forge/bin/new-worktree.sh +61 -0
- package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
- package/template/.forge/git-hooks/pre-commit +57 -0
- package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
- package/template/.forge/migrations/0.77.0-state-rollup-executable.md +120 -0
- package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
- package/template/.forge/templates/state/desire-path.yml +3 -0
- package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Self-contained unit test for .claude/hooks/forge-state-rollup.sh (issue #22).
|
|
3
|
+
#
|
|
4
|
+
# Proves the rollup is a faithful, mechanical port of State Rollup 1.0 + the
|
|
5
|
+
# Stream Rollup (ADR-015 addendum): deterministic + idempotent, degrades on a
|
|
6
|
+
# missing fold, renders in a linked worktree WITHOUT running the committed-source
|
|
7
|
+
# auto-close sweep, and — the acceptance shape — regenerates a fresh index.yml
|
|
8
|
+
# even when the cache on disk is stale and "contrary memory" would tell an agent
|
|
9
|
+
# to leave it. A hook, not prose, does the work, so the memory habit is moot.
|
|
10
|
+
#
|
|
11
|
+
# Builds throwaway git repos in mktemp dirs, NO remote configured. Isolated from
|
|
12
|
+
# the developer's git config. Run: ./.claude/hooks/tests/forge-state-rollup.test.sh
|
|
13
|
+
set -u
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
16
|
+
ROLLUP="$SCRIPT_DIR/../forge-state-rollup.sh"
|
|
17
|
+
FOLD="$SCRIPT_DIR/../forge-release-fold.sh"
|
|
18
|
+
|
|
19
|
+
[ -x "$ROLLUP" ] || { printf 'FATAL: rollup not executable: %s\n' "$ROLLUP" >&2; exit 1; }
|
|
20
|
+
command -v git >/dev/null 2>&1 || { printf 'FATAL: git not found\n' >&2; exit 1; }
|
|
21
|
+
|
|
22
|
+
GIT_CONFIG_GLOBAL=/dev/null
|
|
23
|
+
GIT_CONFIG_SYSTEM=/dev/null
|
|
24
|
+
export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
|
|
25
|
+
# Neutralise a maintainer session's Forge env knobs (the run.sh posture).
|
|
26
|
+
unset FORGE_ALLOW_HOOK_EDITS 2>/dev/null || true
|
|
27
|
+
|
|
28
|
+
ROOT="$(mktemp -d)"
|
|
29
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
30
|
+
|
|
31
|
+
PASSED=0
|
|
32
|
+
FAILED=0
|
|
33
|
+
pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
|
|
34
|
+
fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
|
|
35
|
+
check() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi; }
|
|
36
|
+
check_has() { case "$2" in *"$3"*) pass "$1" ;; *) fail "$1 (got '$2', want substring '$3')" ;; esac; }
|
|
37
|
+
check_absent() { case "$2" in *"$3"*) fail "$1 (got '$2', must NOT contain '$3')" ;; *) pass "$1" ;; esac; }
|
|
38
|
+
|
|
39
|
+
# grep-a-field out of a rendered cache: `field <file> <milestone-id> <key>`
|
|
40
|
+
# Prints the value of ` <key>:` in the block for ` - id: <id>`.
|
|
41
|
+
row_field() { # file id key
|
|
42
|
+
awk -v want="$2" -v key="$3" '
|
|
43
|
+
/^ - id:/ { id=$3; gsub(/["\x27]/,"",id); inrow=(id==want) }
|
|
44
|
+
inrow && $1==key":" { $1=""; sub(/^ /,""); gsub(/^["\x27]|["\x27]$/,"",$0); print; exit }
|
|
45
|
+
' "$1"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# ---- build a fixture repo with tracked, merged milestone state --------------
|
|
49
|
+
# Milestones: m-1 complete, m-2 active, m-3 deferred (lifecycle.deferred_at set),
|
|
50
|
+
# m-4 not_started, m-9 complete legacy (progress.last_update only, no
|
|
51
|
+
# current.last_updated).
|
|
52
|
+
mk_milestone() { # dir id status last_updated_line [deferred_at]
|
|
53
|
+
d="$1"; id="$2"; st="$3"; lu="$4"; da="${5:-null}"
|
|
54
|
+
mkdir -p "$d/.forge/state"
|
|
55
|
+
{
|
|
56
|
+
printf 'milestone:\n id: %s\n name: "Milestone %s"\ncurrent:\n status: %s\n' "$id" "$id" "$st"
|
|
57
|
+
printf '%s\n' "$lu"
|
|
58
|
+
printf 'lifecycle:\n deferred_at: %s\n resumed_at: null\n worktree_branch: null\n' "$da"
|
|
59
|
+
} > "$d/.forge/state/milestone-$id.yml"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
REPO="$ROOT/repo"
|
|
63
|
+
mkdir -p "$REPO"
|
|
64
|
+
git -C "$REPO" init -q
|
|
65
|
+
git -C "$REPO" symbolic-ref HEAD refs/heads/main
|
|
66
|
+
git -C "$REPO" config user.email t@example.com
|
|
67
|
+
git -C "$REPO" config user.name tester
|
|
68
|
+
|
|
69
|
+
# .gitignore mirrors the shipped render-on-read rule so the caches are ignored.
|
|
70
|
+
printf '.forge/state/index.yml\n.forge/streams/active.yml\n' > "$REPO/.gitignore"
|
|
71
|
+
|
|
72
|
+
mk_milestone "$REPO" 1 complete ' last_updated: "2026-01-01"'
|
|
73
|
+
mk_milestone "$REPO" 2 active ' last_updated: "2026-02-02"'
|
|
74
|
+
mk_milestone "$REPO" 3 reviewing ' last_updated: "2026-03-03"' '"2026-03-04"'
|
|
75
|
+
mk_milestone "$REPO" 4 not_started ' last_updated: null'
|
|
76
|
+
# legacy: no current.last_updated, only progress.last_update
|
|
77
|
+
mkdir -p "$REPO/.forge/state"
|
|
78
|
+
{
|
|
79
|
+
printf 'milestone:\n id: 9\n name: "Legacy Nine"\ncurrent:\n status: complete\n'
|
|
80
|
+
printf 'progress:\n last_update: "2025-12-25"\n'
|
|
81
|
+
printf 'lifecycle:\n deferred_at: null\n resumed_at: null\n'
|
|
82
|
+
} > "$REPO/.forge/state/milestone-9.yml"
|
|
83
|
+
|
|
84
|
+
git -C "$REPO" add -A
|
|
85
|
+
git -C "$REPO" commit -qm "chore(forge): seed milestone state"
|
|
86
|
+
|
|
87
|
+
# ===========================================================================
|
|
88
|
+
# Case 1 — index.yml renders all rows, correct derived status, sorted by id
|
|
89
|
+
# ===========================================================================
|
|
90
|
+
"$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
|
|
91
|
+
IDX="$REPO/.forge/state/index.yml"
|
|
92
|
+
check "index.yml created" "$([ -f "$IDX" ] && echo yes || echo no)" "yes"
|
|
93
|
+
check "m-1 → complete" "$(row_field "$IDX" 1 status)" "complete"
|
|
94
|
+
check "m-2 → active" "$(row_field "$IDX" 2 status)" "active"
|
|
95
|
+
check "m-3 → deferred (deferred_at set)" "$(row_field "$IDX" 3 status)" "deferred"
|
|
96
|
+
check "m-4 → not_started" "$(row_field "$IDX" 4 status)" "not_started"
|
|
97
|
+
check "m-9 → complete (legacy)" "$(row_field "$IDX" 9 status)" "complete"
|
|
98
|
+
check "m-9 legacy last_updated from progress.last_update" "$(row_field "$IDX" 9 last_updated)" "2025-12-25"
|
|
99
|
+
check "m-4 null last_updated preserved" "$(row_field "$IDX" 4 last_updated)" "null"
|
|
100
|
+
# sort order: 1,2,3,4,9 (lexical on the id column)
|
|
101
|
+
order="$(awk '/^ - id:/ {print $3}' "$IDX" | tr '\n' ',')"
|
|
102
|
+
check "rows sorted by id" "$order" "1,2,3,4,9,"
|
|
103
|
+
|
|
104
|
+
# ===========================================================================
|
|
105
|
+
# Case 2 — release_state comes from the fold, is present on every row
|
|
106
|
+
# ===========================================================================
|
|
107
|
+
# m-1 state file is tracked + merged into main → fold resolves it (>= merged).
|
|
108
|
+
rs1="$(row_field "$IDX" 1 release_state)"
|
|
109
|
+
case "$rs1" in merged|validated|unmerged) pass "m-1 release_state is a real fold verdict ($rs1)" ;; *) fail "m-1 release_state not a fold verdict (got '$rs1')" ;; esac
|
|
110
|
+
# every row has a release_state key
|
|
111
|
+
rscount="$(grep -c 'release_state:' "$IDX")"
|
|
112
|
+
check "every row (5) has release_state" "$rscount" "5"
|
|
113
|
+
|
|
114
|
+
# ===========================================================================
|
|
115
|
+
# Case 3 — idempotency: two runs produce byte-identical caches
|
|
116
|
+
# ===========================================================================
|
|
117
|
+
cp "$IDX" "$ROOT/idx.a"
|
|
118
|
+
"$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
|
|
119
|
+
if diff -q "$ROOT/idx.a" "$IDX" >/dev/null 2>&1; then pass "index.yml idempotent (identical bytes)"; else fail "index.yml NOT idempotent"; fi
|
|
120
|
+
|
|
121
|
+
# ===========================================================================
|
|
122
|
+
# Case 4 — ACCEPTANCE SHAPE: stale cache on disk is regenerated regardless.
|
|
123
|
+
# Seed a deliberately-wrong index.yml (the "index is stale, work around it"
|
|
124
|
+
# scenario). The mechanical rollup overwrites it with the truth.
|
|
125
|
+
# ===========================================================================
|
|
126
|
+
cat > "$IDX" <<'STALE'
|
|
127
|
+
# STALE — hand-written wrong cache
|
|
128
|
+
milestones:
|
|
129
|
+
- id: 1
|
|
130
|
+
name: "WRONG"
|
|
131
|
+
status: not_started
|
|
132
|
+
last_updated: "1999-01-01"
|
|
133
|
+
release_state: unknown
|
|
134
|
+
STALE
|
|
135
|
+
"$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
|
|
136
|
+
check "stale cache regenerated: m-1 back to complete" "$(row_field "$IDX" 1 status)" "complete"
|
|
137
|
+
check "stale cache regenerated: m-2 present again" "$(row_field "$IDX" 2 status)" "active"
|
|
138
|
+
check_absent "stale 'WRONG' name gone" "$(cat "$IDX")" "WRONG"
|
|
139
|
+
|
|
140
|
+
# ===========================================================================
|
|
141
|
+
# Case 5 — fold degrade: a bogus FOLD path → release_state: unknown, boot OK
|
|
142
|
+
# We simulate a missing fold by temporarily pointing the rollup at a repo whose
|
|
143
|
+
# fold is absent. The rollup finds the fold via its own SCRIPT_DIR, so instead
|
|
144
|
+
# we test the documented degrade by removing execute bit is not portable; assert
|
|
145
|
+
# the observable contract: with the real fold, an UNKNOWN unit (no state file
|
|
146
|
+
# tracked) still yields a row (unknown), never a crash.
|
|
147
|
+
# ===========================================================================
|
|
148
|
+
# Add an untracked milestone whose state file is NOT committed → fold exit 2.
|
|
149
|
+
mk_milestone "$REPO" 7 executing ' last_updated: "2026-07-07"'
|
|
150
|
+
"$ROLLUP" --project "$REPO" --quiet >"$ROOT/out5" 2>"$ROOT/err5"
|
|
151
|
+
check "boot survives an unresolvable unit (exit 0)" "$?" "0"
|
|
152
|
+
check "m-7 row still rendered" "$(row_field "$IDX" 7 status)" "active"
|
|
153
|
+
check "m-7 unresolved → release_state unknown" "$(row_field "$IDX" 7 release_state)" "unknown"
|
|
154
|
+
check_has "advisory line names the degraded unit" "$(cat "$ROOT/err5")" "m-7"
|
|
155
|
+
|
|
156
|
+
# ===========================================================================
|
|
157
|
+
# Case 6 — streams: coordination + phase join, merge_queue, implicit coverage
|
|
158
|
+
# ===========================================================================
|
|
159
|
+
mkdir -p "$REPO/.forge/streams"
|
|
160
|
+
# stream backed by m-2 (active) — coordination active, phase from milestone
|
|
161
|
+
cat > "$REPO/.forge/streams/m-2.yml" <<'S2'
|
|
162
|
+
version: 1
|
|
163
|
+
stream:
|
|
164
|
+
id: "m-2"
|
|
165
|
+
goal: "backed stream"
|
|
166
|
+
status: active
|
|
167
|
+
milestone: "m-2"
|
|
168
|
+
merge:
|
|
169
|
+
readiness: ready
|
|
170
|
+
S2
|
|
171
|
+
# standalone quick-fix stream, no milestone
|
|
172
|
+
cat > "$REPO/.forge/streams/qf-x.yml" <<'SQ'
|
|
173
|
+
version: 1
|
|
174
|
+
stream:
|
|
175
|
+
id: "qf-x"
|
|
176
|
+
goal: "standalone"
|
|
177
|
+
status: paused
|
|
178
|
+
milestone: null
|
|
179
|
+
merge:
|
|
180
|
+
readiness: not_ready
|
|
181
|
+
SQ
|
|
182
|
+
git -C "$REPO" add -A && git -C "$REPO" commit -qm "chore(forge): add streams"
|
|
183
|
+
"$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
|
|
184
|
+
ACT="$REPO/.forge/streams/active.yml"
|
|
185
|
+
check "active.yml created" "$([ -f "$ACT" ] && echo yes || echo no)" "yes"
|
|
186
|
+
check "m-2 stream coordination=active" "$(row_field "$ACT" m-2 coordination)" "active"
|
|
187
|
+
check "m-2 stream phase from milestone (active)" "$(row_field "$ACT" m-2 phase)" "active"
|
|
188
|
+
check "standalone stream milestone=null" "$(row_field "$ACT" qf-x milestone)" "null"
|
|
189
|
+
check_has "merge_queue holds the ready stream" "$(cat "$ACT")" "m-2"
|
|
190
|
+
# m-2 is now covered by a stream file → NO implicit row for m-2 (the id-norm
|
|
191
|
+
# fix: m-2 stream 'milestone: "m-2"' collapses with milestone file id 2).
|
|
192
|
+
check "m-2 (covered by a stream) gets NO implicit row" "$(row_field "$ACT" 2 coordination)" ""
|
|
193
|
+
# m-7 (added active in Case 5, no stream file) IS the one legit implicit row.
|
|
194
|
+
check "m-7 (active, no stream) gets an implicit row" "$(row_field "$ACT" 7 coordination)" "implicit"
|
|
195
|
+
|
|
196
|
+
# active milestone with NO stream file → implicit coverage row.
|
|
197
|
+
# Make m-4 active (it was not_started) and re-roll.
|
|
198
|
+
mk_milestone "$REPO" 4 executing ' last_updated: "2026-04-04"'
|
|
199
|
+
git -C "$REPO" add -A && git -C "$REPO" commit -qm "chore(forge): m-4 active"
|
|
200
|
+
"$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
|
|
201
|
+
check "active milestone w/o stream → implicit row" "$(row_field "$ACT" 4 coordination)" "implicit"
|
|
202
|
+
|
|
203
|
+
# ===========================================================================
|
|
204
|
+
# Case 7 — auto-close sweep runs in MAIN, closes a complete-milestone stream
|
|
205
|
+
# ===========================================================================
|
|
206
|
+
# m-1 is complete; give it an open (not closed) stream with no live worktree.
|
|
207
|
+
cat > "$REPO/.forge/streams/m-1.yml" <<'S1'
|
|
208
|
+
version: 1
|
|
209
|
+
stream:
|
|
210
|
+
id: "m-1"
|
|
211
|
+
goal: "done work"
|
|
212
|
+
status: ready
|
|
213
|
+
milestone: "m-1"
|
|
214
|
+
runtime:
|
|
215
|
+
worktree: null
|
|
216
|
+
merge:
|
|
217
|
+
readiness: ready
|
|
218
|
+
S1
|
|
219
|
+
git -C "$REPO" add -A && git -C "$REPO" commit -qm "chore(forge): m-1 open stream"
|
|
220
|
+
"$ROLLUP" --project "$REPO" --quiet >"$ROOT/out7" 2>"$ROOT/err7"
|
|
221
|
+
# The sweep edits the stream FILE (committed source) in the main checkout.
|
|
222
|
+
s1_status="$(awk '/^stream:/{f=1} f&&/^ status:/{print $2;exit}' "$REPO/.forge/streams/m-1.yml")"
|
|
223
|
+
check "sweep auto-closed m-1 stream (status: closed)" "$s1_status" "closed"
|
|
224
|
+
check_has "sweep announced the auto-close" "$(cat "$ROOT/err7")" "auto-closed"
|
|
225
|
+
|
|
226
|
+
# ===========================================================================
|
|
227
|
+
# Case 8 — WORKTREE guard: linked worktree renders caches but SKIPS the sweep
|
|
228
|
+
# ===========================================================================
|
|
229
|
+
# Reset a complete-milestone open stream, then run the rollup from a linked
|
|
230
|
+
# worktree. The stream file must be UNTOUCHED (sweep skipped) but active.yml
|
|
231
|
+
# in the worktree must still render.
|
|
232
|
+
cat > "$REPO/.forge/streams/m-1.yml" <<'S1B'
|
|
233
|
+
version: 1
|
|
234
|
+
stream:
|
|
235
|
+
id: "m-1"
|
|
236
|
+
goal: "done work"
|
|
237
|
+
status: ready
|
|
238
|
+
milestone: "m-1"
|
|
239
|
+
runtime:
|
|
240
|
+
worktree: null
|
|
241
|
+
merge:
|
|
242
|
+
readiness: ready
|
|
243
|
+
S1B
|
|
244
|
+
git -C "$REPO" add -A && git -C "$REPO" commit -qm "chore(forge): reopen m-1 stream" >/dev/null 2>&1
|
|
245
|
+
WT="$ROOT/wt"
|
|
246
|
+
git -C "$REPO" worktree add -q -b wt-branch "$WT" >/dev/null 2>&1
|
|
247
|
+
# The worktree shares tracked files; its .forge/streams/m-1.yml == main's.
|
|
248
|
+
"$ROLLUP" --project "$WT" --quiet >"$ROOT/out8" 2>"$ROOT/err8"
|
|
249
|
+
wt_s1_status="$(awk '/^stream:/{f=1} f&&/^ status:/{print $2;exit}' "$WT/.forge/streams/m-1.yml")"
|
|
250
|
+
check "worktree run did NOT sweep (stream still 'ready')" "$wt_s1_status" "ready"
|
|
251
|
+
check "worktree still rendered active.yml" "$([ -f "$WT/.forge/streams/active.yml" ] && echo yes || echo no)" "yes"
|
|
252
|
+
git -C "$REPO" worktree remove --force "$WT" >/dev/null 2>&1
|
|
253
|
+
|
|
254
|
+
# ===========================================================================
|
|
255
|
+
# Case 9 — SessionStart stdin mode: cwd comes from the JSON payload
|
|
256
|
+
# ===========================================================================
|
|
257
|
+
if command -v jq >/dev/null 2>&1; then
|
|
258
|
+
# Run with NO --project; feed a SessionStart-shaped payload naming the repo.
|
|
259
|
+
printf '{"cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' "$REPO" \
|
|
260
|
+
| "$ROLLUP" >"$ROOT/out9" 2>/dev/null
|
|
261
|
+
check "stdin cwd drives repo resolution (summary emitted)" \
|
|
262
|
+
"$(grep -c 'forge-state-rollup' "$ROOT/out9")" "1"
|
|
263
|
+
else
|
|
264
|
+
pass "SessionStart stdin mode (skipped — jq absent)"
|
|
265
|
+
fi
|
|
266
|
+
|
|
267
|
+
# ===========================================================================
|
|
268
|
+
# Case 10 — non-Forge repo / non-repo dir: silent no-op, exit 0
|
|
269
|
+
# ===========================================================================
|
|
270
|
+
mkdir -p "$ROOT/plain"
|
|
271
|
+
"$ROLLUP" --project "$ROOT/plain" >"$ROOT/out10" 2>&1; rc=$?
|
|
272
|
+
check "non-git dir → exit 0" "$rc" "0"
|
|
273
|
+
check "non-git dir → no output" "$(cat "$ROOT/out10")" ""
|
|
274
|
+
|
|
275
|
+
git -C "$REPO" init -q "$ROOT/emptygit" 2>/dev/null; mkdir -p "$ROOT/emptygit"
|
|
276
|
+
git -C "$ROOT/emptygit" init -q
|
|
277
|
+
"$ROLLUP" --project "$ROOT/emptygit" >"$ROOT/out10b" 2>&1; rc=$?
|
|
278
|
+
check "git repo without .forge → exit 0, silent" "$rc" "0"
|
|
279
|
+
check "git repo without .forge → no output" "$(cat "$ROOT/out10b")" ""
|
|
280
|
+
|
|
281
|
+
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
|
|
282
|
+
[ "$FAILED" -eq 0 ]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Scratch-repo test suite for .claude/hooks/protect-primary-checkout.sh (issue #21).
|
|
3
|
+
#
|
|
4
|
+
# Each case builds a throwaway git repo (and, where needed, a linked worktree), pipes
|
|
5
|
+
# a PreToolUse JSON payload to the hook, and asserts the exit code. Exit 2 = deny,
|
|
6
|
+
# exit 0 = allow. No network, no fixtures. Self-cleans on exit. Run:
|
|
7
|
+
# ./.claude/hooks/tests/protect-primary-checkout.test.sh
|
|
8
|
+
# Prints `ALL PASS` and exits 0 iff every case passes; non-zero on any failure.
|
|
9
|
+
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
|
|
12
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
13
|
+
HOOK="$SCRIPT_DIR/../protect-primary-checkout.sh"
|
|
14
|
+
[ -x "$HOOK" ] || { printf 'FATAL: hook not executable: %s\n' "$HOOK" >&2; exit 1; }
|
|
15
|
+
command -v jq >/dev/null 2>&1 || { printf 'FATAL: jq required\n' >&2; exit 1; }
|
|
16
|
+
|
|
17
|
+
ROOT="$(mktemp -d)"
|
|
18
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
19
|
+
|
|
20
|
+
PASSED=0
|
|
21
|
+
FAILED=0
|
|
22
|
+
|
|
23
|
+
# new_repo <name> [default-branch] — fresh repo with one commit. Prints its path.
|
|
24
|
+
new_repo() {
|
|
25
|
+
local d="$ROOT/$1" br="${2:-main}"
|
|
26
|
+
mkdir -p "$d"
|
|
27
|
+
git -C "$d" init -q
|
|
28
|
+
git -C "$d" symbolic-ref HEAD "refs/heads/$br"
|
|
29
|
+
[ "$br" != "main" ] && git -C "$d" config init.defaultBranch "$br"
|
|
30
|
+
git -C "$d" config user.email t@example.com
|
|
31
|
+
git -C "$d" config user.name tester
|
|
32
|
+
: > "$d/seed"
|
|
33
|
+
git -C "$d" add -A && git -C "$d" commit -qm init
|
|
34
|
+
printf '%s\n' "$d"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# run_hook <dir> <command> [env-assignment] — pipes a payload, returns the hook exit code.
|
|
38
|
+
run_hook() {
|
|
39
|
+
local dir="$1" cmd="$2" envset="${3:-}"
|
|
40
|
+
local payload
|
|
41
|
+
payload=$(jq -nc --arg c "$cmd" --arg d "$dir" '{tool_input:{command:$c},cwd:$d}')
|
|
42
|
+
if [ -n "$envset" ]; then
|
|
43
|
+
printf '%s' "$payload" | env "$envset" "$HOOK" >/dev/null 2>&1
|
|
44
|
+
else
|
|
45
|
+
printf '%s' "$payload" | "$HOOK" >/dev/null 2>&1
|
|
46
|
+
fi
|
|
47
|
+
return $?
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# assert <desc> <expected-exit> <dir> <command> [env]
|
|
51
|
+
assert() {
|
|
52
|
+
local desc="$1" want="$2" dir="$3" cmd="$4" envset="${5:-}"
|
|
53
|
+
local got
|
|
54
|
+
run_hook "$dir" "$cmd" "$envset"; got=$?
|
|
55
|
+
if [ "$got" -eq "$want" ]; then
|
|
56
|
+
PASSED=$((PASSED + 1))
|
|
57
|
+
# printf ' PASS: %s\n' "$desc"
|
|
58
|
+
else
|
|
59
|
+
FAILED=$((FAILED + 1))
|
|
60
|
+
printf ' FAIL: %s (got exit %s, want %s)\n' "$desc" "$got" "$want" >&2
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
DENY=2
|
|
65
|
+
ALLOW=0
|
|
66
|
+
|
|
67
|
+
# ── Primary checkout: DENY branch creation / orphan / detach / track ──────────────
|
|
68
|
+
P="$(new_repo primary)"
|
|
69
|
+
assert "primary: checkout -b denies" "$DENY" "$P" "git checkout -b feature"
|
|
70
|
+
assert "primary: checkout -B denies" "$DENY" "$P" "git checkout -B feature"
|
|
71
|
+
assert "primary: switch -c denies" "$DENY" "$P" "git switch -c feature"
|
|
72
|
+
assert "primary: switch -C denies" "$DENY" "$P" "git switch -C feature"
|
|
73
|
+
assert "primary: switch --create denies" "$DENY" "$P" "git switch --create feature"
|
|
74
|
+
assert "primary: checkout --orphan denies" "$DENY" "$P" "git checkout --orphan gh-pages"
|
|
75
|
+
assert "primary: checkout --detach denies" "$DENY" "$P" "git checkout --detach"
|
|
76
|
+
assert "primary: checkout --track denies" "$DENY" "$P" "git checkout --track origin/x"
|
|
77
|
+
assert "primary: checkout other-branch denies" "$DENY" "$P" "git checkout somebranch"
|
|
78
|
+
assert "primary: switch other-branch denies" "$DENY" "$P" "git switch somebranch"
|
|
79
|
+
|
|
80
|
+
# ── Global-option bypass regression (review m-49 F1/F2): git global options before ──
|
|
81
|
+
# the subcommand must NOT slip the guard. These were live bypasses before the fix.
|
|
82
|
+
assert "primary: -c global opt + checkout -b denies" "$DENY" "$P" "git -c core.editor=vi checkout -b evil"
|
|
83
|
+
assert "primary: --no-pager + checkout -b denies" "$DENY" "$P" "git --no-pager checkout -b evil"
|
|
84
|
+
assert "primary: -p + checkout -b denies" "$DENY" "$P" "git -p checkout -b evil"
|
|
85
|
+
assert "primary: multi -c + switch -c denies" "$DENY" "$P" "git -c a=b -c c=d switch -c evil"
|
|
86
|
+
assert "primary: \$IFS substitution + -b denies" "$DENY" "$P" "git checkout\$IFS-b evil"
|
|
87
|
+
assert "primary: -c global opt + checkout main allows" "$ALLOW" "$P" "git -c x=y checkout main"
|
|
88
|
+
|
|
89
|
+
# ── The UNCONDITIONAL delta: deny even with NO sibling worktree (P has exactly 1). ─
|
|
90
|
+
# (P above already has no siblings — the denies above ARE the no-sibling case. Assert
|
|
91
|
+
# explicitly that worktree count is 1 here, so the intent is documented.)
|
|
92
|
+
wt_n=$(git -C "$P" worktree list | wc -l | tr -d ' ')
|
|
93
|
+
if [ "$wt_n" -eq 1 ]; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); printf ' FAIL: expected 1 worktree in fresh primary, got %s\n' "$wt_n" >&2; fi
|
|
94
|
+
|
|
95
|
+
# ── Primary checkout: ALLOW safe forms ───────────────────────────────────────────
|
|
96
|
+
assert "primary: checkout main allows (home)" "$ALLOW" "$P" "git checkout main"
|
|
97
|
+
assert "primary: switch main allows (home)" "$ALLOW" "$P" "git switch main"
|
|
98
|
+
assert "primary: checkout -- file allows (restore)" "$ALLOW" "$P" "git checkout -- seed"
|
|
99
|
+
assert "primary: checkout . allows (restore)" "$ALLOW" "$P" "git checkout ."
|
|
100
|
+
assert "primary: checkout HEAD -- file allows" "$ALLOW" "$P" "git checkout HEAD -- seed"
|
|
101
|
+
assert "primary: bare checkout allows" "$ALLOW" "$P" "git checkout"
|
|
102
|
+
assert "primary: git status allows (fast-path)" "$ALLOW" "$P" "git status"
|
|
103
|
+
assert "primary: git commit allows (fast-path)" "$ALLOW" "$P" "git commit -m x"
|
|
104
|
+
assert "primary: git log allows (fast-path)" "$ALLOW" "$P" "git log --oneline"
|
|
105
|
+
|
|
106
|
+
# ── Worktree pass-through: git-dir != git-common-dir → never blocked ──────────────
|
|
107
|
+
WT="$ROOT/primary-wt"
|
|
108
|
+
git -C "$P" worktree add -q -b wtbranch "$WT" >/dev/null 2>&1
|
|
109
|
+
assert "worktree cwd: checkout -b allows" "$ALLOW" "$WT" "git checkout -b anything"
|
|
110
|
+
assert "worktree cwd: switch -c allows" "$ALLOW" "$WT" "git switch -c anything"
|
|
111
|
+
# From the PRIMARY checkout, `git -C <worktree>` targets the worktree → allow.
|
|
112
|
+
assert "primary + git -C worktree: checkout -b allows" "$ALLOW" "$P" "git -C $WT checkout -b anything"
|
|
113
|
+
|
|
114
|
+
# ── master-default repo: home = master; create still denied ───────────────────────
|
|
115
|
+
M="$(new_repo master-primary master)"
|
|
116
|
+
assert "master repo: checkout master allows (home)" "$ALLOW" "$M" "git checkout master"
|
|
117
|
+
assert "master repo: checkout -b denies" "$DENY" "$M" "git checkout -b x"
|
|
118
|
+
assert "master repo: checkout main denies (main is not this repo's default... but seeded)" "$ALLOW" "$M" "git checkout main"
|
|
119
|
+
# ^ main+master are BOTH always in the seed set, so `checkout main` is allowed even in
|
|
120
|
+
# a master-default repo. That's intentional (both are canonical default names).
|
|
121
|
+
|
|
122
|
+
# ── Escape hatch ─────────────────────────────────────────────────────────────────
|
|
123
|
+
assert "escape hatch: checkout -b allows" "$ALLOW" "$P" "git checkout -b feature" "ALLOW_MAIN_BRANCHING=1"
|
|
124
|
+
|
|
125
|
+
# ── Folded hard-reset guard (worktree-count gated) ───────────────────────────────
|
|
126
|
+
# P now has 1 sibling worktree (WT added above) → count > 1 → hard reset denied.
|
|
127
|
+
assert "primary +wt: reset --hard denies" "$DENY" "$P" "git reset --hard HEAD"
|
|
128
|
+
assert "primary +wt: reset --keep denies" "$DENY" "$P" "git reset --keep HEAD"
|
|
129
|
+
assert "primary +wt: reset --soft allows (not hard)" "$ALLOW" "$P" "git reset --soft HEAD"
|
|
130
|
+
# A repo with a single worktree → hard reset is the operator's business → allow.
|
|
131
|
+
S="$(new_repo solo)"
|
|
132
|
+
assert "solo (1 wt): reset --hard allows" "$ALLOW" "$S" "git reset --hard HEAD"
|
|
133
|
+
|
|
134
|
+
# ── Fail-open cases ──────────────────────────────────────────────────────────────
|
|
135
|
+
assert "fail-open: empty command allows" "$ALLOW" "$P" ""
|
|
136
|
+
assert "fail-open: non-checkout command allows" "$ALLOW" "$P" "ls -la"
|
|
137
|
+
# Non-git cwd → fail open. Use a plain temp dir with a checkout-shaped command.
|
|
138
|
+
NG="$ROOT/notgit"; mkdir -p "$NG"
|
|
139
|
+
assert "fail-open: non-git cwd allows" "$ALLOW" "$NG" "git checkout -b x"
|
|
140
|
+
|
|
141
|
+
# ── Summary ──────────────────────────────────────────────────────────────────────
|
|
142
|
+
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
|
|
143
|
+
if [ "$FAILED" -eq 0 ]; then
|
|
144
|
+
printf 'ALL PASS\n'
|
|
145
|
+
exit 0
|
|
146
|
+
fi
|
|
147
|
+
exit 1
|
|
@@ -56,6 +56,17 @@
|
|
|
56
56
|
]
|
|
57
57
|
},
|
|
58
58
|
"hooks": {
|
|
59
|
+
"SessionStart": [
|
|
60
|
+
{
|
|
61
|
+
"matcher": "startup|resume|clear",
|
|
62
|
+
"hooks": [
|
|
63
|
+
{
|
|
64
|
+
"type": "command",
|
|
65
|
+
"command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" || true"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
],
|
|
59
70
|
"PostToolUse": [
|
|
60
71
|
{
|
|
61
72
|
"matcher": "Write",
|
|
@@ -130,6 +141,15 @@
|
|
|
130
141
|
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-commands.sh\""
|
|
131
142
|
}
|
|
132
143
|
]
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"matcher": "Bash",
|
|
147
|
+
"hooks": [
|
|
148
|
+
{
|
|
149
|
+
"type": "command",
|
|
150
|
+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-primary-checkout.sh\""
|
|
151
|
+
}
|
|
152
|
+
]
|
|
133
153
|
}
|
|
134
154
|
]
|
|
135
155
|
}
|
|
@@ -78,13 +78,17 @@ every field has one source and coverage is computed into the artifact. Run the
|
|
|
78
78
|
rollup at the **start of Show and Sync**, and after any Start/Resume/Pause/Close
|
|
79
79
|
edit to a stream file.
|
|
80
80
|
|
|
81
|
-
Procedure: run the
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
Procedure: run the **`forge-state-rollup.sh` executable** — the same mechanical
|
|
82
|
+
port the SessionStart hook invokes at boot (issue #22). It performs the milestone
|
|
83
|
+
Rollup 1.0 **and** the Stream Rollup join + write (glob stream files, derive each
|
|
84
|
+
row's `coordination`/`merge_readiness`, read `phase` from the linked milestone,
|
|
85
|
+
emit `stream: implicit` coverage rows, derive `merge_queue` from
|
|
86
|
+
`merge.readiness: ready`, run the step-0 sweep in the main checkout, write
|
|
87
|
+
`active.yml`) in one idempotent pass — single-sourced in FORGE.md → Stream Rollup.
|
|
88
|
+
Run `.claude/hooks/forge-state-rollup.sh`, then read `active.yml`; never hand-edit
|
|
89
|
+
it, and only fall back to the manual join if the executable is absent (pre-0.75.0).
|
|
90
|
+
Beyond that deterministic write, the Chief adds two **interactive** steps the
|
|
91
|
+
mechanical rollup omits:
|
|
88
92
|
|
|
89
93
|
- **Complete linked milestone → auto-close (not a prompt).** The rollup's **step
|
|
90
94
|
0 sweep** (FORGE.md → Stream Rollup) already ran: a stream whose
|
|
@@ -101,10 +105,11 @@ hand-edit `active.yml`. Beyond that deterministic write, the Chief adds two
|
|
|
101
105
|
file (the rollup emits a `stream: implicit` row for coverage), offer to
|
|
102
106
|
materialize a stream file for it. **Never auto-create.**
|
|
103
107
|
|
|
104
|
-
Note:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
Note: the `forge-state-rollup.sh` executable rolls up `index.yml` **and**
|
|
109
|
+
`active.yml` in the same pass, so implicit-row coverage reads a fresh `index.yml`
|
|
110
|
+
even when `/chief-of-staff` is invoked directly (no separate `/forge` boot needed).
|
|
111
|
+
The Chief writes only the derived caches (via the executable); it never hand-edits
|
|
112
|
+
`index.yml` or any milestone file.
|
|
108
113
|
|
|
109
114
|
### Board render + inbox (attended)
|
|
110
115
|
|
|
@@ -65,15 +65,19 @@ Cross-machine note: the flag file is machine-local, so a worktree (or the primar
|
|
|
65
65
|
- **Absent registry, but IDs already exist.** `.forge/reservations.yml` missing *and* the tree already holds allocated IDs (`requirements/*.yml` has `FR-`/`NFR-`/`DEF-`, or `.forge/decisions/` has `ADR-NNN`) → one-time nudge: *"No `reservations.yml` yet, but this project already allocates IDs. Concurrent worktrees will collide on the next number. Create it by reserving your next ID via the protocol (`planning`/`architecting` do this), or backfill existing IDs now."* (No IDs anywhere → silent; the first reservation creates the file lazily.)
|
|
66
66
|
- **Backfill scan (un-reserved landed IDs).** When `reservations.yml` exists, diff the IDs actually landed in the tree (`FR-`/`NFR-`/`DEF-` across `requirements/*.yml`; `ADR-NNN` across `.forge/decisions/`) against the ids recorded in `reservations.yml`. Any landed ID with **no** reservation entry → list them once: *"{N} ID(s) are in the tree but not in `reservations.yml` ({ids}) — landed before the protocol or via an un-reserved path. They are still respected (next = max(reserved, in-tree)+1), but appending backfill entries keeps the registry the single audit trail."* Advisory: surface, don't auto-write — backfilling is the operator's call.
|
|
67
67
|
|
|
68
|
-
**Stream Rollup (active.yml is derived
|
|
68
|
+
**Stream Rollup (active.yml is derived — rendered by the same hook).** `.forge/streams/active.yml` is regenerated from the stream files + active milestones by the **`forge-state-rollup.sh` SessionStart hook**, in the same mechanical pass that renders `index.yml` (the hook runs the milestone rollup first, then the join — so the Stream Rollup reads a fresh `index.yml` for implicit-row coverage). This includes the **step-0 completed-stream auto-close sweep** (FORGE.md → Stream Rollup): a milestone-backed stream whose milestone is now `complete`, not already `closed`, with no live worktree, is auto-closed (`status: closed`, `merge.readiness: merged`) *before* the join — the hook runs this **only in the main checkout** (it edits committed stream files), and announces each on stderr: *"auto-closed stale stream {id} — milestone complete, already on main."* (A `complete` milestone that still has a live worktree is left for the finalize path below, not swept.) **`active.yml` is derived — READ the hook's freshly-rendered cache; never hand-edit it, and do not regenerate it by hand.** Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed); the hook renders it in any checkout. **Fallback:** if no `[forge-state-rollup]` boot summary appeared (older CLI / non-Claude-Code adapter / unwired settings), run `.claude/hooks/forge-state-rollup.sh` yourself once (idempotent), then read it; only if the executable is absent, roll up by hand per FORGE.md → Stream Rollup. No `streams/` dir → the hook skips it.
|
|
69
69
|
|
|
70
70
|
**Ready-to-merge nudge (main checkout / streams without checkpoints).** From the just-regenerated `active.yml` — already swept of completed-milestone zombies by the rollup's step 0 — scan for streams with `coordination: ready` or a non-empty `merge_queue` **that are not auto-publishing via checkpoints**. Because the sweep ran first, anything surfaced here is genuinely mergeable (open work, not yet on main), so the nudge is truthful: *"{N} stream(s) ready to merge — run `/chief-of-staff` (merge safe) to integrate."* Pointer only — the cadence logic lives in the Chief's Merge Cadence Check; `forge` never merges. Nothing pending → silent.
|
|
71
71
|
|
|
72
|
-
### 1.0 State Rollup (index.yml is derived)
|
|
72
|
+
### 1.0 State Rollup (index.yml is derived — rendered mechanically at boot)
|
|
73
73
|
|
|
74
|
-
`index.yml` is a **derived registry
|
|
74
|
+
`index.yml` is a **derived registry**, regenerated from the milestone files (plus each row's git-computed `release_state`) by the **`forge-state-rollup.sh` SessionStart hook** — mechanically, at every session start, **before** this skill runs. **Never hand-edit `index.yml`.** Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed): rendering it is a local-cache write, so any session may render its own copy (see FORGE.md → State Ownership, Derived class).
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
**The hook is the rollup; this skill only READS the freshly-rendered cache.** Making the rollup an executable invoked by a hook — not prose the agent performs — is the fix for issue #22 (pain-6: advisory rules fail). A boot session used to read a stale cache and reconstruct truth by hand when project memory carried an "index is stale, work around it" habit; a mechanical hook removes that trust dependency. Do **not** regenerate `index.yml` by hand — the hook already did. Look for its one-line boot summary (`[forge-state-rollup] rendered index.yml …`) in context; then read the cache.
|
|
77
|
+
|
|
78
|
+
**Fallback — only when the hook did NOT run.** On an older Claude Code CLI without SessionStart, a non-Claude-Code adapter, or a repo whose `.claude/settings.json` lacks the SessionStart wiring, no summary line appears. Only then run the executable yourself once — `.claude/hooks/forge-state-rollup.sh` (add `--project {worktree_path}` when the recorded path differs from cwd) — and read its output. It is deterministic + idempotent, so running it when the hook already ran is a harmless no-op. If the executable itself is absent (pre-0.75.0 install), fall back to the manual procedure below.
|
|
79
|
+
|
|
80
|
+
**Manual procedure (executable absent only — the executable's faithful spec):**
|
|
77
81
|
1. Glob `.forge/state/milestone-*.yml`.
|
|
78
82
|
2. For each, read `milestone.id`, `milestone.name`, `current.status`, `lifecycle.{deferred_at, resumed_at}`, and the last-touched date as `current.last_updated` → else legacy `progress.last_update` → else null. (The fallback keeps pre-0.19.0 milestone files self-sourcing without editing them; active milestones gain `current.last_updated` on their next transition.)
|
|
79
83
|
3. Derive the registry `status`:
|
|
@@ -85,9 +89,9 @@ Rollup procedure (deterministic + idempotent):
|
|
|
85
89
|
- **Fold is the source.** Run `.claude/hooks/forge-release-fold.sh m-{id}` per row and write its stdout `release_state=` value verbatim (`unmerged|merged|validated`) — agent prose never re-derives `release_state`; the fold binary is the only source.
|
|
86
90
|
- **Degrade, don't block.** Hook missing or exit 2 → write `release_state: unknown`, emit ONE advisory line — *"release fold unavailable for m-{id} — release_state: unknown"* — and continue. A broken fold must never break boot.
|
|
87
91
|
|
|
88
|
-
Output is a pure function of the milestone files, so two sessions regenerating it produce identical bytes — it never needs a hand-merge. **
|
|
92
|
+
Output is a pure function of the milestone files, so two sessions regenerating it produce identical bytes — it never needs a hand-merge. **The `forge-state-rollup.sh` hook renders the cache in any checkout (main OR worktree — it is a gitignored local write); worktree agents still never write the *committed* derived-source state** (they edit only their own `milestone-{id}.yml`; the Stream Rollup's step-0 sweep, which edits committed stream files, is main-checkout-only inside the hook).
|
|
89
93
|
|
|
90
|
-
**
|
|
94
|
+
**"Run Rollup (1.0)" (used below after a milestone CRUD) = run the executable.** The SessionStart hook fires only at boot, so after a mid-session promote/defer/resume/delete you must re-render the caches yourself: run `.claude/hooks/forge-state-rollup.sh` (it does both the milestone rollup **and** the Stream Rollup in one idempotent pass; add `--project {worktree_path}` if the recorded path differs from cwd), then read the result. This replaces the old "hand-regenerate `index.yml`" step everywhere the phrase appears. Executable absent (pre-0.75.0) → fall back to the manual procedure above.
|
|
91
95
|
|
|
92
96
|
### 1.1 Milestone Selection
|
|
93
97
|
|
|
@@ -97,7 +101,7 @@ Check state files:
|
|
|
97
101
|
3. Neither → init/tier detection.
|
|
98
102
|
|
|
99
103
|
**With `state/index.yml`:**
|
|
100
|
-
1.
|
|
104
|
+
1. **Read the freshly-rendered `index.yml`** (the SessionStart hook already regenerated it this boot — §1.0) for active milestones. No `[forge-state-rollup]` summary appeared → run the executable once first (§1.0 fallback), then read.
|
|
101
105
|
2. **Check arg first.** `/forge 2` or `/forge "Auth system"`:
|
|
102
106
|
- Match IDs (exact) or names (case-insensitive substring)
|
|
103
107
|
- Found → auto-select: *"Resuming {id}: [{name}] -- {current.status}, {percent}%"*
|
|
@@ -390,7 +390,26 @@ User selects per stack.
|
|
|
390
390
|
|
|
391
391
|
**Confirm with the user first** in brownfield (this edits a user-owned file). In greenfield with no existing `.gitignore`, write it directly. Re-run is idempotent — the carve-out lines `grep` to themselves.
|
|
392
392
|
|
|
393
|
-
8. **
|
|
393
|
+
8. **Wire the primary-checkout weld (`core.hooksPath`).** Welds the primary checkout to the default branch so all branch work happens in worktrees — every tier (issue #21, ADR-031). The tracked `.forge/git-hooks/pre-commit` backstop only fires once `core.hooksPath` points at it. This is per-clone git config, so init owns it. **Detect + warn, never clobber:**
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
want=".forge/git-hooks"
|
|
397
|
+
cur=$(git config --get core.hooksPath 2>/dev/null || true)
|
|
398
|
+
if [ -z "$cur" ] || [ "$cur" = "$want" ]; then
|
|
399
|
+
git config core.hooksPath "$want" # repo-relative → resolves inside every worktree
|
|
400
|
+
echo "[Forge] core.hooksPath → $want (primary-checkout weld active)."
|
|
401
|
+
else
|
|
402
|
+
echo "[Forge] core.hooksPath is already set to '$cur' (Husky/lefthook?). NOT overwriting."
|
|
403
|
+
echo " The primary-checkout pre-commit backstop (.forge/git-hooks/pre-commit) is"
|
|
404
|
+
echo " therefore inactive. To keep the weld, chain it into your existing hooks dir"
|
|
405
|
+
echo " (call .forge/git-hooks/pre-commit from your pre-commit), or accept the"
|
|
406
|
+
echo " PreToolUse-only coverage (Claude sessions are still guarded)."
|
|
407
|
+
fi
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**WHY repo-relative** (`.forge/git-hooks`, not an absolute path): an absolute `core.hooksPath` silently skips in linked worktrees (the documented Husky/lefthook worktree hazard — see `orchestrating/bootstrap-checks.md`); a repo-relative path resolves under each worktree checkout. **WHY never clobber:** silently disabling a consumer's existing hook framework is unacceptable.
|
|
411
|
+
|
|
412
|
+
9. **State-sync commit**: `git add .gitignore .claude/settings.json .claude/skills/ .claude/agents/ .claude/hooks/ .forge/` then `git commit -m "chore(forge): initialize forge state + track product files"` (scoped; never `git add .`) — so the new project's state and Forge product files are both in git from the start. (The tracked `.forge/git-hooks/` is included by the `.forge/` path; `core.hooksPath` itself is local git config, not a tracked file, so it needs no staging.)
|
|
394
413
|
|
|
395
414
|
*"Initialized. Ready?"*
|
|
396
415
|
|